@dealdeploy/skl 0.3.0 → 1.0.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/add.ts +55 -64
- package/index.ts +110 -974
- package/lib.test.ts +110 -41
- package/lib.ts +125 -38
- package/package.json +1 -1
- package/tui.test.ts +565 -0
- package/tui.ts +612 -0
- package/update.ts +102 -87
package/tui.test.ts
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { createTestRenderer } from "@opentui/core/testing";
|
|
3
|
+
import { createTui, type ColId } from "./tui.ts";
|
|
4
|
+
|
|
5
|
+
const WIDTH = 80;
|
|
6
|
+
const HEIGHT = 20;
|
|
7
|
+
|
|
8
|
+
type ToggleCall = { col: ColId; name: string; enable: boolean };
|
|
9
|
+
type DeleteCall = { name: string };
|
|
10
|
+
|
|
11
|
+
function setup(opts?: {
|
|
12
|
+
skills?: string[];
|
|
13
|
+
globalInstalled?: string[];
|
|
14
|
+
localInstalled?: string[];
|
|
15
|
+
}) {
|
|
16
|
+
const skills = opts?.skills ?? ["alpha", "beta", "gamma"];
|
|
17
|
+
const globalInstalled = new Set(opts?.globalInstalled ?? []);
|
|
18
|
+
const localInstalled = new Set(opts?.localInstalled ?? []);
|
|
19
|
+
|
|
20
|
+
const toggleCalls: ToggleCall[] = [];
|
|
21
|
+
const deleteCalls: DeleteCall[] = [];
|
|
22
|
+
const editCalls: string[] = [];
|
|
23
|
+
let quitCalled = false;
|
|
24
|
+
|
|
25
|
+
// Toggle resolvers for async control
|
|
26
|
+
let pendingToggleResolve: ((ok: boolean) => void) | null = null;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
skills,
|
|
30
|
+
globalInstalled,
|
|
31
|
+
localInstalled,
|
|
32
|
+
toggleCalls,
|
|
33
|
+
deleteCalls,
|
|
34
|
+
editCalls,
|
|
35
|
+
get quitCalled() { return quitCalled; },
|
|
36
|
+
resolveToggle(ok: boolean) {
|
|
37
|
+
pendingToggleResolve?.(ok);
|
|
38
|
+
pendingToggleResolve = null;
|
|
39
|
+
},
|
|
40
|
+
makeDeps() {
|
|
41
|
+
return {
|
|
42
|
+
allSkills: skills,
|
|
43
|
+
globalInstalled,
|
|
44
|
+
localInstalled,
|
|
45
|
+
catalogPath: "/tmp/test-catalog",
|
|
46
|
+
async onToggle(col: ColId, name: string, enable: boolean) {
|
|
47
|
+
toggleCalls.push({ col, name, enable });
|
|
48
|
+
return new Promise<boolean>((resolve) => {
|
|
49
|
+
pendingToggleResolve = resolve;
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
async onDelete(name: string) {
|
|
53
|
+
deleteCalls.push({ name });
|
|
54
|
+
},
|
|
55
|
+
async onEdit(name: string) {
|
|
56
|
+
editCalls.push(name);
|
|
57
|
+
},
|
|
58
|
+
onQuit() {
|
|
59
|
+
quitCalled = true;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
// Immediate toggle (resolves instantly)
|
|
64
|
+
makeDepsImmediate(toggleResult = true) {
|
|
65
|
+
return {
|
|
66
|
+
allSkills: skills,
|
|
67
|
+
globalInstalled,
|
|
68
|
+
localInstalled,
|
|
69
|
+
catalogPath: "/tmp/test-catalog",
|
|
70
|
+
async onToggle(col: ColId, name: string, enable: boolean) {
|
|
71
|
+
toggleCalls.push({ col, name, enable });
|
|
72
|
+
return toggleResult;
|
|
73
|
+
},
|
|
74
|
+
async onDelete(name: string) {
|
|
75
|
+
deleteCalls.push({ name });
|
|
76
|
+
},
|
|
77
|
+
async onEdit(name: string) {
|
|
78
|
+
editCalls.push(name);
|
|
79
|
+
},
|
|
80
|
+
onQuit() {
|
|
81
|
+
quitCalled = true;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test("renders skill names and column headers", async () => {
|
|
89
|
+
const ctx = setup();
|
|
90
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
91
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
92
|
+
|
|
93
|
+
createTui(renderer, ctx.makeDepsImmediate());
|
|
94
|
+
await renderOnce();
|
|
95
|
+
|
|
96
|
+
const frame = captureCharFrame();
|
|
97
|
+
expect(frame).toContain("Skill");
|
|
98
|
+
expect(frame).toContain("Global");
|
|
99
|
+
expect(frame).toContain("Local");
|
|
100
|
+
expect(frame).toContain("alpha");
|
|
101
|
+
expect(frame).toContain("beta");
|
|
102
|
+
expect(frame).toContain("gamma");
|
|
103
|
+
|
|
104
|
+
renderer.destroy();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("renders checkboxes for installed skills", async () => {
|
|
108
|
+
const ctx = setup({ globalInstalled: ["alpha"], localInstalled: ["beta"] });
|
|
109
|
+
const { renderer, renderOnce, captureCharFrame } =
|
|
110
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
111
|
+
|
|
112
|
+
createTui(renderer, ctx.makeDepsImmediate());
|
|
113
|
+
await renderOnce();
|
|
114
|
+
|
|
115
|
+
const frame = captureCharFrame();
|
|
116
|
+
// alpha should have [x] in global column and [ ] in local
|
|
117
|
+
// beta should have [ ] in global and [x] in local
|
|
118
|
+
// gamma should have [ ] in both
|
|
119
|
+
const lines = frame.split("\n");
|
|
120
|
+
const alphaLine = lines.find((l) => l.includes("alpha"));
|
|
121
|
+
const betaLine = lines.find((l) => l.includes("beta"));
|
|
122
|
+
const gammaLine = lines.find((l) => l.includes("gamma"));
|
|
123
|
+
|
|
124
|
+
expect(alphaLine).toBeDefined();
|
|
125
|
+
expect(betaLine).toBeDefined();
|
|
126
|
+
expect(gammaLine).toBeDefined();
|
|
127
|
+
|
|
128
|
+
// Count [x] occurrences per line
|
|
129
|
+
const countChecked = (line: string) => (line.match(/\[x\]/g) ?? []).length;
|
|
130
|
+
const countUnchecked = (line: string) => (line.match(/\[ \]/g) ?? []).length;
|
|
131
|
+
|
|
132
|
+
expect(countChecked(alphaLine!)).toBe(1); // global checked
|
|
133
|
+
expect(countUnchecked(alphaLine!)).toBe(1); // local unchecked
|
|
134
|
+
expect(countChecked(betaLine!)).toBe(1); // local checked
|
|
135
|
+
expect(countUnchecked(betaLine!)).toBe(1); // global unchecked
|
|
136
|
+
expect(countUnchecked(gammaLine!)).toBe(2); // both unchecked
|
|
137
|
+
|
|
138
|
+
renderer.destroy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("starts in search focus area", async () => {
|
|
142
|
+
const ctx = setup();
|
|
143
|
+
const { renderer, renderOnce, captureCharFrame } =
|
|
144
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
145
|
+
|
|
146
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
147
|
+
await renderOnce();
|
|
148
|
+
|
|
149
|
+
expect(tui.state.focusArea).toBe("search");
|
|
150
|
+
|
|
151
|
+
const frame = captureCharFrame();
|
|
152
|
+
// Search bar should show cursor indicator
|
|
153
|
+
expect(frame).toContain("\u25b8");
|
|
154
|
+
|
|
155
|
+
renderer.destroy();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("arrow down moves to grid", async () => {
|
|
159
|
+
const ctx = setup();
|
|
160
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
161
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
162
|
+
|
|
163
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
164
|
+
await renderOnce();
|
|
165
|
+
|
|
166
|
+
expect(tui.state.focusArea).toBe("search");
|
|
167
|
+
|
|
168
|
+
mockInput.pressArrow("down");
|
|
169
|
+
await renderOnce();
|
|
170
|
+
|
|
171
|
+
expect(tui.state.focusArea).toBe("grid");
|
|
172
|
+
expect(tui.state.cursor).toBe(0);
|
|
173
|
+
|
|
174
|
+
renderer.destroy();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("arrow down navigates through skills in grid", async () => {
|
|
178
|
+
const ctx = setup();
|
|
179
|
+
const { renderer, mockInput, renderOnce } =
|
|
180
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
181
|
+
|
|
182
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
183
|
+
|
|
184
|
+
// Enter grid
|
|
185
|
+
mockInput.pressArrow("down");
|
|
186
|
+
await renderOnce();
|
|
187
|
+
expect(tui.state.cursor).toBe(0);
|
|
188
|
+
expect(tui.state.currentSkillIndex()).toBe(0);
|
|
189
|
+
|
|
190
|
+
// Move down
|
|
191
|
+
mockInput.pressArrow("down");
|
|
192
|
+
await renderOnce();
|
|
193
|
+
expect(tui.state.cursor).toBe(1);
|
|
194
|
+
|
|
195
|
+
// Move down again
|
|
196
|
+
mockInput.pressArrow("down");
|
|
197
|
+
await renderOnce();
|
|
198
|
+
expect(tui.state.cursor).toBe(2);
|
|
199
|
+
|
|
200
|
+
// Can't move past last skill
|
|
201
|
+
mockInput.pressArrow("down");
|
|
202
|
+
await renderOnce();
|
|
203
|
+
expect(tui.state.cursor).toBe(2);
|
|
204
|
+
|
|
205
|
+
renderer.destroy();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("left/right switches columns", async () => {
|
|
209
|
+
const ctx = setup();
|
|
210
|
+
const { renderer, mockInput, renderOnce } =
|
|
211
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
212
|
+
|
|
213
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
214
|
+
|
|
215
|
+
// Enter grid
|
|
216
|
+
mockInput.pressArrow("down");
|
|
217
|
+
await renderOnce();
|
|
218
|
+
expect(tui.state.cursorCol).toBe("global");
|
|
219
|
+
|
|
220
|
+
mockInput.pressArrow("right");
|
|
221
|
+
await renderOnce();
|
|
222
|
+
expect(tui.state.cursorCol).toBe("local");
|
|
223
|
+
|
|
224
|
+
// Wraps around
|
|
225
|
+
mockInput.pressArrow("right");
|
|
226
|
+
await renderOnce();
|
|
227
|
+
expect(tui.state.cursorCol).toBe("global");
|
|
228
|
+
|
|
229
|
+
mockInput.pressArrow("left");
|
|
230
|
+
await renderOnce();
|
|
231
|
+
expect(tui.state.cursorCol).toBe("local");
|
|
232
|
+
|
|
233
|
+
renderer.destroy();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("search filters skills", async () => {
|
|
237
|
+
const ctx = setup({ skills: ["alpha", "beta", "gamma", "alphabet"] });
|
|
238
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
239
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
240
|
+
|
|
241
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
242
|
+
await renderOnce();
|
|
243
|
+
|
|
244
|
+
// Type "alph" in search
|
|
245
|
+
mockInput.typeText("alph");
|
|
246
|
+
await renderOnce();
|
|
247
|
+
|
|
248
|
+
expect(tui.state.searchQuery).toBe("alph");
|
|
249
|
+
expect(tui.state.filteredIndices.length).toBe(2); // alpha and alphabet
|
|
250
|
+
|
|
251
|
+
const frame = captureCharFrame();
|
|
252
|
+
expect(frame).toContain("alpha");
|
|
253
|
+
expect(frame).toContain("alphabet");
|
|
254
|
+
expect(frame).not.toContain("beta");
|
|
255
|
+
expect(frame).not.toContain("gamma");
|
|
256
|
+
|
|
257
|
+
renderer.destroy();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("escape clears search", async () => {
|
|
261
|
+
const ctx = setup();
|
|
262
|
+
const { renderer, mockInput, renderOnce } =
|
|
263
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
264
|
+
|
|
265
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
266
|
+
|
|
267
|
+
await mockInput.typeText("test");
|
|
268
|
+
await renderOnce();
|
|
269
|
+
expect(tui.state.searchQuery).toBe("test");
|
|
270
|
+
|
|
271
|
+
mockInput.pressEscape();
|
|
272
|
+
// ESC byte needs time to disambiguate from CSI sequence start
|
|
273
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
274
|
+
await renderOnce();
|
|
275
|
+
expect(tui.state.searchQuery).toBe("");
|
|
276
|
+
expect(tui.state.filteredIndices.length).toBe(3); // all skills visible
|
|
277
|
+
|
|
278
|
+
renderer.destroy();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("enter in grid triggers toggle", async () => {
|
|
282
|
+
const ctx = setup();
|
|
283
|
+
const { renderer, mockInput, renderOnce } =
|
|
284
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
285
|
+
|
|
286
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
287
|
+
|
|
288
|
+
// Enter grid, cursor on alpha, global column
|
|
289
|
+
mockInput.pressArrow("down");
|
|
290
|
+
await renderOnce();
|
|
291
|
+
|
|
292
|
+
mockInput.pressEnter();
|
|
293
|
+
await renderOnce();
|
|
294
|
+
|
|
295
|
+
// Should have called onToggle with global, alpha, enable=true
|
|
296
|
+
expect(ctx.toggleCalls.length).toBe(1);
|
|
297
|
+
expect(ctx.toggleCalls[0]).toEqual({ col: "global", name: "alpha", enable: true });
|
|
298
|
+
|
|
299
|
+
renderer.destroy();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("toggle on local column", async () => {
|
|
303
|
+
const ctx = setup();
|
|
304
|
+
const { renderer, mockInput, renderOnce } =
|
|
305
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
306
|
+
|
|
307
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
308
|
+
|
|
309
|
+
// Enter grid
|
|
310
|
+
mockInput.pressArrow("down");
|
|
311
|
+
await renderOnce();
|
|
312
|
+
|
|
313
|
+
// Move to local column
|
|
314
|
+
mockInput.pressArrow("right");
|
|
315
|
+
await renderOnce();
|
|
316
|
+
|
|
317
|
+
// Move to beta
|
|
318
|
+
mockInput.pressArrow("down");
|
|
319
|
+
await renderOnce();
|
|
320
|
+
|
|
321
|
+
mockInput.pressEnter();
|
|
322
|
+
await renderOnce();
|
|
323
|
+
|
|
324
|
+
expect(ctx.toggleCalls.length).toBe(1);
|
|
325
|
+
expect(ctx.toggleCalls[0]).toEqual({ col: "local", name: "beta", enable: true });
|
|
326
|
+
|
|
327
|
+
renderer.destroy();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("toggle off an installed skill", async () => {
|
|
331
|
+
const ctx = setup({ globalInstalled: ["alpha"] });
|
|
332
|
+
const { renderer, mockInput, renderOnce } =
|
|
333
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
334
|
+
|
|
335
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
336
|
+
|
|
337
|
+
// Enter grid on alpha (which is globally installed)
|
|
338
|
+
mockInput.pressArrow("down");
|
|
339
|
+
await renderOnce();
|
|
340
|
+
|
|
341
|
+
mockInput.pressEnter();
|
|
342
|
+
await renderOnce();
|
|
343
|
+
|
|
344
|
+
expect(ctx.toggleCalls[0]).toEqual({ col: "global", name: "alpha", enable: false });
|
|
345
|
+
|
|
346
|
+
renderer.destroy();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("d key prompts for delete, y confirms", async () => {
|
|
350
|
+
const ctx = setup();
|
|
351
|
+
const { renderer, mockInput, renderOnce } =
|
|
352
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
353
|
+
|
|
354
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
355
|
+
|
|
356
|
+
// Enter grid
|
|
357
|
+
mockInput.pressArrow("down");
|
|
358
|
+
await renderOnce();
|
|
359
|
+
|
|
360
|
+
// Press d
|
|
361
|
+
mockInput.pressKey("d");
|
|
362
|
+
await renderOnce();
|
|
363
|
+
|
|
364
|
+
expect(tui.state.pendingDelete).toBe(0);
|
|
365
|
+
|
|
366
|
+
// Confirm with y
|
|
367
|
+
mockInput.pressKey("y");
|
|
368
|
+
await renderOnce();
|
|
369
|
+
|
|
370
|
+
expect(ctx.deleteCalls.length).toBe(1);
|
|
371
|
+
expect(ctx.deleteCalls[0]).toEqual({ name: "alpha" });
|
|
372
|
+
expect(tui.state.pendingDelete).toBeNull();
|
|
373
|
+
|
|
374
|
+
renderer.destroy();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("d key prompts for delete, other key cancels", async () => {
|
|
378
|
+
const ctx = setup();
|
|
379
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
380
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
381
|
+
|
|
382
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
383
|
+
|
|
384
|
+
// Enter grid
|
|
385
|
+
mockInput.pressArrow("down");
|
|
386
|
+
await renderOnce();
|
|
387
|
+
|
|
388
|
+
mockInput.pressKey("d");
|
|
389
|
+
await renderOnce();
|
|
390
|
+
expect(tui.state.pendingDelete).toBe(0);
|
|
391
|
+
|
|
392
|
+
// Cancel with n
|
|
393
|
+
mockInput.pressKey("n");
|
|
394
|
+
await renderOnce();
|
|
395
|
+
|
|
396
|
+
expect(ctx.deleteCalls.length).toBe(0);
|
|
397
|
+
expect(tui.state.pendingDelete).toBeNull();
|
|
398
|
+
|
|
399
|
+
const frame = captureCharFrame();
|
|
400
|
+
expect(frame).toContain("cancelled");
|
|
401
|
+
|
|
402
|
+
renderer.destroy();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("q in grid calls onQuit", async () => {
|
|
406
|
+
const ctx = setup();
|
|
407
|
+
const { renderer, mockInput, renderOnce } =
|
|
408
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
409
|
+
|
|
410
|
+
createTui(renderer, ctx.makeDepsImmediate());
|
|
411
|
+
|
|
412
|
+
// Enter grid
|
|
413
|
+
mockInput.pressArrow("down");
|
|
414
|
+
await renderOnce();
|
|
415
|
+
|
|
416
|
+
mockInput.pressKey("q");
|
|
417
|
+
await renderOnce();
|
|
418
|
+
|
|
419
|
+
expect(ctx.quitCalled).toBe(true);
|
|
420
|
+
|
|
421
|
+
renderer.destroy();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("/ key enters search from grid", async () => {
|
|
425
|
+
const ctx = setup();
|
|
426
|
+
const { renderer, mockInput, renderOnce } =
|
|
427
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
428
|
+
|
|
429
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
430
|
+
|
|
431
|
+
// Enter grid
|
|
432
|
+
mockInput.pressArrow("down");
|
|
433
|
+
await renderOnce();
|
|
434
|
+
expect(tui.state.focusArea).toBe("grid");
|
|
435
|
+
|
|
436
|
+
// Press /
|
|
437
|
+
mockInput.pressKey("/");
|
|
438
|
+
await renderOnce();
|
|
439
|
+
expect(tui.state.focusArea).toBe("search");
|
|
440
|
+
|
|
441
|
+
renderer.destroy();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("up arrow at top of grid returns to search", async () => {
|
|
445
|
+
const ctx = setup();
|
|
446
|
+
const { renderer, mockInput, renderOnce } =
|
|
447
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
448
|
+
|
|
449
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
450
|
+
|
|
451
|
+
// Enter grid
|
|
452
|
+
mockInput.pressArrow("down");
|
|
453
|
+
await renderOnce();
|
|
454
|
+
expect(tui.state.focusArea).toBe("grid");
|
|
455
|
+
expect(tui.state.cursor).toBe(0);
|
|
456
|
+
|
|
457
|
+
// Up at top → search
|
|
458
|
+
mockInput.pressArrow("up");
|
|
459
|
+
await renderOnce();
|
|
460
|
+
expect(tui.state.focusArea).toBe("search");
|
|
461
|
+
|
|
462
|
+
renderer.destroy();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("pending toggle shows [~] indicator", async () => {
|
|
466
|
+
const ctx = setup();
|
|
467
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
468
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
469
|
+
|
|
470
|
+
const tui = createTui(renderer, ctx.makeDeps()); // uses async toggle
|
|
471
|
+
|
|
472
|
+
// Enter grid
|
|
473
|
+
mockInput.pressArrow("down");
|
|
474
|
+
await renderOnce();
|
|
475
|
+
|
|
476
|
+
// Toggle alpha — will pend since we haven't resolved
|
|
477
|
+
mockInput.pressEnter();
|
|
478
|
+
await renderOnce();
|
|
479
|
+
|
|
480
|
+
const frame = captureCharFrame();
|
|
481
|
+
expect(frame).toContain("[~]");
|
|
482
|
+
expect(tui.state.pendingToggles.size).toBe(1);
|
|
483
|
+
|
|
484
|
+
// Resolve the toggle
|
|
485
|
+
ctx.resolveToggle(true);
|
|
486
|
+
// Let the promise settle
|
|
487
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
488
|
+
await renderOnce();
|
|
489
|
+
|
|
490
|
+
const frame2 = captureCharFrame();
|
|
491
|
+
expect(frame2).not.toContain("[~]");
|
|
492
|
+
expect(tui.state.pendingToggles.size).toBe(0);
|
|
493
|
+
|
|
494
|
+
renderer.destroy();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("renders search... placeholder when search empty and not focused", async () => {
|
|
498
|
+
const ctx = setup();
|
|
499
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
500
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
501
|
+
|
|
502
|
+
createTui(renderer, ctx.makeDepsImmediate());
|
|
503
|
+
|
|
504
|
+
// Enter grid (search loses focus)
|
|
505
|
+
mockInput.pressArrow("down");
|
|
506
|
+
await renderOnce();
|
|
507
|
+
|
|
508
|
+
const frame = captureCharFrame();
|
|
509
|
+
expect(frame).toContain("search...");
|
|
510
|
+
|
|
511
|
+
renderer.destroy();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("backspace removes search characters", async () => {
|
|
515
|
+
const ctx = setup();
|
|
516
|
+
const { renderer, mockInput, renderOnce } =
|
|
517
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
518
|
+
|
|
519
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
520
|
+
|
|
521
|
+
mockInput.typeText("abc");
|
|
522
|
+
await renderOnce();
|
|
523
|
+
expect(tui.state.searchQuery).toBe("abc");
|
|
524
|
+
|
|
525
|
+
mockInput.pressBackspace();
|
|
526
|
+
await renderOnce();
|
|
527
|
+
expect(tui.state.searchQuery).toBe("ab");
|
|
528
|
+
|
|
529
|
+
renderer.destroy();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("tab switches columns like right arrow", async () => {
|
|
533
|
+
const ctx = setup();
|
|
534
|
+
const { renderer, mockInput, renderOnce } =
|
|
535
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
536
|
+
|
|
537
|
+
const tui = createTui(renderer, ctx.makeDepsImmediate());
|
|
538
|
+
|
|
539
|
+
// Enter grid
|
|
540
|
+
mockInput.pressArrow("down");
|
|
541
|
+
await renderOnce();
|
|
542
|
+
expect(tui.state.cursorCol).toBe("global");
|
|
543
|
+
|
|
544
|
+
mockInput.pressTab();
|
|
545
|
+
await renderOnce();
|
|
546
|
+
expect(tui.state.cursorCol).toBe("local");
|
|
547
|
+
|
|
548
|
+
renderer.destroy();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("empty catalog renders without crashing", async () => {
|
|
552
|
+
const ctx = setup({ skills: [] });
|
|
553
|
+
const { renderer, renderOnce, captureCharFrame } =
|
|
554
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
555
|
+
|
|
556
|
+
createTui(renderer, ctx.makeDepsImmediate());
|
|
557
|
+
await renderOnce();
|
|
558
|
+
|
|
559
|
+
const frame = captureCharFrame();
|
|
560
|
+
expect(frame).toContain("Skill");
|
|
561
|
+
expect(frame).toContain("Global");
|
|
562
|
+
expect(frame).toContain("Local");
|
|
563
|
+
|
|
564
|
+
renderer.destroy();
|
|
565
|
+
});
|