1import { ThreadId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it } from "vite-plus/test";
3
4import {
5 getThreadKeysToDeselectAfterDelete,
6 useThreadSelectionStore,
7} from "./threadSelectionStore";
8
9const THREAD_A = ThreadId.make("thread-a");
10const THREAD_B = ThreadId.make("thread-b");
11const THREAD_C = ThreadId.make("thread-c");
12const THREAD_D = ThreadId.make("thread-d");
13const THREAD_E = ThreadId.make("thread-e");
14
15const ORDERED = [THREAD_A, THREAD_B, THREAD_C, THREAD_D, THREAD_E] as const;
16
17describe("threadSelectionStore", () => {
18 beforeEach(() => {
19 useThreadSelectionStore.getState().clearSelection();
20 });
21
22 describe("bulk deletion cleanup", () => {
23 it("clears missing selection rows and completed deletions while retaining a failed thread", () => {
24 const store = useThreadSelectionStore.getState();
25 store.toggleThread(THREAD_A);
26 store.toggleThread(THREAD_B);
27 store.toggleThread(THREAD_C);
28 const selected = [...useThreadSelectionStore.getState().selectedThreadKeys];
29 // A deleted successfully but its shell has not refreshed; B failed;
30 // C was deleted elsewhere and never entered this client's delete loop.
31 const existingThreads = new Set([THREAD_A, THREAD_B]);
32 store.removeFromSelection(
33 getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), (key) =>
34 existingThreads.has(ThreadId.make(key)),
35 ),
36 );
37
38 expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([THREAD_B]);
39 expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull();
40 });
41
42 it("exits selection mode when the last selected thread disappeared elsewhere", () => {
43 const store = useThreadSelectionStore.getState();
44 store.toggleThread(THREAD_A);
45 store.removeFromSelection(
46 getThreadKeysToDeselectAfterDelete([THREAD_A], new Set(), () => false),
47 );
48
49 expect(useThreadSelectionStore.getState().hasSelection()).toBe(false);
50 expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull();
51 });
52
53 it("keeps unprocessed and hidden live threads and selections added while deletion was pending", () => {
54 const store = useThreadSelectionStore.getState();
55 store.toggleThread(THREAD_A);
56 store.toggleThread(THREAD_B);
57 store.toggleThread(THREAD_C);
58 const selected = [...useThreadSelectionStore.getState().selectedThreadKeys];
59 store.toggleThread(THREAD_D);
60 // Only A completed before interruption. B is unprocessed; C still
61 // has a shell even though its row is outside the rendered page.
62 store.removeFromSelection(
63 getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), () => true),
64 );
65
66 expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([
67 THREAD_B,
68 THREAD_C,
69 THREAD_D,
70 ]);
71 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_D);
72 });
73 });
74
75 describe("toggleThread", () => {
76 it("adds a thread to empty selection", () => {
77 useThreadSelectionStore.getState().toggleThread(THREAD_A);
78
79 const state = useThreadSelectionStore.getState();
80 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true);
81 expect(state.selectedThreadKeys.size).toBe(1);
82 expect(state.anchorThreadKey).toBe(THREAD_A);
83 });
84
85 it("removes a thread that is already selected", () => {
86 const store = useThreadSelectionStore.getState();
87 store.toggleThread(THREAD_A);
88 store.toggleThread(THREAD_A);
89
90 const state = useThreadSelectionStore.getState();
91 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(false);
92 expect(state.selectedThreadKeys.size).toBe(0);
93 });
94
95 it("preserves existing selections when toggling a new thread", () => {
96 const store = useThreadSelectionStore.getState();
97 store.toggleThread(THREAD_A);
98 store.toggleThread(THREAD_B);
99
100 const state = useThreadSelectionStore.getState();
101 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true);
102 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
103 expect(state.selectedThreadKeys.size).toBe(2);
104 });
105
106 it("sets anchor to the newly added thread", () => {
107 const store = useThreadSelectionStore.getState();
108 store.toggleThread(THREAD_A);
109 store.toggleThread(THREAD_B);
110
111 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B);
112 });
113
114 it("preserves anchor when deselecting a non-anchor thread", () => {
115 const store = useThreadSelectionStore.getState();
116 store.toggleThread(THREAD_A);
117 store.toggleThread(THREAD_B);
118 store.toggleThread(THREAD_A); // deselect A, anchor should stay B
119
120 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B);
121 });
122 });
123
124 describe("setAnchor", () => {
125 it("sets anchor without adding to selection", () => {
126 useThreadSelectionStore.getState().setAnchor(THREAD_B);
127
128 const state = useThreadSelectionStore.getState();
129 expect(state.anchorThreadKey).toBe(THREAD_B);
130 expect(state.selectedThreadKeys.size).toBe(0);
131 });
132
133 it("enables range select from a plain-click anchor", () => {
134 const store = useThreadSelectionStore.getState();
135 store.setAnchor(THREAD_B); // simulate plain-click navigate to B
136 store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D
137
138 const state = useThreadSelectionStore.getState();
139 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
140 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
141 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
142 expect(state.selectedThreadKeys.size).toBe(3);
143 });
144
145 it("is a no-op when anchor is already set to the same thread", () => {
146 const store = useThreadSelectionStore.getState();
147 store.setAnchor(THREAD_B);
148 const stateBefore = useThreadSelectionStore.getState();
149 store.setAnchor(THREAD_B);
150 const stateAfter = useThreadSelectionStore.getState();
151
152 // Should be referentially the same (no unnecessary re-render)
153 expect(stateAfter).toBe(stateBefore);
154 });
155
156 it("survives clearSelection followed by setAnchor", () => {
157 const store = useThreadSelectionStore.getState();
158 store.toggleThread(THREAD_A);
159 store.toggleThread(THREAD_B);
160 store.clearSelection();
161 store.setAnchor(THREAD_C);
162
163 const state = useThreadSelectionStore.getState();
164 expect(state.anchorThreadKey).toBe(THREAD_C);
165 expect(state.selectedThreadKeys.size).toBe(0);
166 });
167 });
168
169 describe("rangeSelectTo", () => {
170 it("selects a single thread when no anchor exists", () => {
171 useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED);
172
173 const state = useThreadSelectionStore.getState();
174 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
175 expect(state.selectedThreadKeys.size).toBe(1);
176 expect(state.anchorThreadKey).toBe(THREAD_C);
177 });
178
179 it("selects range from anchor to target (forward)", () => {
180 const store = useThreadSelectionStore.getState();
181 store.toggleThread(THREAD_B); // sets anchor to B
182 store.rangeSelectTo(THREAD_D, ORDERED);
183
184 const state = useThreadSelectionStore.getState();
185 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
186 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
187 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
188 expect(state.selectedThreadKeys.size).toBe(3);
189 });
190
191 it("selects range from anchor to target (backward)", () => {
192 const store = useThreadSelectionStore.getState();
193 store.toggleThread(THREAD_D); // sets anchor to D
194 store.rangeSelectTo(THREAD_B, ORDERED);
195
196 const state = useThreadSelectionStore.getState();
197 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
198 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
199 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
200 expect(state.selectedThreadKeys.size).toBe(3);
201 });
202
203 it("keeps anchor stable across multiple range selects", () => {
204 const store = useThreadSelectionStore.getState();
205 store.toggleThread(THREAD_B); // anchor = B
206 store.rangeSelectTo(THREAD_D, ORDERED); // selects B-D
207 store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B)
208
209 const state = useThreadSelectionStore.getState();
210 expect(state.anchorThreadKey).toBe(THREAD_B);
211 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
212 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
213 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
214 expect(state.selectedThreadKeys.has(THREAD_E)).toBe(true);
215 });
216
217 it("falls back to toggle when anchor is not in the ordered list", () => {
218 const store = useThreadSelectionStore.getState();
219 store.toggleThread(THREAD_A); // anchor = A
220 // Range-select with a list that does NOT contain the anchor
221 store.rangeSelectTo(THREAD_C, [THREAD_B, THREAD_C, THREAD_D]);
222
223 const state = useThreadSelectionStore.getState();
224 // Should have added C and reset anchor to C
225 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
226 expect(state.anchorThreadKey).toBe(THREAD_C);
227 });
228
229 it("falls back to toggle when target is not in the ordered list", () => {
230 const store = useThreadSelectionStore.getState();
231 store.toggleThread(THREAD_B); // anchor = B
232 const unknownThread = ThreadId.make("thread-unknown");
233 store.rangeSelectTo(unknownThread, ORDERED);
234
235 const state = useThreadSelectionStore.getState();
236 expect(state.selectedThreadKeys.has(unknownThread)).toBe(true);
237 expect(state.anchorThreadKey).toBe(unknownThread);
238 });
239
240 it("selects the single thread when anchor equals target", () => {
241 const store = useThreadSelectionStore.getState();
242 store.toggleThread(THREAD_C); // anchor = C
243 store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C
244
245 const state = useThreadSelectionStore.getState();
246 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
247 expect(state.selectedThreadKeys.size).toBe(1);
248 });
249
250 it("preserves previously selected threads outside the range", () => {
251 const store = useThreadSelectionStore.getState();
252 store.toggleThread(THREAD_A); // select A, anchor = A
253 store.toggleThread(THREAD_B); // select B, anchor = B
254
255 // Now shift-select from B (anchor) to D — should add B, C, D but keep A
256 store.rangeSelectTo(THREAD_D, ORDERED);
257
258 const state = useThreadSelectionStore.getState();
259 expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true);
260 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
261 expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true);
262 expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true);
263 expect(state.selectedThreadKeys.size).toBe(4);
264 });
265 });
266
267 describe("clearSelection", () => {
268 it("clears all selected threads and anchor", () => {
269 const store = useThreadSelectionStore.getState();
270 store.toggleThread(THREAD_A);
271 store.toggleThread(THREAD_B);
272 store.clearSelection();
273
274 const state = useThreadSelectionStore.getState();
275 expect(state.selectedThreadKeys.size).toBe(0);
276 expect(state.anchorThreadKey).toBeNull();
277 });
278
279 it("is a no-op when already empty", () => {
280 const stateBefore = useThreadSelectionStore.getState();
281 stateBefore.clearSelection();
282 const stateAfter = useThreadSelectionStore.getState();
283
284 // Should be referentially the same (no unnecessary re-render)
285 expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys);
286 });
287 });
288
289 describe("removeFromSelection", () => {
290 it("removes specified threads from selection", () => {
291 const store = useThreadSelectionStore.getState();
292 store.toggleThread(THREAD_A);
293 store.toggleThread(THREAD_B);
294 store.toggleThread(THREAD_C);
295 store.removeFromSelection([THREAD_A, THREAD_C]);
296
297 const state = useThreadSelectionStore.getState();
298 expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true);
299 expect(state.selectedThreadKeys.size).toBe(1);
300 });
301
302 it("clears anchor when the anchor thread is removed", () => {
303 const store = useThreadSelectionStore.getState();
304 store.toggleThread(THREAD_A);
305 store.toggleThread(THREAD_B); // anchor = B
306 store.removeFromSelection([THREAD_B]);
307
308 expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull();
309 });
310
311 it("preserves anchor when the anchor thread is not removed", () => {
312 const store = useThreadSelectionStore.getState();
313 store.toggleThread(THREAD_A);
314 store.toggleThread(THREAD_B); // anchor = B
315 store.removeFromSelection([THREAD_A]);
316
317 expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B);
318 });
319
320 it("is a no-op when none of the specified threads are selected", () => {
321 const store = useThreadSelectionStore.getState();
322 store.toggleThread(THREAD_A);
323 const stateBefore = useThreadSelectionStore.getState();
324 store.removeFromSelection([THREAD_B, THREAD_C]);
325 const stateAfter = useThreadSelectionStore.getState();
326
327 expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys);
328 });
329 });
330
331 describe("hasSelection", () => {
332 it("returns false when nothing is selected", () => {
333 expect(useThreadSelectionStore.getState().hasSelection()).toBe(false);
334 });
335
336 it("returns true when threads are selected", () => {
337 useThreadSelectionStore.getState().toggleThread(THREAD_A);
338 expect(useThreadSelectionStore.getState().hasSelection()).toBe(true);
339 });
340
341 it("returns false after clearing selection", () => {
342 const store = useThreadSelectionStore.getState();
343 store.toggleThread(THREAD_A);
344 store.clearSelection();
345 expect(useThreadSelectionStore.getState().hasSelection()).toBe(false);
346 });
347 });
348});