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 { environmentThreadShells } from "./threads";
15
16vi.mock("./atom-registry", async () => {
17 const { AtomRegistry } = await import("effect/reactivity");
18 return { appAtomRegistry: AtomRegistry.make() };
19});
20vi.mock("./threads", async () => {
21 const { Atom } = await import("effect/reactivity");
22 return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } };
23});
24vi.mock("./server", async () => {
25 const { Atom } = await import("effect/reactivity");
26 return {
27 threadListEnvironmentsAtom: Atom.make({
28 settlementEnvironmentIds: new Set(),
29 snoozeEnvironmentIds: new Set(),
30 }).pipe(Atom.keepAlive),
31 };
32});
33vi.mock("./use-thread-outbox", async () => {
34 const { Atom } = await import("effect/reactivity");
35 return { queuedThreadKeysAtom: Atom.make(new Set<string>()).pipe(Atom.keepAlive) };
36});
37
38// The mocked shell source is writable so tests can deliver canonical upserts.
39const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable<
40 readonly EnvironmentThreadShell[],
41 readonly EnvironmentThreadShell[]
42>;
43
44function fixture() {
45 // The shared section helper also reads lineage/settled/snooze fields, so
46 // partial casts break when it grows — build complete shells instead.
47 const rows = ["a", "b"].map((id, index) =>
48 makeThreadShellFixture({
49 id: ThreadId.make(id),
50 environmentId: EnvironmentId.make("env"),
51 createdAt: `2026-06-01T0${2 - index}:00:00.000Z`,
52 }),
53 );
54 appAtomRegistry.set(shellsAtom, rows);
55 const pending = createPendingThreadOrder({
56 section: "active",
57 ordered: rows,
58 movedId: "env:b",
59 direction: "up",
60 assignments: [
61 { id: "env:b", orderKey: "aa" },
62 { id: "env:a", orderKey: "bb" },
63 ],
64 });
65 const start = () => beginPendingThreadOrder(pending);
66 const upsert = (id: string, key: string) => {
67 const current = appAtomRegistry.get(shellsAtom);
68 appAtomRegistry.set(
69 shellsAtom,
70 current.map((row) => (row.id === id ? { ...row, activeOrderKey: key } : row)),
71 );
72 };
73 return { rows, start, upsert };
74}
75
76afterEach(() => appAtomRegistry.reset());
77
78describe("shared mobile pending move", () => {
79 it("blocks another pickup after receipts and clears on final canonical upsert", () => {
80 const { start, upsert } = fixture();
81 const move = start();
82 move.complete();
83 expect(getPendingThreadOrder()).not.toBeNull();
84 upsert("b", "aa");
85 expect(getPendingThreadOrder()).not.toBeNull();
86 upsert("a", "bb");
87 expect(getPendingThreadOrder()).toBeNull();
88 expect(move.isPending()).toBe(false);
89 });
90
91 it("waits for receipts when shells arrive first", () => {
92 const { start, upsert } = fixture();
93 const move = start();
94 upsert("b", "aa");
95 upsert("a", "bb");
96 expect(getPendingThreadOrder()).not.toBeNull();
97 move.complete();
98 expect(getPendingThreadOrder()).toBeNull();
99 });
100
101 it.each(["failure", "interruption"])("releases a %s without restoring old canonical keys", () => {
102 const { start, upsert } = fixture();
103 const move = start();
104 upsert("b", "aa");
105 move.cancel();
106 expect(getPendingThreadOrder()).toBeNull();
107 expect(appAtomRegistry.get(shellsAtom)[1]?.activeOrderKey).toBe("aa");
108 const next = start();
109 move.cancel();
110 expect(next.isPending()).toBe(true);
111 next.cancel();
112 });
113
114 it("stops remaining writes when a canonical membership change invalidates the move", () => {
115 const { rows, start } = fixture();
116 const move = start();
117 appAtomRegistry.set(shellsAtom, rows.slice(1));
118 expect(move.isPending()).toBe(false);
119 expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull();
120 move.complete();
121 appAtomRegistry.set(shellsAtom, rows);
122 expect(getPendingThreadOrder()).toBeNull();
123 });
124});