| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import type { Atom } from "effect/reactivity"; |
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 5 | |
| 6 | import { createPendingThreadOrder } from "../features/threads/threadOrder"; |
| 7 | import { makeThreadShellFixture } from "../test-fixtures"; |
| 8 | import { appAtomRegistry } from "./atom-registry"; |
| 9 | import { |
| 10 | beginPendingThreadOrder, |
| 11 | getPendingThreadOrder, |
| 12 | pendingThreadOrderAtom, |
| 13 | } from "./thread-order"; |
| 14 | import { environmentThreadShells } from "./threads"; |
| 15 | |
| 16 | vi.mock("./atom-registry", async () => { |
| 17 | const { AtomRegistry } = await import("effect/reactivity"); |
| 18 | return { appAtomRegistry: AtomRegistry.make() }; |
| 19 | }); |
| 20 | vi.mock("./threads", async () => { |
| 21 | const { Atom } = await import("effect/reactivity"); |
| 22 | return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } }; |
| 23 | }); |
| 24 | vi.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 | }); |
| 33 | vi.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. |
| 39 | const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< |
| 40 | readonly EnvironmentThreadShell[], |
| 41 | readonly EnvironmentThreadShell[] |
| 42 | >; |
| 43 | |
| 44 | function 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 | |
| 76 | afterEach(() => appAtomRegistry.reset()); |
| 77 | |
| 78 | describe("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 | }); |