pi-archimedes 2.7.3 → 2.9.0
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.
- package/README.md +5 -16
- package/package.json +16 -15
- package/src/config.ts +21 -7
- package/src/factory-lifecycle.test.ts +74 -25
- package/src/index.ts +36 -24
- package/src/onboarding/index.test.ts +391 -0
- package/src/onboarding/index.ts +80 -0
- package/src/onboarding/overlay.test.ts +503 -0
- package/src/onboarding/overlay.ts +255 -0
- package/src/plugins.test.ts +24 -10
- package/src/plugins.ts +5 -4
- package/src/settings.ts +25 -21
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { SPINNER_STYLES } from "@pi-archimedes/ui/config";
|
|
3
|
+
import { createOnboardingOverlay, type OnboardingResult } from "./overlay.js";
|
|
4
|
+
import type { OverlayTheme } from "@pi-archimedes/core/overlay";
|
|
5
|
+
|
|
6
|
+
// The live-preview seam: a deterministic 4-char string that varies with `tick`
|
|
7
|
+
// (so the tests can verify the frame advances) — the real spinFrame is the
|
|
8
|
+
// stateless 4-cell frame resolver in @pi-archimedes/ui/editor.
|
|
9
|
+
vi.mock("@pi-archimedes/ui/editor", () => ({
|
|
10
|
+
spinFrame: vi.fn((style: string, tick: number) => {
|
|
11
|
+
const c = 0x2800 + ((tick % 8) + (style.length % 4));
|
|
12
|
+
return String.fromCharCode(c) + "⣷⣾⣽";
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Mirrors the mock above — the expected preview for a style at a tick.
|
|
17
|
+
const previewAt = (style: string, tick: number): string => {
|
|
18
|
+
const c = 0x2800 + ((tick % 8) + (style.length % 4));
|
|
19
|
+
return String.fromCharCode(c) + "⣷⣾⣽";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// A plain-object theme satisfying the structural OverlayTheme (no ANSI — the
|
|
23
|
+
// `fg` mock returns its input verbatim, so rendered lines are plain text).
|
|
24
|
+
const theme: OverlayTheme = { fg: (_t: string, s: string) => s };
|
|
25
|
+
|
|
26
|
+
// Raw terminal sequences (matchesKey compares against these, NOT the Key.*
|
|
27
|
+
// literals): down / up / enter / space / escape.
|
|
28
|
+
const DOWN = "\x1b[B";
|
|
29
|
+
const UP = "\x1b[A";
|
|
30
|
+
const ENTER = "\r";
|
|
31
|
+
const SPACE = " ";
|
|
32
|
+
const ESC = "\x1b";
|
|
33
|
+
|
|
34
|
+
type PluginSeed = { id: string; label: string; description: string; selected: boolean };
|
|
35
|
+
type Style = "Full" | "Compact";
|
|
36
|
+
type ToolOpt = "Native" | "Minimal";
|
|
37
|
+
|
|
38
|
+
function makeOverlay(opts: {
|
|
39
|
+
thinkingDefault?: Style;
|
|
40
|
+
toolDefault?: ToolOpt;
|
|
41
|
+
plugins?: PluginSeed[];
|
|
42
|
+
spinners?: readonly string[];
|
|
43
|
+
spinnerDefault?: string;
|
|
44
|
+
requestRender?: () => void;
|
|
45
|
+
} = {}) {
|
|
46
|
+
const onDone = vi.fn<(result: OnboardingResult) => void>();
|
|
47
|
+
const comp = createOnboardingOverlay({
|
|
48
|
+
theme,
|
|
49
|
+
thinkingDefault: opts.thinkingDefault ?? "Full",
|
|
50
|
+
toolDefault: opts.toolDefault ?? "Minimal",
|
|
51
|
+
plugins:
|
|
52
|
+
opts.plugins ?? [
|
|
53
|
+
{ id: "ui", label: "UI", description: "TUI enhancements", selected: true },
|
|
54
|
+
{ id: "diff", label: "Diff", description: "Diff rendering", selected: false },
|
|
55
|
+
],
|
|
56
|
+
spinners: opts.spinners ?? ["typing", "pulse", "rain"],
|
|
57
|
+
spinnerDefault: opts.spinnerDefault ?? "pulse",
|
|
58
|
+
// exactOptionalPropertyTypes: only pass the key when defined.
|
|
59
|
+
...(opts.requestRender !== undefined
|
|
60
|
+
? { requestRender: opts.requestRender }
|
|
61
|
+
: {}),
|
|
62
|
+
onDone,
|
|
63
|
+
});
|
|
64
|
+
return { comp, onDone };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resultOf(onDone: ReturnType<typeof vi.fn>): OnboardingResult {
|
|
68
|
+
const call = onDone.mock.calls[0];
|
|
69
|
+
expect(call).toBeDefined();
|
|
70
|
+
return call?.[0] as OnboardingResult;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Advance to a given step (0..3) via Enter presses (the last one finalizes).
|
|
74
|
+
function toStep(comp: { handleInput: (d: string) => void }, step: number): void {
|
|
75
|
+
for (let i = 0; i < step; i++) comp.handleInput(ENTER);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("createOnboardingOverlay", () => {
|
|
79
|
+
it("returns a TUI component with the expected shape", () => {
|
|
80
|
+
const { comp } = makeOverlay();
|
|
81
|
+
expect(comp.focused).toBe(true);
|
|
82
|
+
expect(typeof comp.render).toBe("function");
|
|
83
|
+
expect(typeof comp.handleInput).toBe("function");
|
|
84
|
+
expect(typeof comp.invalidate).toBe("function");
|
|
85
|
+
expect(typeof comp.dispose).toBe("function");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// (a) step 0 renders both thinking options + description; the default carries the "> " cursor.
|
|
89
|
+
it("step 0 shows both thinking options with the default carrying the cursor", () => {
|
|
90
|
+
const { comp } = makeOverlay({ thinkingDefault: "Compact" });
|
|
91
|
+
const lines = comp.render(80);
|
|
92
|
+
const text = lines.join("\n");
|
|
93
|
+
expect(text).toContain("Full");
|
|
94
|
+
expect(text).toContain("Compact");
|
|
95
|
+
const fullLine = lines.find((l) => l.includes("Full"));
|
|
96
|
+
const compactLine = lines.find((l) => l.includes("Compact"));
|
|
97
|
+
expect(fullLine).toBeDefined();
|
|
98
|
+
expect(compactLine).toBeDefined();
|
|
99
|
+
// The pre-selected (Compact) carries the cursor; the other does not.
|
|
100
|
+
expect(compactLine).toContain("> Compact");
|
|
101
|
+
expect(fullLine).not.toContain("> Full");
|
|
102
|
+
// The explanatory description is rendered next to the label.
|
|
103
|
+
expect(fullLine).toContain("Show all of the model's reasoning");
|
|
104
|
+
expect(compactLine).toContain("One line of reasoning (click to expand)");
|
|
105
|
+
// A corrupt/unknown default clamps to index 0 (Full), never -1.
|
|
106
|
+
const { comp: corrupt } = makeOverlay({ thinkingDefault: "Bogus" as Style });
|
|
107
|
+
expect(corrupt.render(80).join("\n")).toContain("> Full");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// (a2) step 1 renders both tool options + description; the default carries the cursor.
|
|
111
|
+
it("step 1 shows both tool options with the default carrying the cursor", () => {
|
|
112
|
+
const { comp } = makeOverlay({ toolDefault: "Native" });
|
|
113
|
+
toStep(comp, 1);
|
|
114
|
+
const lines = comp.render(80);
|
|
115
|
+
const text = lines.join("\n");
|
|
116
|
+
expect(text).toContain("Minimal");
|
|
117
|
+
expect(text).toContain("Native");
|
|
118
|
+
const minimalLine = lines.find((l) => l.includes("Minimal"));
|
|
119
|
+
const nativeLine = lines.find((l) => l.includes("Native"));
|
|
120
|
+
expect(minimalLine).toBeDefined();
|
|
121
|
+
expect(nativeLine).toBeDefined();
|
|
122
|
+
// The pre-selected (Native) carries the cursor; the other does not.
|
|
123
|
+
expect(nativeLine).toContain("> Native");
|
|
124
|
+
expect(minimalLine).not.toContain("> Minimal");
|
|
125
|
+
expect(minimalLine).toContain("Archimedes-styled tools (minimal view)");
|
|
126
|
+
expect(nativeLine).toContain("Pi's native tool rendering (no styling)");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// (b) down then enter advances to step 1 (tool) and confirms thinking; the
|
|
130
|
+
// later finalize reports thinkingAnswered with the down-arrow selection.
|
|
131
|
+
it("down + enter advances to step 1 and a later finalize reports thinkingAnswered", () => {
|
|
132
|
+
const { comp, onDone } = makeOverlay({ thinkingDefault: "Full" });
|
|
133
|
+
comp.handleInput(DOWN); // move the thinking cursor Full → Compact
|
|
134
|
+
comp.handleInput(ENTER); // confirm thinking, advance to step 1
|
|
135
|
+
expect(comp.render(80).join("\n")).toContain("Tool output style");
|
|
136
|
+
comp.handleInput(ESC); // finalize
|
|
137
|
+
expect(onDone).toHaveBeenCalledTimes(1);
|
|
138
|
+
const result = resultOf(onDone);
|
|
139
|
+
expect(result.thinkingAnswered).toBe(true);
|
|
140
|
+
expect(result.thinkingValue).toBe("Compact"); // the down-arrow selection
|
|
141
|
+
expect(result.toolAnswered).toBe(false);
|
|
142
|
+
expect(result.pluginsAnswered).toBe(false);
|
|
143
|
+
expect(result.spinnerAnswered).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// (c) space on step 2 (plugins) toggles the selected plugin; finalize reports it.
|
|
147
|
+
it("space on step 2 toggles the selected plugin", () => {
|
|
148
|
+
const { comp, onDone } = makeOverlay({
|
|
149
|
+
thinkingDefault: "Full",
|
|
150
|
+
toolDefault: "Minimal",
|
|
151
|
+
plugins: [
|
|
152
|
+
{ id: "ui", label: "UI", description: "TUI", selected: true },
|
|
153
|
+
{ id: "diff", label: "Diff", description: "Diff", selected: false },
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
comp.handleInput(ENTER); // confirm step 0 → step 1
|
|
157
|
+
comp.handleInput(ENTER); // confirm step 1 → step 2 (plugins, cursor at index 0 = ui)
|
|
158
|
+
comp.handleInput(SPACE); // toggle ui: true → false
|
|
159
|
+
comp.handleInput(ESC); // finalize
|
|
160
|
+
expect(onDone).toHaveBeenCalledTimes(1);
|
|
161
|
+
const result = resultOf(onDone);
|
|
162
|
+
expect(result.pluginSelections).toEqual({ ui: false, diff: false });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// (c2) space on the non-plugin steps does NOT toggle anything.
|
|
166
|
+
it("space on the thinking/tool/spinner steps is a no-op", () => {
|
|
167
|
+
const { comp, onDone } = makeOverlay({
|
|
168
|
+
plugins: [
|
|
169
|
+
{ id: "ui", label: "UI", description: "TUI", selected: true },
|
|
170
|
+
{ id: "diff", label: "Diff", description: "Diff", selected: false },
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
comp.handleInput(SPACE); // step 0: no-op
|
|
174
|
+
comp.handleInput(ENTER); // step 0 → 1
|
|
175
|
+
comp.handleInput(SPACE); // step 1: no-op
|
|
176
|
+
comp.handleInput(ENTER); // step 1 → 2
|
|
177
|
+
comp.handleInput(ENTER); // step 2 → 3 (plugins cursor at 0 = ui, still selected)
|
|
178
|
+
comp.handleInput(SPACE); // step 3: no-op
|
|
179
|
+
comp.handleInput(ESC); // finalize
|
|
180
|
+
const result = resultOf(onDone);
|
|
181
|
+
expect(result.pluginSelections).toEqual({ ui: true, diff: false });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// (d) escape on step 0 finalizes with nothing answered, exactly once.
|
|
185
|
+
it("escape on step 0 calls onDone once with nothing answered", () => {
|
|
186
|
+
const { comp, onDone } = makeOverlay();
|
|
187
|
+
comp.handleInput(ESC);
|
|
188
|
+
expect(onDone).toHaveBeenCalledTimes(1);
|
|
189
|
+
const result = resultOf(onDone);
|
|
190
|
+
expect(result.thinkingAnswered).toBe(false);
|
|
191
|
+
expect(result.toolAnswered).toBe(false);
|
|
192
|
+
expect(result.pluginsAnswered).toBe(false);
|
|
193
|
+
expect(result.spinnerAnswered).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// (e) completing all four steps reports everything answered + selected values.
|
|
197
|
+
it("completing all four steps reports all answered with the selected values", () => {
|
|
198
|
+
const { comp, onDone } = makeOverlay({
|
|
199
|
+
thinkingDefault: "Full",
|
|
200
|
+
toolDefault: "Native",
|
|
201
|
+
plugins: [
|
|
202
|
+
{ id: "ui", label: "UI", description: "TUI", selected: true },
|
|
203
|
+
{ id: "diff", label: "Diff", description: "Diff", selected: false },
|
|
204
|
+
],
|
|
205
|
+
spinners: ["typing", "pulse", "rain"],
|
|
206
|
+
spinnerDefault: "pulse",
|
|
207
|
+
});
|
|
208
|
+
comp.handleInput(ENTER); // step 0 → 1
|
|
209
|
+
comp.handleInput(ENTER); // step 1 → 2
|
|
210
|
+
comp.handleInput(ENTER); // step 2 → 3
|
|
211
|
+
comp.handleInput(ENTER); // step 3 → finalize
|
|
212
|
+
expect(onDone).toHaveBeenCalledTimes(1);
|
|
213
|
+
const result = resultOf(onDone);
|
|
214
|
+
expect(result.thinkingAnswered).toBe(true);
|
|
215
|
+
expect(result.toolAnswered).toBe(true);
|
|
216
|
+
expect(result.pluginsAnswered).toBe(true);
|
|
217
|
+
expect(result.spinnerAnswered).toBe(true);
|
|
218
|
+
expect(result.thinkingValue).toBe("Full");
|
|
219
|
+
expect(result.toolValue).toBe("Native");
|
|
220
|
+
expect(result.pluginSelections).toEqual({ ui: true, diff: false });
|
|
221
|
+
expect(result.spinnerValue).toBe("pulse");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// (e2) the user can pick DIFFERENT thinking vs tool styles (Full thinking +
|
|
225
|
+
// Native tool) — the payload carries both independently.
|
|
226
|
+
it("supports different thinking vs tool styles (Full thinking + Native tool)", () => {
|
|
227
|
+
const { comp, onDone } = makeOverlay({
|
|
228
|
+
thinkingDefault: "Full",
|
|
229
|
+
toolDefault: "Minimal",
|
|
230
|
+
});
|
|
231
|
+
comp.handleInput(ENTER); // step 0: confirm Full thinking
|
|
232
|
+
comp.handleInput(UP); // step 1: move the tool cursor Minimal → Native (Native is the first option, Minimal the second)
|
|
233
|
+
comp.handleInput(ENTER); // step 1 → 2 (confirm Native tool)
|
|
234
|
+
comp.handleInput(ENTER); // step 2 → 3
|
|
235
|
+
comp.handleInput(ENTER); // step 3 → finalize
|
|
236
|
+
expect(onDone).toHaveBeenCalledTimes(1);
|
|
237
|
+
const result = resultOf(onDone);
|
|
238
|
+
expect(result.thinkingValue).toBe("Full");
|
|
239
|
+
expect(result.toolValue).toBe("Native");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// (f) onDone is called at most once — a second finalize/escape is a no-op.
|
|
243
|
+
it("onDone is called at most once (repeated finalize is a no-op)", () => {
|
|
244
|
+
const { comp, onDone } = makeOverlay();
|
|
245
|
+
comp.handleInput(ENTER); // step 0 → 1
|
|
246
|
+
comp.handleInput(ENTER); // step 1 → 2
|
|
247
|
+
comp.handleInput(ENTER); // step 2 → 3
|
|
248
|
+
comp.handleInput(ENTER); // step 3 → finalize (first onDone)
|
|
249
|
+
comp.handleInput(ESC); // second finalize attempt → no-op
|
|
250
|
+
comp.handleInput(ENTER); // enter after finalize → no-op
|
|
251
|
+
comp.handleInput(DOWN); // cursor moves are still safe no-ops
|
|
252
|
+
expect(onDone).toHaveBeenCalledTimes(1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Up-arrow clamps at the top (never a negative index).
|
|
256
|
+
it("up-arrow clamps the cursor at index 0", () => {
|
|
257
|
+
const { comp, onDone } = makeOverlay({ thinkingDefault: "Full" });
|
|
258
|
+
comp.handleInput(UP); // already at 0 → stays 0
|
|
259
|
+
comp.handleInput(UP);
|
|
260
|
+
comp.handleInput(ENTER); // confirm
|
|
261
|
+
comp.handleInput(ENTER);
|
|
262
|
+
comp.handleInput(ENTER);
|
|
263
|
+
comp.handleInput(ENTER); // finalize
|
|
264
|
+
const result = resultOf(onDone);
|
|
265
|
+
expect(result.thinkingValue).toBe("Full"); // never wrapped to a negative index
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// (g) The spinner step renders a live 4-char preview next to each name
|
|
269
|
+
// (tick 0 — no requestRender means no timer, so the frame is static).
|
|
270
|
+
it("spinner step renders a 4-char preview next to each name", () => {
|
|
271
|
+
const { comp } = makeOverlay({ spinners: ["typing", "pulse", "rain"] });
|
|
272
|
+
toStep(comp, 3); // steps 0 → 1 → 2 → 3
|
|
273
|
+
const lines = comp.render(80);
|
|
274
|
+
expect(lines.join("\n")).toContain("Which spinner for the editor border?");
|
|
275
|
+
for (const name of ["typing", "pulse", "rain"]) {
|
|
276
|
+
const line = lines.find((l) => l.includes(name));
|
|
277
|
+
expect(line).toBeDefined();
|
|
278
|
+
expect(line).toContain(previewAt(name, 0));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// (g2) The spinner options render consecutively — NO blank line between
|
|
283
|
+
// them: the 10-option list must fit the overlay's 80% max height on a small
|
|
284
|
+
// terminal, so the per-option vertical spacing was dropped. A border-only
|
|
285
|
+
// line renders as `││` after trimming whitespace.
|
|
286
|
+
it("spinner step renders options consecutively (no blank line between them)", () => {
|
|
287
|
+
const { comp } = makeOverlay({ spinners: ["typing", "pulse", "rain"] });
|
|
288
|
+
toStep(comp, 3);
|
|
289
|
+
const content = comp.render(80).map((l) => l.trim());
|
|
290
|
+
const names = ["typing", "pulse", "rain"];
|
|
291
|
+
for (let i = 0; i < names.length - 1; i++) {
|
|
292
|
+
const name = names[i];
|
|
293
|
+
if (name === undefined) continue;
|
|
294
|
+
const line = content.findIndex((l) => l.includes(name));
|
|
295
|
+
expect(line).toBeGreaterThanOrEqual(0);
|
|
296
|
+
const below = content[line + 1];
|
|
297
|
+
expect(below).toBeDefined();
|
|
298
|
+
// The line directly below an option is the NEXT option (has text), not
|
|
299
|
+
// a border-only spacer.
|
|
300
|
+
expect((below ?? "").replace(/\s/g, "")).not.toBe("││");
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// (g3) The 10-option spinner step renders a specific total height (border +
|
|
305
|
+
// content) so a future change that adds or removes lines is caught. The
|
|
306
|
+
// compact layout (no per-option spacing, no blank after the header note)
|
|
307
|
+
// keeps the tallest step at 19 lines: 3 header lines + question + blank +
|
|
308
|
+
// 10 options + separator blank + footer = 17 content; the border adds top
|
|
309
|
+
// + bottom = 19 total — fits an 80%-max-height overlay on a 24-row terminal
|
|
310
|
+
// (~19.2 rows).
|
|
311
|
+
it("spinner step with the 10 real SPINNER_STYLES renders exactly 19 lines", () => {
|
|
312
|
+
const { comp } = makeOverlay({ spinners: SPINNER_STYLES });
|
|
313
|
+
toStep(comp, 3);
|
|
314
|
+
const lines = comp.render(80);
|
|
315
|
+
expect(SPINNER_STYLES).toHaveLength(10);
|
|
316
|
+
expect(lines).toHaveLength(19);
|
|
317
|
+
// Every one of the 10 names is rendered.
|
|
318
|
+
for (const name of SPINNER_STYLES) {
|
|
319
|
+
expect(lines.find((l) => l.includes(name))).toBeDefined();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// (h) When requestRender is provided, the 40 ms interval fires it and the
|
|
324
|
+
// rendered preview advances (tick increments).
|
|
325
|
+
it("with requestRender: the interval fires it and the preview advances", () => {
|
|
326
|
+
vi.useFakeTimers();
|
|
327
|
+
const requestRender = vi.fn();
|
|
328
|
+
const { comp } = makeOverlay({ requestRender });
|
|
329
|
+
toStep(comp, 3); // (spinner step)
|
|
330
|
+
const beforeLines = comp.render(80);
|
|
331
|
+
expect(beforeLines.join("\n")).toContain(previewAt("typing", 0));
|
|
332
|
+
vi.advanceTimersByTime(40);
|
|
333
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
334
|
+
vi.advanceTimersByTime(40);
|
|
335
|
+
expect(requestRender).toHaveBeenCalledTimes(2);
|
|
336
|
+
const afterLines = comp.render(80);
|
|
337
|
+
expect(afterLines.join("\n")).toContain(previewAt("typing", 2));
|
|
338
|
+
// The typing line's frame advanced (the previews are live, not static).
|
|
339
|
+
const typingBefore = beforeLines.find((l) => l.includes("typing"));
|
|
340
|
+
const typingAfter = afterLines.find((l) => l.includes("typing"));
|
|
341
|
+
expect(typingAfter).toBeDefined();
|
|
342
|
+
expect(typingBefore).toBeDefined();
|
|
343
|
+
expect(typingAfter).not.toBe(typingBefore);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// (i) dispose() stops the interval — no more requestRender calls after close.
|
|
347
|
+
it("dispose() stops the interval (no more requestRender calls)", () => {
|
|
348
|
+
vi.useFakeTimers();
|
|
349
|
+
const requestRender = vi.fn();
|
|
350
|
+
const { comp } = makeOverlay({ requestRender });
|
|
351
|
+
toStep(comp, 3); // (the interval only ticks here)
|
|
352
|
+
vi.advanceTimersByTime(40);
|
|
353
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
354
|
+
comp.dispose();
|
|
355
|
+
vi.advanceTimersByTime(400);
|
|
356
|
+
expect(requestRender).toHaveBeenCalledTimes(1); // no further calls
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// (j) The interval only ticks on the spinner step (step 3) — the
|
|
360
|
+
// thinking/tool/plugin steps render no live frame, so no repaint is
|
|
361
|
+
// requested (and no tick advances) while the user is off the spinner step.
|
|
362
|
+
it("the interval only ticks on the spinner step (no repaint on the other steps)", () => {
|
|
363
|
+
vi.useFakeTimers();
|
|
364
|
+
const requestRender = vi.fn();
|
|
365
|
+
const { comp } = makeOverlay({ requestRender });
|
|
366
|
+
vi.advanceTimersByTime(400);
|
|
367
|
+
expect(requestRender).not.toHaveBeenCalled(); // step 0: no repaint
|
|
368
|
+
comp.handleInput(ENTER); // step 0 → 1
|
|
369
|
+
vi.advanceTimersByTime(400);
|
|
370
|
+
expect(requestRender).not.toHaveBeenCalled(); // step 1: no repaint
|
|
371
|
+
comp.handleInput(ENTER); // step 1 → 2
|
|
372
|
+
vi.advanceTimersByTime(400);
|
|
373
|
+
expect(requestRender).not.toHaveBeenCalled(); // step 2: no repaint
|
|
374
|
+
comp.handleInput(ENTER); // step 2 → 3
|
|
375
|
+
vi.advanceTimersByTime(40);
|
|
376
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
377
|
+
vi.advanceTimersByTime(40);
|
|
378
|
+
expect(requestRender).toHaveBeenCalledTimes(2);
|
|
379
|
+
const lines = comp.render(80);
|
|
380
|
+
expect(lines.join("\n")).toContain(previewAt("typing", 2)); // tick advanced here
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// (k) finalize() stops the interval (belt-and-braces — in case the TUI tears
|
|
384
|
+
// down without calling dispose()).
|
|
385
|
+
it("finalize() stops the interval (no repaint after the wizard is done)", () => {
|
|
386
|
+
vi.useFakeTimers();
|
|
387
|
+
const requestRender = vi.fn();
|
|
388
|
+
const { comp, onDone } = makeOverlay({ requestRender });
|
|
389
|
+
toStep(comp, 3);
|
|
390
|
+
vi.advanceTimersByTime(40);
|
|
391
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
392
|
+
comp.handleInput(ENTER); // step 3 → finalize
|
|
393
|
+
expect(onDone).toHaveBeenCalledTimes(1);
|
|
394
|
+
vi.advanceTimersByTime(400);
|
|
395
|
+
expect(requestRender).toHaveBeenCalledTimes(1); // the interval was cleared in finalize
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// (l) With an empty options list the cursor is clamped at 0 — the min() bound
|
|
399
|
+
// alone could drive it to -1 (zero options → bound -1); Math.max(0, …) keeps it
|
|
400
|
+
// non-negative (finalize then yields a valid empty value, not a crash).
|
|
401
|
+
it("down-arrow with an empty options list keeps the cursor non-negative", () => {
|
|
402
|
+
const { comp, onDone } = makeOverlay({ spinners: [] });
|
|
403
|
+
toStep(comp, 3); // (empty spinner list)
|
|
404
|
+
comp.handleInput(DOWN); // must stay at 0, never -1
|
|
405
|
+
comp.handleInput(DOWN);
|
|
406
|
+
comp.handleInput(UP);
|
|
407
|
+
comp.handleInput(ENTER); // finalize
|
|
408
|
+
expect(onDone).toHaveBeenCalledTimes(1);
|
|
409
|
+
const result = resultOf(onDone);
|
|
410
|
+
expect(result.spinnerAnswered).toBe(true);
|
|
411
|
+
expect(result.spinnerValue).toBe(""); // empty list → empty value, no crash
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// (l2) Down-arrow clamps at the BOTTOM of a 2-option step (the Math.min(count-1, …)
|
|
415
|
+
// ceiling) — repeated DOWN keeps the cursor at the last option, never past it.
|
|
416
|
+
it("down-arrow clamps the cursor at the last option of a 2-option step", () => {
|
|
417
|
+
const { comp } = makeOverlay({ thinkingDefault: "Full" });
|
|
418
|
+
comp.handleInput(DOWN); // Full → Compact (index 1)
|
|
419
|
+
comp.handleInput(DOWN); // already at the last option → stays 1
|
|
420
|
+
const lines = comp.render(80);
|
|
421
|
+
expect(lines.find((l) => l.includes("Compact"))).toContain("> Compact");
|
|
422
|
+
expect(lines.find((l) => l.includes("Full"))).not.toContain("> Full");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// (p) The plugin step shows the CURSOR on the active row alongside each
|
|
426
|
+
// row's enabled state (✓/·) — without the cursor, the user can't tell which
|
|
427
|
+
// plugin Space is about to toggle.
|
|
428
|
+
it("plugin step shows the cursor on the active row alongside its enabled state", () => {
|
|
429
|
+
const { comp } = makeOverlay({
|
|
430
|
+
plugins: [
|
|
431
|
+
{ id: "footer", label: "Footer", description: "Status bar", selected: true },
|
|
432
|
+
{ id: "diff", label: "Diff", description: "Diff rendering", selected: false },
|
|
433
|
+
],
|
|
434
|
+
});
|
|
435
|
+
toStep(comp, 2);
|
|
436
|
+
let lines = comp.render(80);
|
|
437
|
+
expect(lines.find((l) => l.includes("Footer"))).toContain(">"); // cursor: the active row
|
|
438
|
+
expect(lines.find((l) => l.includes("Footer"))).toContain("✓"); // ...and its enabled state
|
|
439
|
+
expect(lines.find((l) => l.includes("Diff"))).not.toContain(">");
|
|
440
|
+
expect(lines.find((l) => l.includes("Diff"))).toContain("·");
|
|
441
|
+
comp.handleInput(DOWN); // move the cursor to diff
|
|
442
|
+
lines = comp.render(80);
|
|
443
|
+
expect(lines.find((l) => l.includes("Diff"))).toContain(">");
|
|
444
|
+
expect(lines.find((l) => l.includes("Footer"))).not.toContain(">");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// (c3) space toggles the plugin at the MOVED cursor (not always index 0).
|
|
448
|
+
it("space on step 2 toggles the plugin at the moved cursor (index 1, not 0)", () => {
|
|
449
|
+
const { comp, onDone } = makeOverlay({
|
|
450
|
+
plugins: [
|
|
451
|
+
{ id: "ui", label: "UI", description: "TUI", selected: true },
|
|
452
|
+
{ id: "diff", label: "Diff", description: "Diff", selected: false },
|
|
453
|
+
],
|
|
454
|
+
});
|
|
455
|
+
comp.handleInput(ENTER); // step 0 → 1
|
|
456
|
+
comp.handleInput(ENTER); // step 1 → 2 (plugins, cursor at index 0 = ui)
|
|
457
|
+
comp.handleInput(DOWN); // move to index 1 = diff
|
|
458
|
+
comp.handleInput(SPACE); // toggle diff: false → true (ui stays true)
|
|
459
|
+
comp.handleInput(ESC); // finalize
|
|
460
|
+
expect(onDone).toHaveBeenCalledTimes(1);
|
|
461
|
+
const result = resultOf(onDone);
|
|
462
|
+
expect(result.pluginSelections).toEqual({ ui: true, diff: true });
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// (n) The header area carries a static next-session note on every step —
|
|
466
|
+
// the style choices are written to archimedes.ui but the renderer patches
|
|
467
|
+
// are configured at session start, so the note sets expectations up front.
|
|
468
|
+
it("renders the next-session note on all four steps", () => {
|
|
469
|
+
for (let step = 0; step < 4; step++) {
|
|
470
|
+
const { comp } = makeOverlay();
|
|
471
|
+
toStep(comp, step);
|
|
472
|
+
const lines = comp.render(80);
|
|
473
|
+
const note = lines.find((l) => l.includes("Choices apply from your next session."));
|
|
474
|
+
expect(note).toBeDefined();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// (n2) The step counter reads N/4 (four steps, not three).
|
|
479
|
+
it("the step counter reads N/4 on every step", () => {
|
|
480
|
+
for (let step = 0; step < 4; step++) {
|
|
481
|
+
const { comp } = makeOverlay();
|
|
482
|
+
toStep(comp, step);
|
|
483
|
+
const counter = comp.render(80).find((l) => l.includes(`· ${step + 1}/4`));
|
|
484
|
+
expect(counter).toBeDefined();
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// (m) The spinner-step footer makes the esc skip/discard semantics explicit —
|
|
489
|
+
// esc finalizes and reports ONLY the confirmed steps (the unconfirmed ones
|
|
490
|
+
// are skipped, not applied), so the hint must not read like "keep my changes".
|
|
491
|
+
it("spinner step footer makes the esc skip/discard semantics explicit", () => {
|
|
492
|
+
const { comp } = makeOverlay();
|
|
493
|
+
toStep(comp, 3);
|
|
494
|
+
const footer = comp.render(80).find((l) => l.includes("esc"));
|
|
495
|
+
expect(footer).toBeDefined();
|
|
496
|
+
expect(footer).toContain("finish (skip rest)");
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
afterEach(() => {
|
|
501
|
+
vi.useRealTimers();
|
|
502
|
+
vi.clearAllMocks();
|
|
503
|
+
});
|