import { ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, } from "./threadSelectionStore"; const THREAD_A = ThreadId.make("thread-a"); const THREAD_B = ThreadId.make("thread-b"); const THREAD_C = ThreadId.make("thread-c"); const THREAD_D = ThreadId.make("thread-d"); const THREAD_E = ThreadId.make("thread-e"); const ORDERED = [THREAD_A, THREAD_B, THREAD_C, THREAD_D, THREAD_E] as const; describe("threadSelectionStore", () => { beforeEach(() => { useThreadSelectionStore.getState().clearSelection(); }); describe("bulk deletion cleanup", () => { it("clears missing selection rows and completed deletions while retaining a failed thread", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); store.toggleThread(THREAD_C); const selected = [...useThreadSelectionStore.getState().selectedThreadKeys]; // A deleted successfully but its shell has not refreshed; B failed; // C was deleted elsewhere and never entered this client's delete loop. const existingThreads = new Set([THREAD_A, THREAD_B]); store.removeFromSelection( getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), (key) => existingThreads.has(ThreadId.make(key)), ), ); expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([THREAD_B]); expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); }); it("exits selection mode when the last selected thread disappeared elsewhere", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.removeFromSelection( getThreadKeysToDeselectAfterDelete([THREAD_A], new Set(), () => false), ); expect(useThreadSelectionStore.getState().hasSelection()).toBe(false); expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); }); it("keeps unprocessed and hidden live threads and selections added while deletion was pending", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); store.toggleThread(THREAD_C); const selected = [...useThreadSelectionStore.getState().selectedThreadKeys]; store.toggleThread(THREAD_D); // Only A completed before interruption. B is unprocessed; C still // has a shell even though its row is outside the rendered page. store.removeFromSelection( getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), () => true), ); expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([ THREAD_B, THREAD_C, THREAD_D, ]); expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_D); }); }); describe("toggleThread", () => { it("adds a thread to empty selection", () => { useThreadSelectionStore.getState().toggleThread(THREAD_A); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); expect(state.selectedThreadKeys.size).toBe(1); expect(state.anchorThreadKey).toBe(THREAD_A); }); it("removes a thread that is already selected", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_A); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_A)).toBe(false); expect(state.selectedThreadKeys.size).toBe(0); }); it("preserves existing selections when toggling a new thread", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); expect(state.selectedThreadKeys.size).toBe(2); }); it("sets anchor to the newly added thread", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); }); it("preserves anchor when deselecting a non-anchor thread", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); store.toggleThread(THREAD_A); // deselect A, anchor should stay B expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); }); }); describe("setAnchor", () => { it("sets anchor without adding to selection", () => { useThreadSelectionStore.getState().setAnchor(THREAD_B); const state = useThreadSelectionStore.getState(); expect(state.anchorThreadKey).toBe(THREAD_B); expect(state.selectedThreadKeys.size).toBe(0); }); it("enables range select from a plain-click anchor", () => { const store = useThreadSelectionStore.getState(); store.setAnchor(THREAD_B); // simulate plain-click navigate to B store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); expect(state.selectedThreadKeys.size).toBe(3); }); it("is a no-op when anchor is already set to the same thread", () => { const store = useThreadSelectionStore.getState(); store.setAnchor(THREAD_B); const stateBefore = useThreadSelectionStore.getState(); store.setAnchor(THREAD_B); const stateAfter = useThreadSelectionStore.getState(); // Should be referentially the same (no unnecessary re-render) expect(stateAfter).toBe(stateBefore); }); it("survives clearSelection followed by setAnchor", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); store.clearSelection(); store.setAnchor(THREAD_C); const state = useThreadSelectionStore.getState(); expect(state.anchorThreadKey).toBe(THREAD_C); expect(state.selectedThreadKeys.size).toBe(0); }); }); describe("rangeSelectTo", () => { it("selects a single thread when no anchor exists", () => { useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); expect(state.selectedThreadKeys.size).toBe(1); expect(state.anchorThreadKey).toBe(THREAD_C); }); it("selects range from anchor to target (forward)", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_B); // sets anchor to B store.rangeSelectTo(THREAD_D, ORDERED); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); expect(state.selectedThreadKeys.size).toBe(3); }); it("selects range from anchor to target (backward)", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_D); // sets anchor to D store.rangeSelectTo(THREAD_B, ORDERED); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); expect(state.selectedThreadKeys.size).toBe(3); }); it("keeps anchor stable across multiple range selects", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_B); // anchor = B store.rangeSelectTo(THREAD_D, ORDERED); // selects B-D store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B) const state = useThreadSelectionStore.getState(); expect(state.anchorThreadKey).toBe(THREAD_B); expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_E)).toBe(true); }); it("falls back to toggle when anchor is not in the ordered list", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); // anchor = A // Range-select with a list that does NOT contain the anchor store.rangeSelectTo(THREAD_C, [THREAD_B, THREAD_C, THREAD_D]); const state = useThreadSelectionStore.getState(); // Should have added C and reset anchor to C expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); expect(state.anchorThreadKey).toBe(THREAD_C); }); it("falls back to toggle when target is not in the ordered list", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_B); // anchor = B const unknownThread = ThreadId.make("thread-unknown"); store.rangeSelectTo(unknownThread, ORDERED); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(unknownThread)).toBe(true); expect(state.anchorThreadKey).toBe(unknownThread); }); it("selects the single thread when anchor equals target", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_C); // anchor = C store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); expect(state.selectedThreadKeys.size).toBe(1); }); it("preserves previously selected threads outside the range", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); // select A, anchor = A store.toggleThread(THREAD_B); // select B, anchor = B // Now shift-select from B (anchor) to D — should add B, C, D but keep A store.rangeSelectTo(THREAD_D, ORDERED); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); expect(state.selectedThreadKeys.size).toBe(4); }); }); describe("clearSelection", () => { it("clears all selected threads and anchor", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); store.clearSelection(); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.size).toBe(0); expect(state.anchorThreadKey).toBeNull(); }); it("is a no-op when already empty", () => { const stateBefore = useThreadSelectionStore.getState(); stateBefore.clearSelection(); const stateAfter = useThreadSelectionStore.getState(); // Should be referentially the same (no unnecessary re-render) expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys); }); }); describe("removeFromSelection", () => { it("removes specified threads from selection", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); store.toggleThread(THREAD_C); store.removeFromSelection([THREAD_A, THREAD_C]); const state = useThreadSelectionStore.getState(); expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); expect(state.selectedThreadKeys.size).toBe(1); }); it("clears anchor when the anchor thread is removed", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); // anchor = B store.removeFromSelection([THREAD_B]); expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); }); it("preserves anchor when the anchor thread is not removed", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); // anchor = B store.removeFromSelection([THREAD_A]); expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); }); it("is a no-op when none of the specified threads are selected", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); const stateBefore = useThreadSelectionStore.getState(); store.removeFromSelection([THREAD_B, THREAD_C]); const stateAfter = useThreadSelectionStore.getState(); expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys); }); }); describe("hasSelection", () => { it("returns false when nothing is selected", () => { expect(useThreadSelectionStore.getState().hasSelection()).toBe(false); }); it("returns true when threads are selected", () => { useThreadSelectionStore.getState().toggleThread(THREAD_A); expect(useThreadSelectionStore.getState().hasSelection()).toBe(true); }); it("returns false after clearing selection", () => { const store = useThreadSelectionStore.getState(); store.toggleThread(THREAD_A); store.clearSelection(); expect(useThreadSelectionStore.getState().hasSelection()).toBe(false); }); }); });