import { ThreadId } from "@t3tools/contracts";import { beforeEach, describe, expect, it } from "vite-plus/test"; import { 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("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); }); });});
import { ThreadId } from "@t3tools/contracts";
import { beforeEach, describe, expect, it } from "vite-plus/test";
import { 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("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);
});
});
});