1import { describe, expect, it } from "vite-plus/test";
2import {
3 generateSpreadPinOrderKeys,
4 pinOrderKeyBetween,
5} from "@t3tools/client-runtime/state/thread-sort";
6import type { EnvironmentId } from "@t3tools/contracts";
7
8import {
9 computeThreadMoveAvailability,
10 createThreadMovePlanner,
11 type OrderRow,
12} from "./threadOrder";
13
14function makeRow(id: string, environmentId: string, key: string | null, pinned: boolean): OrderRow {
15 return {
16 id: id as OrderRow["id"],
17 environmentId: environmentId as OrderRow["environmentId"],
18 pinOrderKey: pinned ? key : null,
19 activeOrderKey: pinned ? null : key,
20 createdAt: "2026-01-01T00:00:00.000Z",
21 unsettledAt: "2026-01-01T00:00:00.000Z",
22 pinnedAt: pinned ? "2026-01-01T00:00:00.000Z" : null,
23 };
24}
25
26function makeRng(seed: number) {
27 let state = seed >>> 0;
28 return () => {
29 state = (state * 1_664_525 + 1_013_904_223) >>> 0;
30 return state / 0x1_0000_0000;
31 };
32}
33
34const KEY_POOL = [
35 "a",
36 "b",
37 "ba",
38 "bb",
39 "c",
40 "d",
41 "ca",
42 "cb",
43 "da",
44 "db",
45 "e",
46 "f",
47 "g",
48 "h",
49 "i",
50 "j",
51 "k",
52 "l",
53 "m",
54 "n",
55 "o",
56 "p",
57 "q",
58 "r",
59 "s",
60 "t",
61 "u",
62 "v",
63 "w",
64 "x",
65 "y",
66 "z",
67 null,
68];
69
70function referenceAvailability(
71 ordered: readonly OrderRow[],
72 allThreads: readonly OrderRow[],
73 writable: ReadonlySet<EnvironmentId>,
74) {
75 const planner = createThreadMovePlanner({
76 ordered,
77 allThreads,
78 section: "pinned",
79 reorderableEnvironmentIds: writable,
80 });
81 const answers = new Map<string, { canMoveUp: boolean; canMoveDown: boolean }>();
82 for (const row of ordered) {
83 const movedId = `${row.environmentId}:${row.id}`;
84 answers.set(movedId, {
85 canMoveUp: planner(movedId, "up") !== null,
86 canMoveDown: planner(movedId, "down") !== null,
87 });
88 }
89 return answers;
90}
91
92function randomCase(rng: () => number) {
93 const rowCount = 1 + Math.floor(rng() * 9);
94 const rows: OrderRow[] = [];
95 for (let index = 0; index < rowCount; index += 1) {
96 const environment = rng() < 0.75 ? "env-w" : "env-x";
97 const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null;
98 const id = rng() < 0.4 ? `t:${index}` : `t${index}`;
99 rows.push(makeRow(id, environment, key, true));
100 }
101 const hidden: OrderRow[] = [];
102 const hiddenCount = Math.floor(rng() * 4);
103 for (let index = 0; index < hiddenCount; index += 1) {
104 const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null;
105 const id = rng() < 0.4 ? `h:${index}` : `h${index}`;
106 hidden.push(makeRow(id, rng() < 0.75 ? "env-w" : "env-x", key, true));
107 }
108 return { ordered: rows, allThreads: [...rows, ...hidden] };
109}
110
111const WRITABLE = new Set<EnvironmentId>(["env-w" as EnvironmentId]);
112
113describe("computeThreadMoveAvailability matches the reference planner", () => {
114 it("agrees across randomized sections (keys, holes, non-writable rows, hidden keys)", () => {
115 for (let seed = 1; seed <= 4_000; seed += 1) {
116 const rng = makeRng(seed);
117 const { ordered, allThreads } = randomCase(rng);
118 const batch = computeThreadMoveAvailability({
119 ordered,
120 allThreads,
121 section: "pinned",
122 reorderableEnvironmentIds: WRITABLE,
123 });
124 const reference = referenceAvailability(ordered, allThreads, WRITABLE);
125 for (const [id, answer] of reference) {
126 expect(
127 batch.get(id) ?? { canMoveUp: false, canMoveDown: false },
128 `seed ${seed} row ${id} ordered=${ordered
129 .map((row) => `${row.id}:${row.pinOrderKey ?? "-"}:${row.environmentId}`)
130 .join(",")}`,
131 ).toEqual(answer);
132 }
133 }
134 });
135
136 it("locks every row while a pending reorder is in flight", () => {
137 const rows = [makeRow("t0", "env-w", "a", true), makeRow("t1", "env-w", "c", true)];
138 const pending = {
139 section: "pinned" as const,
140 orderedIds: ["env-w:t1", "env-w:t0"],
141 before: new Map(),
142 assignments: new Map(),
143 confirmed: new Set<string>(),
144 commandsComplete: false,
145 };
146 const batch = computeThreadMoveAvailability({
147 ordered: rows,
148 section: "pinned",
149 reorderableEnvironmentIds: WRITABLE,
150 pendingOrder: pending,
151 });
152 expect(batch.size).toBe(0);
153 });
154
155 it("allows the fast-path walk past reserved keys and rewrites that skip matching keys (auditor cases)", () => {
156 const rows = [makeRow("t0", "env-w", "f", true), makeRow("t1", "env-x", "gn", true)];
157 const withThird = [...rows, makeRow("t2", "env-w", null, true)];
158 const batch = computeThreadMoveAvailability({
159 ordered: withThird,
160 allThreads: withThird,
161 section: "pinned",
162 reorderableEnvironmentIds: WRITABLE,
163 });
164 const reference = referenceAvailability(withThird, withThird, WRITABLE);
165 expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
166 expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: true });
167 });
168
169 it("keeps moves available for ids containing colons (composite-id parsing)", () => {
170 const rows = [makeRow("thread:1", "env", "a", true), makeRow("thread:2", "env", "c", true)];
171 const batch = computeThreadMoveAvailability({
172 ordered: rows,
173 section: "pinned",
174 reorderableEnvironmentIds: new Set<EnvironmentId>(["env" as EnvironmentId]),
175 });
176 const reference = referenceAvailability(
177 rows,
178 rows,
179 new Set<EnvironmentId>(["env" as EnvironmentId]),
180 );
181 expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
182 expect(batch.get("env:thread:1")).toEqual({ canMoveUp: false, canMoveDown: true });
183 expect(batch.get("env:thread:2")).toEqual({ canMoveUp: true, canMoveDown: false });
184 });
185
186 it("answers empty and single-row sections without consulting hidden rows", () => {
187 const hidden = [makeRow("h0", "env-w", "b", true), makeRow("h1", "env-x", null, true)];
188 expect(
189 computeThreadMoveAvailability({
190 ordered: [],
191 allThreads: hidden,
192 section: "pinned",
193 reorderableEnvironmentIds: WRITABLE,
194 }).size,
195 ).toBe(0);
196 for (const environment of ["env-w", "env-x"]) {
197 const ordered = [makeRow("t0", environment, "a", true)];
198 const allThreads = [...ordered, ...hidden];
199 const batch = computeThreadMoveAvailability({
200 ordered,
201 allThreads,
202 section: "pinned",
203 reorderableEnvironmentIds: WRITABLE,
204 });
205 expect(Object.fromEntries(batch)).toEqual({
206 [`${environment}:t0`]: { canMoveUp: false, canMoveDown: false },
207 });
208 expect(Object.fromEntries(batch)).toEqual(
209 Object.fromEntries(referenceAvailability(ordered, allThreads, WRITABLE)),
210 );
211 }
212 });
213
214 it("denies rows whose section has non-writable neighbours when the fast path fails", () => {
215 const rows = [
216 makeRow("t0", "env-w", null, true),
217 makeRow("t1", "env-x", "c", true),
218 makeRow("t2", "env-w", "d", true),
219 ];
220 const batch = computeThreadMoveAvailability({
221 ordered: rows,
222 allThreads: rows,
223 section: "pinned",
224 reorderableEnvironmentIds: WRITABLE,
225 });
226 const reference = referenceAvailability(rows, rows, WRITABLE);
227 expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
228 expect(batch.get("env-x:t1")).toEqual({ canMoveUp: false, canMoveDown: false });
229 });
230
231 it("agrees on a section where every adjacency midpoint is a hidden reserved key", () => {
232 const visibleKeys = generateSpreadPinOrderKeys(24);
233 const hiddenKeys = visibleKeys
234 .slice(0, -1)
235 .map((key, index) => pinOrderKeyBetween(key, visibleKeys[index + 1]!))
236 .filter((key): key is string => key != null);
237 const ordered = visibleKeys.map((key, index) => makeRow(`v${index}`, "env-w", key, true));
238 const hidden = hiddenKeys.map((key, index) => makeRow(`h${index}`, "env-w", key, true));
239 const allThreads = [...ordered, ...hidden];
240 const batch = computeThreadMoveAvailability({
241 ordered,
242 allThreads,
243 section: "pinned",
244 reorderableEnvironmentIds: WRITABLE,
245 });
246 const reference = referenceAvailability(ordered, allThreads, WRITABLE);
247 expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
248 });
249});