Open History to see the last commit
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import type { Atom } from "effect/reactivity";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { createPendingThreadOrder } from "../features/threads/threadOrder";
7import { makeThreadShellFixture } from "../test-fixtures";
8import { appAtomRegistry } from "./atom-registry";
9import {
10 beginPendingThreadOrder,
11 getPendingThreadOrder,
12 pendingThreadOrderAtom,
13} from "./thread-order";
14import { environmentServerConfigsAtom } from "./server";
15import { steadyNavigationThreadShellsAtom } from "./threads";
16
17vi.mock("./atom-registry", async () => {
18 const { AtomRegistry } = await import("effect/reactivity");
19 return { appAtomRegistry: AtomRegistry.make() };
20});
21vi.mock("./threads", async () => {
22 const { Atom } = await import("effect/reactivity");
23 return {
24 steadyNavigationThreadShellsAtom: Atom.make([]).pipe(Atom.keepAlive),
25 };
26});
27vi.mock("./server", async () => {
28 const { Atom } = await import("effect/reactivity");
29 return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) };
30});
31vi.mock("./use-thread-outbox", async () => {
32 const { Atom } = await import("effect/reactivity");
33 return { queuedThreadKeysAtom: Atom.make(new Set<string>()).pipe(Atom.keepAlive) };
34});
35
36type ServerConfigs = Atom.Type<typeof environmentServerConfigsAtom>;
37type ServerConfig = ServerConfigs extends ReadonlyMap<EnvironmentId, infer Config> ? Config : never;
38const configsAtom = environmentServerConfigsAtom as Atom.Writable<ServerConfigs, ServerConfigs>;
39const shellsAtom = steadyNavigationThreadShellsAtom as Atom.Writable<
40 readonly EnvironmentThreadShell[],
41 readonly EnvironmentThreadShell[]
42>;
43
44function fixture() {
45 const rows = ["a", "b"].map((id, index) =>
46 makeThreadShellFixture({
47 id: ThreadId.make(id),
48 environmentId: EnvironmentId.make("env"),
49 createdAt: `2026-06-01T0${2 - index}:00:00.000Z`,
50 }),
51 );
52 appAtomRegistry.set(shellsAtom, rows);
53 const pending = createPendingThreadOrder({
54 section: "active",
55 ordered: rows,
56 movedId: "env:b",
57 direction: "up",
58 assignments: [
59 { id: "env:b", orderKey: "aa" },
60 { id: "env:a", orderKey: "bb" },
61 ],
62 });
63 const start = () => beginPendingThreadOrder(pending);
64 const upsert = (id: string, key: string) => {
65 const current = appAtomRegistry.get(shellsAtom);
66 appAtomRegistry.set(
67 shellsAtom,
68 current.map((row) => (row.id === id ? { ...row, activeOrderKey: key } : row)),
69 );
70 };
71 return { rows, start, upsert };
72}
73
74afterEach(() => appAtomRegistry.reset());
75
76describe("shared mobile pending move", () => {
77 it("blocks another pickup after receipts and clears on final canonical upsert", () => {
78 const { start, upsert } = fixture();
79 const move = start();
80 move.complete();
81 expect(getPendingThreadOrder()).not.toBeNull();
82 upsert("b", "aa");
83 expect(getPendingThreadOrder()).not.toBeNull();
84 upsert("a", "bb");
85 expect(getPendingThreadOrder()).toBeNull();
86 expect(move.isPending()).toBe(false);
87 });
88
89 it("waits for receipts when shells arrive first", () => {
90 const { start, upsert } = fixture();
91 const move = start();
92 upsert("b", "aa");
93 upsert("a", "bb");
94 expect(getPendingThreadOrder()).not.toBeNull();
95 move.complete();
96 expect(getPendingThreadOrder()).toBeNull();
97 });
98
99 it("releases a cancelled move without restoring old canonical keys", () => {
100 const { start, upsert } = fixture();
101 const move = start();
102 upsert("b", "aa");
103 move.cancel();
104 expect(getPendingThreadOrder()).toBeNull();
105 expect(appAtomRegistry.get(shellsAtom)[1]?.activeOrderKey).toBe("aa");
106 const next = start();
107 move.cancel();
108 expect(next.isPending()).toBe(true);
109 next.cancel();
110 });
111
112 it("stops remaining writes when a canonical membership change invalidates the move", () => {
113 const { rows, start } = fixture();
114 const move = start();
115 appAtomRegistry.set(shellsAtom, rows.slice(1));
116 expect(move.isPending()).toBe(false);
117 expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull();
118 move.complete();
119 appAtomRegistry.set(shellsAtom, rows);
120 expect(getPendingThreadOrder()).toBeNull();
121 });
122
123 it("retires the hold when a snooze expiry changes the section without a shell event", () => {
124 vi.useFakeTimers();
125 try {
126 vi.setSystemTime(new Date("2026-06-01T03:00:00.000Z"));
127 const { rows, start } = fixture();
128 // Only the snooze capability matters to the section filter; afterEach's
129 // registry reset restores the empty configs.
130 const config = { environment: { capabilities: { threadSnooze: true } } } as ServerConfig;
131 appAtomRegistry.set(configsAtom, new Map([[EnvironmentId.make("env"), config]]));
132 const snoozed = makeThreadShellFixture({
133 id: ThreadId.make("c"),
134 environmentId: EnvironmentId.make("env"),
135 createdAt: "2026-06-01T00:00:00.000Z",
136 snoozedUntil: "2026-06-01T03:00:05.000Z",
137 });
138 appAtomRegistry.set(shellsAtom, [...rows, snoozed]);
139 start();
140 expect(getPendingThreadOrder()).not.toBeNull();
141
142 vi.advanceTimersByTime(5_000);
143
144 expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull();
145 } finally {
146 vi.useRealTimers();
147 }
148 });
149
150 it("tears down a superseded hold's clocks when another hold begins", () => {
151 vi.useFakeTimers();
152 try {
153 vi.setSystemTime(new Date("2026-06-01T03:00:00.000Z"));
154 const { rows, start } = fixture();
155 const snoozed = makeThreadShellFixture({
156 id: ThreadId.make("c"),
157 environmentId: EnvironmentId.make("env"),
158 createdAt: "2026-06-01T00:00:00.000Z",
159 snoozedUntil: "2026-06-01T04:00:00.000Z",
160 });
161 appAtomRegistry.set(shellsAtom, [...rows, snoozed]);
162 start();
163 start().cancel();
164 expect(vi.getTimerCount()).toBe(0);
165 } finally {
166 vi.useRealTimers();
167 }
168 });
169});