@pi-archimedes/core 2.5.1 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ vi,
6
+ beforeAll,
7
+ afterAll,
8
+ afterEach,
9
+ } from "vitest";
10
+ import type {
11
+ Theme,
12
+ KeybindingsManager,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
15
+
16
+ // ── Mocks ────────────────────────────────────────────────────────────────────
17
+
18
+ const widthProbe = vi.hoisted(() => ({
19
+ /** Mutable width probe: default reports each char as width 1 (non-EAW), so `visibleWidth(s)` = `s.length` for ASCII. */
20
+ probe: (s: string): number => s.length,
21
+ }));
22
+
23
+ vi.mock("@earendil-works/pi-tui", () => ({
24
+ truncateToWidth: (s: string, _w: number, _pad?: string, _incl?: boolean) => s,
25
+ isKeyRelease: () => false,
26
+ visibleWidth: (s: string) => widthProbe.probe!(s),
27
+ }));
28
+
29
+ vi.mock("@earendil-works/pi-coding-agent", () => {
30
+ // Minimal stand-in for pi's CustomEditor: mirrors the verified 0.85.1
31
+ // behavior of reading `tui.terminal.rows` in render() and touching
32
+ // `borderColor` (set from theme.borderColor in the ctor) so missing
33
+ // stubs fail fast. `getAgentDir` serves config.ts's module-scope
34
+ // settings path (the mock never reads or writes it).
35
+ class CustomEditor {
36
+ tui: TUI;
37
+ borderColor: (s: string) => string;
38
+ text = "";
39
+ constructor(
40
+ tui: TUI,
41
+ editorTheme: { borderColor: (s: string) => string },
42
+ ) {
43
+ this.tui = tui;
44
+ this.borderColor = editorTheme.borderColor;
45
+ }
46
+ getText(): string {
47
+ return this.text;
48
+ }
49
+ setText(t: string): void {
50
+ this.text = t;
51
+ }
52
+ handleInput(_data: string): void {}
53
+ render(_width: number): string[] {
54
+ void this.tui.terminal.rows;
55
+ return [
56
+ this.borderColor("┌───┐"),
57
+ "input line",
58
+ this.borderColor("└───┘"),
59
+ ];
60
+ }
61
+ }
62
+ return { CustomEditor, getAgentDir: () => "/nonexistent-pi-agent-dir" };
63
+ });
64
+
65
+ import {
66
+ HephaestusEditor,
67
+ SPIN_TYPE_START,
68
+ SPIN_TYPE_CELLS,
69
+ } from "./index.js";
70
+ import { SPIN_VARIANTS } from "./spin.js";
71
+
72
+ /** The default label (no `spinLabel` in the ctor options), including the leading AND trailing spaces the renderer contributes (`" " + label + " "`). */
73
+ const LABEL = " Working ";
74
+
75
+ // ── Helpers ──────────────────────────────────────────────────────────────────
76
+
77
+ const stubTheme = {
78
+ fg: (_k: string, t: string) => t,
79
+ } as unknown as Theme;
80
+
81
+ interface ConstructOpts {
82
+ spin?: boolean;
83
+ /** The `editorSpinSpeed` setting; × multiplies the style's native per-tick tempo (× 1.5 / × 1 / × 0.6), 32 ms tick floor. */
84
+ spinSpeed?: "slow" | "normal" | "fast";
85
+ /** The `editorSpinStyle` setting (raw setting string tolerated; a style not registered in `SPIN_VARIANTS` yet (batch 4) normalizes to typing frames — the fallback, which stays typing even though the default setting is now pendulum). */
86
+ spinStyle?: string;
87
+ /** Label typed after the window (empty hides it; unset → "Working"). */
88
+ spinLabel?: string;
89
+ /** Values consumed one per isIdle() call (idle if no values left). */
90
+ idleSeq?: boolean[];
91
+ onSpinInterval?: (h: ReturnType<typeof setInterval> | undefined) => void;
92
+ }
93
+
94
+ const createdEditors: HephaestusEditor[] = [];
95
+
96
+ function makeEditor(opts: ConstructOpts = {}): {
97
+ editor: HephaestusEditor;
98
+ tui: ReturnType<typeof makeTui>;
99
+ onSpinInterval?: ReturnType<typeof vi.fn>;
100
+ } {
101
+ const tui = makeTui() as unknown as TUI;
102
+ const editorTheme = {
103
+ borderColor: (s: string) => s,
104
+ } as unknown as EditorTheme;
105
+ const keybindings = {} as KeybindingsManager;
106
+ const seq = opts.idleSeq ? opts.idleSeq.slice() : [];
107
+ const onSpinInterval = opts.onSpinInterval
108
+ ? opts.onSpinInterval
109
+ : vi.fn();
110
+ const editor = new HephaestusEditor(tui, editorTheme, keybindings, {
111
+ getTheme: () => stubTheme,
112
+ isIdle: () => (seq.length > 0 ? (seq.shift() as boolean) : true),
113
+ shutdown: vi.fn(),
114
+ spin: opts.spin,
115
+ spinSpeed: opts.spinSpeed,
116
+ spinStyle: opts.spinStyle,
117
+ spinLabel: opts.spinLabel,
118
+ onSpinInterval,
119
+ } as any);
120
+ createdEditors.push(editor);
121
+ return { editor, tui, onSpinInterval: onSpinInterval as ReturnType<typeof vi.fn> };
122
+ }
123
+
124
+ function makeTui(): { requestRender: () => void; terminal: { rows: number } } {
125
+ return { requestRender: vi.fn(), terminal: { rows: 24 } };
126
+ }
127
+
128
+ const tick = (ed: HephaestusEditor): void => {
129
+ (ed as any).tickSpin();
130
+ };
131
+
132
+ // Rendered lines are wrapped in SGR sequences by wrap(); strip them so a
133
+ // position-based prefix check sees pad + prefix + content.
134
+ const plain = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "");
135
+
136
+ // An editor that has been `step` busy ticks and reports busy to render.
137
+ function busyAt(step: number, opts: ConstructOpts = {}): HephaestusEditor {
138
+ const { editor } = makeEditor({
139
+ spin: true,
140
+ ...opts,
141
+ idleSeq: Array.from({ length: step }, () => false),
142
+ });
143
+ for (let i = 0; i < step; i++) tick(editor);
144
+ (editor as any).isIdle = () => false;
145
+ return editor;
146
+ }
147
+
148
+ // The `┌───┐` border row is the second rendered line (first is the plain
149
+ // `▁` top edge, which is un-padded; wrapped raw lines carry one PAD_X space).
150
+ // Strip pad + corners and SGR; width→inner = width - 4 (PAD_X = 1).
151
+ const borderRun = (lines: string[], width: number): string => {
152
+ const top = plain(lines[1]!);
153
+ return top.slice(2, 2 + (width - 4));
154
+ };
155
+
156
+ // Row shape: counting the window's cells (any char other than `─`, i.e.
157
+ // stage chars, spaces, and the label chars of ` Working ` when present)
158
+ // as cells, the whole row firms up to all dashes.
159
+ const rowFirmsToDashes = (run: string, len: number): void => {
160
+ expect(run.replace(/[^─]/g, "─")).toBe("─".repeat(len));
161
+ };
162
+
163
+ // Compensated width for a 60-wide box (inner = 56): the block sits at start 0
164
+ // right at the corner (leading space at column 0, the 4-cell window at 1–4,
165
+ // ` Working ` with its leading AND trailing space at 5–13): the row minus the
166
+ // block's 14 columns is all hard `─` — those columns replaced trailing dashes,
167
+ // so the row width stays constant.
168
+ const compensatedAt60 = (run: string): void => {
169
+ const end = SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length; // 0 + 1 + 4 + 9 = 14
170
+ const rest = run.slice(0, SPIN_TYPE_START) + run.slice(end);
171
+ expect(rest).toBe(
172
+ "─".repeat(56 - 1 - SPIN_TYPE_CELLS - LABEL.length), // 42
173
+ );
174
+ };
175
+
176
+ // ── Setup / teardown ────────────────────────────────────────────────────────
177
+
178
+ beforeAll(() => {
179
+ vi.useFakeTimers();
180
+ });
181
+
182
+ afterAll(() => {
183
+ vi.useRealTimers();
184
+ });
185
+
186
+ afterEach(() => {
187
+ for (const ed of createdEditors) {
188
+ (ed as any).dispose?.();
189
+ }
190
+ createdEditors.length = 0;
191
+ vi.restoreAllMocks();
192
+ });
193
+
194
+ // ── 1. Static prefix, plain idle edge ──────────────────────────────────────
195
+
196
+ describe("static prefix, plain idle edge", () => {
197
+ it("tickSpin is a no-op while idle: no render", () => {
198
+ const { editor, tui } = makeEditor({ spin: true });
199
+ tick(editor);
200
+ expect(tui.requestRender).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it("renders the static > prefix while idle", () => {
204
+ const { editor } = makeEditor({ spin: true });
205
+ tick(editor);
206
+ const lines = editor.render(60);
207
+ // Prefix is attached to the first content line (mock's "input line" row);
208
+ // wrapped lines start with an SGR, so strip it before checking the position
209
+ const prefixLine = lines.find((l) => l.includes("input line"));
210
+ expect(prefixLine).toBeDefined();
211
+ expect(plain(prefixLine!).trimStart().startsWith("> ")).toBe(true);
212
+ });
213
+
214
+ it("spin=false also renders the static > prefix", () => {
215
+ const { editor } = makeEditor({ spin: false });
216
+ const lines = editor.render(60);
217
+ const prefixLine = lines.find((l) => l.includes("input line"));
218
+ expect(prefixLine).toBeDefined();
219
+ expect(plain(prefixLine!).trimStart().startsWith("> ")).toBe(true);
220
+ });
221
+
222
+ it("prefix is the static > even while busy (chevron never animates)", () => {
223
+ const editor = busyAt(4);
224
+ const lines = editor.render(60);
225
+ const prefixLine = lines.find((l) => l.includes("input line"));
226
+ expect(prefixLine).toBeDefined();
227
+ expect(plain(prefixLine!).trimStart().startsWith("> ")).toBe(true);
228
+ });
229
+
230
+ it("idle renders a plain edge row (no stage chars anywhere)", () => {
231
+ const { editor } = makeEditor({ spin: true }); // never went busy
232
+ const lines = editor.render(60);
233
+ const first = plain(lines[0]!);
234
+ expect(first).toBe("▁".repeat(60));
235
+ });
236
+
237
+ it("spin=false leaves the edge plain while busy", () => {
238
+ const { editor } = makeEditor({
239
+ spin: false,
240
+ idleSeq: Array(4).fill(false),
241
+ });
242
+ for (let i = 0; i < 4; i++) tick(editor);
243
+ (editor as any).isIdle = () => false;
244
+ const first = plain(editor.render(60)[0]!);
245
+ expect(first).toBe("▁".repeat(60));
246
+ });
247
+ });
248
+
249
+ // ── 2. Busy/idle repaint at the render level ───────────────────────────────
250
+ // (the tick state machine itself — advance, wrap, reset, repaint counts,
251
+ // and the full frame table — is tested against BorderTypeSpinner in
252
+ // spin.test.ts; here the edge is the plain-border repaint, which the
253
+ // typing strip must NOT freeze on screen — an idle→no-op would.)
254
+
255
+ describe("busy/idle repaint at the render level", () => {
256
+ it("busy→idle: the plain border repaints exactly once, then idle ticks are no-ops", () => {
257
+ // isIdle() returns false, false, true, true across the four ticks
258
+ const { editor, tui } = makeEditor({ spin: true, idleSeq: [false, false, true, true] });
259
+
260
+ tick(editor); // busy #1
261
+ expect(tui.requestRender).toHaveBeenCalledTimes(1);
262
+
263
+ tick(editor); // busy #2
264
+ expect(tui.requestRender).toHaveBeenCalledTimes(2);
265
+
266
+ tick(editor); // idle after busy — reset + exactly one repaint
267
+ expect(tui.requestRender).toHaveBeenCalledTimes(3);
268
+
269
+ tick(editor); // idle after idle — full no-op
270
+ expect(tui.requestRender).toHaveBeenCalledTimes(3);
271
+ });
272
+
273
+ it("busy streak wraps at the 38-step cycle: clear beat is 4 spaces + ` Working ` (label stays up), then the block grows again", () => {
274
+ // 40 busy values: 37 to reach step 37, then the wrap tick (→ 0, clear beat)
275
+ // and the regrowth tick (→ step 1); the render decision uses the
276
+ // overridden isIdle below, the tick machine uses the constructor sequence.
277
+ // The intent is the typing cycle (38 steps, braille frames) — explicit, since the default style is now pendulum.
278
+ const { editor } = makeEditor({ spin: true, spinStyle: "typing", idleSeq: Array.from({ length: 40 }, () => false) });
279
+ for (let i = 0; i < 37; i++) tick(editor); // s=1..37
280
+ (editor as any).isIdle = () => false;
281
+ tick(editor); // wraps to 0 — the clear beat
282
+ const cleared = borderRun(editor.render(60), 60);
283
+ expect(
284
+ cleared.slice(
285
+ SPIN_TYPE_START,
286
+ SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
287
+ ),
288
+ ).toBe( // ` Working` — the leading space + 4 clear spaces + the label
289
+ " " + " " + LABEL,
290
+ );
291
+ tick(editor); // back to step 1 — cell 1 enters at ⠁
292
+ const regrown = borderRun(editor.render(60), 60);
293
+ expect(
294
+ regrown.slice(
295
+ SPIN_TYPE_START,
296
+ SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
297
+ ),
298
+ ).toBe(" ⠁ " + LABEL); // `⠁ Working`
299
+
300
+ });
301
+ });
302
+
303
+ // ── 3. Typing strip on the top border row (serpentine line fill) ─────────
304
+ // Cells fill cell-by-cell left→right, each cell walking the 8 chart-order
305
+ // stages in 2-step line pairs (⠁⠉ / ⠋⠛ / ⠟⠿ / ⡿⣿): line 1 is steps 1–8,
306
+ // line 2 9–16, line 3 17–24, line 4 25–32, hold 33–37, clear at the wrap (0).
307
+
308
+ describe("typing strip on the top border row", () => {
309
+ // 60-wide box (inner = 56): the full beat = leading space at 0 + 4-cell
310
+ // window + ` Working ` label (leading AND trailing space) when labelFit
311
+ // (16 for the default label) is met.
312
+ const beat = (ed: HephaestusEditor): string =>
313
+ borderRun(ed.render(60), 60).slice(
314
+ SPIN_TYPE_START,
315
+ SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
316
+ );
317
+
318
+ it("step 1: leading space at 0, then only cell 1 is `⠁` (dot block starts to grow), ` Working ` label follows", () => {
319
+ expect(beat(busyAt(1, { spinStyle: "typing" }))).toBe(" ⠁ " + LABEL);
320
+ rowFirmsToDashes(borderRun(busyAt(1, { spinStyle: "typing" }).render(60), 60), 56);
321
+ compensatedAt60(borderRun(busyAt(1, { spinStyle: "typing" }).render(60), 60));
322
+ });
323
+
324
+ it("step 8 (line 1 complete): leading space at 0, all 4 cells are `⠉`, label follows", () => {
325
+ const ed = busyAt(8, { spinStyle: "typing" });
326
+ expect(beat(ed)).toBe(" ⠉⠉⠉⠉" + LABEL);
327
+ rowFirmsToDashes(borderRun(ed.render(60), 60), 56);
328
+ compensatedAt60(borderRun(ed.render(60), 60));
329
+ });
330
+
331
+ it("step 32 (fully grown): leading space at 0, all 4 cells are `⣿`, label follows", () => {
332
+ const ed = busyAt(32, { spinStyle: "typing" });
333
+ expect(beat(ed)).toBe(" ⣿⣿⣿⣿" + LABEL);
334
+ rowFirmsToDashes(borderRun(ed.render(60), 60), 56);
335
+ compensatedAt60(borderRun(ed.render(60), 60));
336
+ });
337
+
338
+ it("hold region (step 37): leading space at 0, full block holds — `⣿`×4 on, label still up (the hold clamp 33–38 is covered in spin.test.ts)", () => {
339
+ expect(beat(busyAt(37, { spinStyle: "typing" }))).toBe(" ⣿⣿⣿⣿" + LABEL);
340
+ });
341
+
342
+ it("narrow width (14, inner 10): block at the corner (leading space at 0), window at 1, no label, row dash-compensated", () => {
343
+ // inner = 10 → 7 ≤ 10 < 16: window-only tier at start 0; trailing = 10 − 1 − 4 = 5
344
+ const run = borderRun(busyAt(4, { spinStyle: "typing" }).render(14), 14);
345
+ expect(run.slice(0, 1)).toBe(" ");
346
+ expect(run.slice(1, 5)).toBe("⠉⠉ ");
347
+ expect(run.slice(5)).toBe("─".repeat(5));
348
+ expect(run).not.toContain("Working");
349
+ rowFirmsToDashes(run, 10);
350
+ });
351
+
352
+ it("label-fit tiers: window-only at inner 15 (19, 10 trailing dashes); label on at inner 16 (20 = L + 9) — verbatim beat ` ⠛⠛⠛⠛ Working ` with 2 trailing dashes", () => {
353
+ const run15 = borderRun(busyAt(16, { spinStyle: "typing" }).render(19), 19);
354
+ expect(run15).not.toContain("Working"); // inner 15 < 16 → window-only tier
355
+ expect(run15.slice(0, 1)).toBe(" ");
356
+ expect(run15.slice(1, 5)).toBe("⠛⠛⠛⠛");
357
+ expect(run15.slice(5)).toBe("─".repeat(10)); // trailing = 15 − 1 − 4 = 10
358
+ rowFirmsToDashes(run15, 15);
359
+ const run = borderRun(busyAt(16, { spinStyle: "typing" }).render(20), 20); // inner = 16 = 0 + 1 + 4 + (1 + 7 + 1) + 2 (labelFit for L = 7)
360
+ expect(run.slice(0, 1)).toBe(" ");
361
+ expect(run.slice(1, 5)).toBe("⠛⠛⠛⠛");
362
+ expect(run.slice(5, 14)).toBe(LABEL); // ` Working ` — leading AND trailing space
363
+ expect(run.slice(14)).toBe("─".repeat(2)); // trailing = 16 − 0 − 1 − 4 − (1 + 7 + 1) = 2
364
+ rowFirmsToDashes(run, 16);
365
+ });
366
+
367
+ it("CJK 4-char label (visible width 8 per 4-char CJK label): shows from inner 17 (= 8 + 9), absent at inner 16 (widths, not chars)", () => {
368
+ widthProbe.probe = (s: string) => (s === "你好世界" ? 8 : s.length);
369
+ try {
370
+ const onAll = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "你好世界" }).render(21), 21); // inner 17, 13 chars
371
+ const on = onAll.slice(0, 13); // the row (borderRun over-fetches the corner when chars ≠ visible cells)
372
+ expect(on.slice(0, 1)).toBe(" ");
373
+ expect(on.slice(1, 5)).toBe("⠉⠉ ");
374
+ expect(on.slice(5, 11)).toBe(" 你好世界 "); // own leading + trailing space (the 4 CJK chars are visible-width 8)
375
+ expect(on.slice(11)).toBe("─".repeat(2)); // trailing = 17 − 0 − 1 − 4 − (1 + 8 + 1) = 2 visible cells
376
+ rowFirmsToDashes(on, 13); // char length: 5 + (1 + 4 + 1) + 2
377
+ const offAll = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "你好世界" }).render(20), 20); // inner = 16 < 17, 16 chars
378
+ const off = offAll.slice(0, 16);
379
+ expect(off).not.toContain("你好世界");
380
+ expect(off.slice(0, 1)).toBe(" ");
381
+ expect(off.slice(1, 5)).toBe("⠉⠉ ");
382
+ expect(off.slice(5)).toBe("─".repeat(11)); // window-only tier: 16 − 1 − 4 = 11
383
+ rowFirmsToDashes(off, 16);
384
+ } finally {
385
+ widthProbe.probe = (s: string) => s.length;
386
+ }
387
+ });
388
+
389
+ it("below the floor (width 10, inner 6): no window, no label, plain border", () => {
390
+ const run = borderRun(busyAt(4, { spinStyle: "typing" }).render(10), 10);
391
+ expect(run).toBe("─".repeat(6));
392
+ expect(run).not.toContain("Working");
393
+ });
394
+
395
+ it("idle: all-dash border row — no leading space, no window, no stage chars, no label (spin on, never busy)", () => {
396
+ const { editor } = makeEditor({ spin: true });
397
+ const run = borderRun(editor.render(60), 60);
398
+ expect(run).toBe("─".repeat(56));
399
+ for (const c of ["⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "⡿", "⣿", "░", "▒", "▓", "█"]) {
400
+ expect(run).not.toContain(c);
401
+ }
402
+ expect(run).not.toContain("Working");
403
+ });
404
+
405
+ // ── Configurable label (archimedes.core.editorSpinLabel) ────────────────
406
+
407
+ it("custom label (\"Thinking\"): the wide beat ends in ` Thinking ` (leading AND trailing space), no ` Working ` anywhere in the row", () => {
408
+ const ed = busyAt(1, { spinStyle: "typing", spinLabel: "Thinking" });
409
+ const run = borderRun(ed.render(60), 60);
410
+ expect(run).toContain(" Thinking ");
411
+ expect(run).not.toContain("Working");
412
+ expect(run.slice(SPIN_TYPE_START, SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + "Thinking".length + 2)).toBe(
413
+ " ⠁ " + " Thinking ",
414
+ );
415
+ // Compensated: trailing = 56 − 0 − 1 − 4 − (1 + 8 + 1) = 41 dashes
416
+ expect(run.slice(SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + "Thinking".length + 2)).toBe("─".repeat(41));
417
+ rowFirmsToDashes(run, 56);
418
+ });
419
+
420
+ it("custom label (\"Thinking\") survives the cycle: clear beat holds the label, regrowth keeps it (the spin machine is untouched by the label)", () => {
421
+ const { editor } = makeEditor({
422
+ spin: true,
423
+ spinStyle: "typing",
424
+ spinLabel: "Thinking",
425
+ idleSeq: Array.from({ length: 40 }, () => false),
426
+ });
427
+ for (let i = 0; i < 37; i++) tick(editor);
428
+ (editor as any).isIdle = () => false;
429
+ tick(editor); // wraps to 0 — clear beat
430
+ expect(borderRun(editor.render(60), 60)).toContain(" Thinking");
431
+ tick(editor); // step 1
432
+ expect(borderRun(editor.render(60), 60)).toContain(" ⠁ " + " Thinking ");
433
+ // Label drop-off on narrow boxes follows the same width tiers as the default label (labelFit = 8 + 9 = 17 > 10)
434
+ const run14 = borderRun(editor.render(14), 14);
435
+ expect(run14).not.toContain("Thinking");
436
+ });
437
+
438
+ it("empty label (\"\"): no label text in any tier — wide (win + trailing dashes), narrow (window-only tier identical)", () => {
439
+ const wide = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "" }).render(60), 60);
440
+ expect(wide).not.toContain("Working");
441
+ expect(wide.slice(0, 1)).toBe(" ");
442
+ expect(wide.slice(1, 5)).toBe("⠉⠉ ");
443
+ expect(wide.slice(5)).toBe("─".repeat(51)); // trailing = 56 − 1 − 4 = 51
444
+ rowFirmsToDashes(wide, 56);
445
+ const narrow = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "" }).render(14), 14);
446
+ expect(narrow).not.toContain("Working");
447
+ expect(narrow.slice(5)).toBe("─".repeat(5)); // window-only tier (inner 10), same as a narrow default-label box
448
+ rowFirmsToDashes(narrow, 10);
449
+ });
450
+
451
+ it("label longer than the inner width can host (60 chars at inner 56, labelFit = 60 + 9 = 69 > 56): window-only tier even at the wide width, no negative trailing — row width constant", () => {
452
+ const run = borderRun(busyAt(4, { spinStyle: "typing", spinLabel: "A".repeat(60) }).render(60), 60);
453
+ expect(run).not.toContain("A");
454
+ expect(run.slice(0, 1)).toBe(" ");
455
+ expect(run.slice(1, 5)).toBe("⠉⠉ ");
456
+ expect(run.slice(5)).toBe("─".repeat(51));
457
+ rowFirmsToDashes(run, 56);
458
+ });
459
+
460
+ it("non-string label (corrupt config): the ctor falls back to \"Working\" (never throws in visibleWidth)", () => {
461
+ const ed = busyAt(4, { spinStyle: "typing", spinLabel: null as unknown as string });
462
+ const run = borderRun(ed.render(60), 60);
463
+ expect(run).toContain(LABEL);
464
+ });
465
+ });
466
+
467
+ // ── 4. Timer lifecycle & gating ────────────────────────────────────────────
468
+
469
+ describe("timer lifecycle & gating", () => {
470
+ it("spin=false: no interval is set up", () => {
471
+ const setSpy = vi.spyOn(globalThis, "setInterval");
472
+ const before = setSpy.mock.calls.length;
473
+ makeEditor({ spin: false });
474
+ expect(setSpy.mock.calls.length).toBe(before);
475
+ });
476
+
477
+ it("spin=true (default ctor): one 32ms interval — the default pendulum 12ms native tempo × the normal multiplier, clamped at the 32ms tick floor (12 × 1 = 12 < 32 → 32); onSpinInterval receives the handle", () => {
478
+ const setSpy = vi.spyOn(globalThis, "setInterval");
479
+ const { editor, onSpinInterval } = makeEditor({ spin: true });
480
+ expect(setSpy).toHaveBeenCalledTimes(1);
481
+ expect(setSpy.mock.calls[0]![1]).toBe(32); // 12 clamped at the 32ms floor
482
+ expect(onSpinInterval).toHaveBeenCalledTimes(1);
483
+ const handle = onSpinInterval!.mock.calls[0]![0];
484
+ expect(handle).not.toBeUndefined();
485
+ expect(handle).toBe((editor as any).spinTimer);
486
+ });
487
+
488
+ it("spinSpeed \"slow\" (the typing 80 ms × 1.5): the interval period is the mapped 120ms", () => {
489
+ const setSpy = vi.spyOn(globalThis, "setInterval");
490
+ makeEditor({ spin: true, spinSpeed: "slow", spinStyle: "typing" });
491
+ expect(setSpy).toHaveBeenCalledTimes(1);
492
+ expect(setSpy.mock.calls[0]![1]).toBe(120);
493
+ });
494
+
495
+ it("spinStyle \"wave-rows\" (ported — batch 2): a never-ticked busy box shows the wave-rows step-0 window (⢦⣠⠞⠙), NOT the typing fallback (⠁)", () => {
496
+ // hold-0 port styles run the full source loop: step 0 is the real first frame (no blank beat).
497
+ const { editor } = makeEditor({ spin: true, spinStyle: "wave-rows" });
498
+ (editor as any).isIdle = () => false;
499
+ const wave0 = SPIN_VARIANTS["wave-rows"]!.compute(0);
500
+ const cellStr = wave0
501
+ .map((m) => (m === 0 ? " " : String.fromCharCode(0x2800 + m)))
502
+ .join("");
503
+ const run = borderRun(editor.render(60), 60);
504
+ expect(run).toContain(" " + cellStr + LABEL);
505
+ expect(run).not.toContain(" ⠁ "); // not the typing fallback
506
+ });
507
+
508
+ it("spinStyle \"marquee\" + normal speed: the interval period is the marquee 55ms native tempo × the normal multiplier", () => {
509
+ const setSpy = vi.spyOn(globalThis, "setInterval");
510
+ makeEditor({ spin: true, spinStyle: "marquee" });
511
+ expect(setSpy).toHaveBeenCalledTimes(1);
512
+ expect(setSpy.mock.calls[0]![1]).toBe(55);
513
+ });
514
+
515
+ it("spinStyle \"pendulum\" + normal speed (12ms native): the period clamps at the 32ms tick floor (12 × 1 = 12 < 32 → 32)", () => {
516
+ const setSpy = vi.spyOn(globalThis, "setInterval");
517
+ makeEditor({ spin: true, spinStyle: "pendulum" });
518
+ expect(setSpy).toHaveBeenCalledTimes(1);
519
+ expect(setSpy.mock.calls[0]![1]).toBe(32);
520
+ });
521
+
522
+ it("spinSpeed \"fast\" with style \"diagonal-swipe\" (30ms native): the period clamps at the 32ms tick floor (30 × 0.6 = 18 → 32)", () => {
523
+ const setSpy = vi.spyOn(globalThis, "setInterval");
524
+ makeEditor({ spin: true, spinSpeed: "fast", spinStyle: "diagonal-swipe" });
525
+ expect(setSpy).toHaveBeenCalledTimes(1);
526
+ expect(setSpy.mock.calls[0]![1]).toBe(32);
527
+ });
528
+
529
+ it("spinStyle \"rain\" + normal speed (40ms native, ported — batch 4): the interval period is the rain 40ms native tempo × the normal multiplier", () => {
530
+ const setSpy = vi.spyOn(globalThis, "setInterval");
531
+ makeEditor({ spin: true, spinStyle: "rain" });
532
+ expect(setSpy).toHaveBeenCalledTimes(1);
533
+ expect(setSpy.mock.calls[0]![1]).toBe(40);
534
+ });
535
+
536
+ it("spinStyle \"sparkle\" + fast (40ms native, ported — batch 4): the period is 40 × 0.6 = 24 → clamps at the 32ms tick floor (24 → 32)", () => {
537
+ const setSpy = vi.spyOn(globalThis, "setInterval");
538
+ makeEditor({ spin: true, spinSpeed: "fast", spinStyle: "sparkle" });
539
+ expect(setSpy).toHaveBeenCalledTimes(1);
540
+ expect(setSpy.mock.calls[0]![1]).toBe(32);
541
+ });
542
+
543
+ it("hand-edited speed string (out of the union, e.g. `turbo`): the multiplier resolves to ×1 (never a NaN hot timer)", () => {
544
+ const setSpy = vi.spyOn(globalThis, "setInterval");
545
+ makeEditor({ spin: true, spinSpeed: "turbo" as any, spinStyle: "typing" });
546
+ expect(setSpy).toHaveBeenCalledTimes(1);
547
+ expect(setSpy.mock.calls[0]![1]).toBe(80); // 80 × (undefined ?? 1)
548
+ });
549
+
550
+ it("dispose clears the interval and notifies onSpinInterval(undefined)", () => {
551
+ const { editor, onSpinInterval } = makeEditor({ spin: true });
552
+ const clearSpy = vi.spyOn(globalThis, "clearInterval");
553
+ editor.dispose();
554
+ expect(clearSpy).toHaveBeenCalledTimes(1);
555
+ expect((editor as any).spinTimer).toBeUndefined();
556
+ expect(onSpinInterval).toHaveBeenLastCalledWith(undefined);
557
+ });
558
+
559
+ it("dispose with spin=false is a no-op (no interval, no notification)", () => {
560
+ const clearSpy = vi.spyOn(globalThis, "clearInterval");
561
+ const { editor, onSpinInterval } = makeEditor({ spin: false });
562
+ editor.dispose();
563
+ expect(clearSpy).not.toHaveBeenCalled();
564
+ expect(onSpinInterval).not.toHaveBeenCalled();
565
+ });
566
+
567
+ it("fake 80ms ticks drive render while the agent is busy", () => {
568
+ const { editor, tui } = makeEditor({ spin: true, spinStyle: "typing", idleSeq: [false] });
569
+ (editor as any).isIdle = () => false;
570
+ vi.advanceTimersByTime(80);
571
+ expect(tui.requestRender).toHaveBeenCalledTimes(1);
572
+ });
573
+
574
+ it("fast tick (48ms — editorSpinSpeed \"fast\"): the mapped period drives the render", () => {
575
+ const { editor, tui } = makeEditor({ spin: true, spinSpeed: "fast", spinStyle: "typing", idleSeq: [false] });
576
+ (editor as any).isIdle = () => false;
577
+ vi.advanceTimersByTime(48);
578
+ expect(tui.requestRender).toHaveBeenCalledTimes(1);
579
+ });
580
+ });
581
+
582
+ // ── 5. EAW fallback (⣿ reports width 2 → stage set flips to shading) ──────
583
+
584
+ describe("EAW terminal fallback", () => {
585
+ it("⣿ reports width 2: the window uses the shade set (░→█) in the same cell-wise order, no braille in the row, the ` Working ` label stays (ASCII-safe)", () => {
586
+ const braille = ["⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "⡿", "⣿"];
587
+ widthProbe.probe = (s: string) => (s === "⣿" ? 2 : s.length);
588
+ try {
589
+ const expectWindow = (s: number, window: string) => {
590
+ const run = borderRun(busyAt(s, { spinStyle: "typing" }).render(60), 60);
591
+ expect(run.slice(SPIN_TYPE_START, SPIN_TYPE_START + 1)).toBe(" ");
592
+ expect(
593
+ run.slice(
594
+ SPIN_TYPE_START + 1,
595
+ SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS,
596
+ ),
597
+ ).toBe(window);
598
+ expect(
599
+ run.slice(
600
+ SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS,
601
+ SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + LABEL.length,
602
+ ),
603
+ ).toBe(LABEL);
604
+ for (const c of braille) expect(run).not.toContain(c);
605
+ rowFirmsToDashes(run, 56);
606
+ compensatedAt60(run);
607
+ };
608
+ expectWindow(1, "░ ");
609
+ expectWindow(3, "░░ ");
610
+ expectWindow(8, "░░░░");
611
+ expectWindow(17, "▒░░░");
612
+ expectWindow(19, "▒▒░░");
613
+ expectWindow(26, "█▒▒▒");
614
+ expectWindow(32, "████");
615
+ } finally {
616
+ widthProbe.probe = (s: string) => s.length;
617
+ }
618
+ });
619
+ });