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
14// The batch availability computation must answer exactly what the reference
15// per-move planner answers; these tests randomize sections that stress every
16// branch (keyless rows, hidden keys, non-writable rows, adversarial keys).
17
18function makeRow(id: string, environmentId: string, key: string | null, pinned: boolean): OrderRow {
19 return {
20 id: id as OrderRow["id"],
21 environmentId: environmentId as OrderRow["environmentId"],
22 pinOrderKey: pinned ? key : null,
23 activeOrderKey: pinned ? null : key,
24 createdAt: "2026-01-01T00:00:00.000Z",
25 unsettledAt: "2026-01-01T00:00:00.000Z",
26 pinnedAt: pinned ? "2026-01-01T00:00:00.000Z" : null,
27 };
28}
29
30function makeRng(seed: number) {
31 let state = seed >>> 0;
32 return () => {
33 state = (state * 1_664_525 + 1_013_904_223) >>> 0;
34 return state / 0x1_0000_0000;
35 };
36}
37
38const KEY_POOL = [
39 "a",
40 "b",
41 "ba",
42 "bb",
43 "c",
44 "d",
45 "ca",
46 "cb",
47 "da",
48 "db",
49 "e",
50 "f",
51 "g",
52 "h",
53 "i",
54 "j",
55 "k",
56 "l",
57 "m",
58 "n",
59 "o",
60 "p",
61 "q",
62 "r",
63 "s",
64 "t",
65 "u",
66 "v",
67 "w",
68 "x",
69 "y",
70 "z",
71 null,
72];
73
74function referenceAvailability(
75 ordered: readonly OrderRow[],
76 allThreads: readonly OrderRow[],
77 writable: ReadonlySet<EnvironmentId>,
78) {
79 const planner = createThreadMovePlanner({
80 ordered,
81 allThreads,
82 section: "pinned",
83 reorderableEnvironmentIds: writable,
84 });
85 const answers = new Map<string, { canMoveUp: boolean; canMoveDown: boolean }>();
86 for (const row of ordered) {
87 const movedId = `${row.environmentId}:${row.id}`;
88 answers.set(movedId, {
89 canMoveUp: planner(movedId, "up") !== null,
90 canMoveDown: planner(movedId, "down") !== null,
91 });
92 }
93 return answers;
94}
95
96function randomCase(rng: () => number) {
97 const rowCount = 1 + Math.floor(rng() * 9);
98 // Two environments; "writable" env vs. one lacking the reorder capability.
99 const rows: OrderRow[] = [];
100 for (let index = 0; index < rowCount; index += 1) {
101 const environment = rng() < 0.75 ? "env-w" : "env-x";
102 const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null;
103 // Ids with colons: a `${environmentId}:${id}` string is not splittable
104 // back into its parts, so batch and planner must agree even here.
105 const id = rng() < 0.4 ? `t:${index}` : `t${index}`;
106 rows.push(makeRow(id, environment, key, true));
107 }
108 // Hidden rows (in allThreads, not in the visible ordered section) may hold
109 // keys that collide with fast-path midpoints.
110 const hidden: OrderRow[] = [];
111 const hiddenCount = Math.floor(rng() * 4);
112 for (let index = 0; index < hiddenCount; index += 1) {
113 const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null;
114 const id = rng() < 0.4 ? `h:${index}` : `h${index}`;
115 hidden.push(makeRow(id, rng() < 0.75 ? "env-w" : "env-x", key, true));
116 }
117 return { ordered: rows, allThreads: [...rows, ...hidden] };
118}
119
120const WRITABLE = new Set<EnvironmentId>(["env-w" as EnvironmentId]);
121
122describe("computeThreadMoveAvailability matches the reference planner", () => {
123 it("agrees across randomized sections (keys, holes, non-writable rows, hidden keys)", () => {
124 for (let seed = 1; seed <= 4_000; seed += 1) {
125 const rng = makeRng(seed);
126 const { ordered, allThreads } = randomCase(rng);
127 const batch = computeThreadMoveAvailability({
128 ordered,
129 allThreads,
130 section: "pinned",
131 reorderableEnvironmentIds: WRITABLE,
132 });
133 const reference = referenceAvailability(ordered, allThreads, WRITABLE);
134 for (const [id, answer] of reference) {
135 expect(
136 batch.get(id) ?? { canMoveUp: false, canMoveDown: false },
137 `seed ${seed} row ${id} ordered=${ordered
138 .map((row) => `${row.id}:${row.pinOrderKey ?? "-"}:${row.environmentId}`)
139 .join(",")}`,
140 ).toEqual(answer);
141 }
142 }
143 });
144
145 it("locks every row while a pending reorder is in flight", () => {
146 const rows = [makeRow("t0", "env-w", "a", true), makeRow("t1", "env-w", "c", true)];
147 const pending = {
148 section: "pinned" as const,
149 orderedIds: ["env-w:t1", "env-w:t0"],
150 before: new Map(),
151 assignments: new Map(),
152 confirmed: new Set<string>(),
153 commandsComplete: false,
154 };
155 const batch = computeThreadMoveAvailability({
156 ordered: rows,
157 section: "pinned",
158 reorderableEnvironmentIds: WRITABLE,
159 pendingOrder: pending,
160 });
161 expect(batch.size).toBe(0);
162 });
163
164 it("allows the fast-path walk past reserved keys and rewrites that skip matching keys (auditor cases)", () => {
165 // Reported parity case: keys ["f","gn",null], middle row NOT writable.
166 // The rewrite assigns the middle row the key it already holds, so the
167 // diff never writes it and the first row's down-move stays available.
168 const rows = [makeRow("t0", "env-w", "f", true), makeRow("t1", "env-x", "gn", true)];
169 const withThird = [...rows, makeRow("t2", "env-w", null, true)];
170 const batch = computeThreadMoveAvailability({
171 ordered: withThird,
172 allThreads: withThird,
173 section: "pinned",
174 reorderableEnvironmentIds: WRITABLE,
175 });
176 const reference = referenceAvailability(withThird, withThird, WRITABLE);
177 expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
178 expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: true });
179 });
180
181 it("keeps moves available for ids containing colons (composite-id parsing)", () => {
182 // The reported case: environment `env`, ids `thread:1`/`thread:2`. Splitting
183 // the composite id at the last colon yields `env:thread` and falsely locks
184 // both rows; writability must come from the row's own environmentId.
185 const rows = [makeRow("thread:1", "env", "a", true), makeRow("thread:2", "env", "c", true)];
186 const batch = computeThreadMoveAvailability({
187 ordered: rows,
188 section: "pinned",
189 reorderableEnvironmentIds: new Set<EnvironmentId>(["env" as EnvironmentId]),
190 });
191 const reference = referenceAvailability(
192 rows,
193 rows,
194 new Set<EnvironmentId>(["env" as EnvironmentId]),
195 );
196 expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
197 expect(batch.get("env:thread:1")).toEqual({ canMoveUp: false, canMoveDown: true });
198 expect(batch.get("env:thread:2")).toEqual({ canMoveUp: true, canMoveDown: false });
199 });
200
201 it("denies single-row sections on both directions", () => {
202 const rows = [makeRow("t0", "env-w", "a", true)];
203 const batch = computeThreadMoveAvailability({
204 ordered: rows,
205 section: "pinned",
206 reorderableEnvironmentIds: WRITABLE,
207 });
208 expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: false });
209 });
210
211 it("answers empty and single-row sections without consulting hidden rows", () => {
212 const hidden = [makeRow("h0", "env-w", "b", true), makeRow("h1", "env-x", null, true)];
213 expect(
214 computeThreadMoveAvailability({
215 ordered: [],
216 allThreads: hidden,
217 section: "pinned",
218 reorderableEnvironmentIds: WRITABLE,
219 }).size,
220 ).toBe(0);
221 for (const environment of ["env-w", "env-x"]) {
222 const ordered = [makeRow("t0", environment, "a", true)];
223 const allThreads = [...ordered, ...hidden];
224 const batch = computeThreadMoveAvailability({
225 ordered,
226 allThreads,
227 section: "pinned",
228 reorderableEnvironmentIds: WRITABLE,
229 });
230 expect(Object.fromEntries(batch)).toEqual({
231 [`${environment}:t0`]: { canMoveUp: false, canMoveDown: false },
232 });
233 expect(Object.fromEntries(batch)).toEqual(
234 Object.fromEntries(referenceAvailability(ordered, allThreads, WRITABLE)),
235 );
236 }
237 });
238
239 it("denies rows whose section has non-writable neighbours when the fast path fails", () => {
240 // Keyless neighbors force the section-rewrite fallback; a non-writable
241 // neighbor makes the rewrite illegal for every row in the section.
242 const rows = [
243 makeRow("t0", "env-w", null, true),
244 makeRow("t1", "env-x", "c", true),
245 makeRow("t2", "env-w", "d", true),
246 ];
247 const batch = computeThreadMoveAvailability({
248 ordered: rows,
249 allThreads: rows,
250 section: "pinned",
251 reorderableEnvironmentIds: WRITABLE,
252 });
253 const reference = referenceAvailability(rows, rows, WRITABLE);
254 expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
255 // Sanity: the middle (non-writable) row is denied on both sides.
256 expect(batch.get("env-x:t1")).toEqual({ canMoveUp: false, canMoveDown: false });
257 });
258
259 it("agrees on a section where every adjacency midpoint is a hidden reserved key", () => {
260 // The worst case for reserved-key collisions: one hidden row holds the
261 // exact midpoint key of every adjacent visible pair, so every probe walks.
262 const visibleKeys = generateSpreadPinOrderKeys(24);
263 const hiddenKeys = visibleKeys
264 .slice(0, -1)
265 .map((key, index) => pinOrderKeyBetween(key, visibleKeys[index + 1]!))
266 .filter((key): key is string => key != null);
267 const ordered = visibleKeys.map((key, index) => makeRow(`v${index}`, "env-w", key, true));
268 const hidden = hiddenKeys.map((key, index) => makeRow(`h${index}`, "env-w", key, true));
269 const allThreads = [...ordered, ...hidden];
270 const batch = computeThreadMoveAvailability({
271 ordered,
272 allThreads,
273 section: "pinned",
274 reorderableEnvironmentIds: WRITABLE,
275 });
276 const reference = referenceAvailability(ordered, allThreads, WRITABLE);
277 expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
278 });
279
280 it("agrees on an adversarial section of consecutive single-char keys", () => {
281 // Every midpoint between consecutive one-char keys is unrepresentable, so
282 // no row may claim a fast-path plan.
283 const rows = ["a", "b", "c", "d"].map((key, index) => makeRow(`t${index}`, "env-w", key, true));
284 const batch = computeThreadMoveAvailability({
285 ordered: rows,
286 allThreads: rows,
287 section: "pinned",
288 reorderableEnvironmentIds: WRITABLE,
289 });
290 const reference = referenceAvailability(rows, rows, WRITABLE);
291 expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
292 });
293});