@zhushanwen/pi-ask-user 0.0.1

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,784 @@
1
+ // src/__tests__/component.test.ts
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { AskUserComponent } from "../component";
5
+ import type { Question, Result } from "../types";
6
+ import {
7
+ BKSP,
8
+ DOWN,
9
+ ENTER,
10
+ ESC,
11
+ LEFT,
12
+ mockTui,
13
+ multiQ,
14
+ multiQWithComment,
15
+ RIGHT,
16
+ singleQ,
17
+ singleQMulti,
18
+ singleQWithComment,
19
+ stubTheme,
20
+ TAB,
21
+ UP,
22
+ } from "./fixtures";
23
+
24
+ // Helper: make component with mutable result holder
25
+ const make = (
26
+ questions: Question[],
27
+ ): { c: AskUserComponent; result: { val: Result | null | undefined } } => {
28
+ const result = { val: undefined as Result | null | undefined };
29
+ const c = new AskUserComponent(questions, mockTui, stubTheme, (r) => (result.val = r));
30
+ return { c, result };
31
+ };
32
+
33
+ // ── 5a. 单问题(AC-2)──────────────────────────────────
34
+ describe("AskUserComponent — single question", () => {
35
+ it("C-1: renders question without tab bar", () => {
36
+ const { c } = make([singleQ]);
37
+ const lines = c.render(60);
38
+ expect(lines.some((l) => l.includes("Which DB?"))).toBe(true);
39
+ expect(lines.some((l) => l.includes("Submit"))).toBe(false);
40
+ });
41
+
42
+ it("C-2: confirms first option on Enter and resolves", () => {
43
+ const { c, result } = make([singleQ]);
44
+ c.handleInput(ENTER);
45
+ expect(result.val).not.toBeUndefined();
46
+ expect(result.val!.cancelled).toBe(false);
47
+ expect(result.val!.answers["Which DB?"]).toBe("Postgres");
48
+ });
49
+
50
+ it("C-3: moves cursor down then confirms second option", () => {
51
+ const { c, result } = make([singleQ]);
52
+ c.handleInput(DOWN);
53
+ c.handleInput(ENTER);
54
+ expect(result.val!.answers["Which DB?"]).toBe("SQLite");
55
+ });
56
+
57
+ it("C-4: Esc shows confirm overlay; second Esc cancels (single)", () => {
58
+ const { c, result } = make([singleQ]);
59
+ // 首个问题按 Esc → 进入确认取消覆盖层(不立即取消)
60
+ c.handleInput(ESC);
61
+ expect(result.val).toBeUndefined();
62
+ expect(c.render(60).some((l) => l.includes("Cancel all"))).toBe(true);
63
+ // 覆盖层内再按 Esc → 确认取消
64
+ c.handleInput(ESC);
65
+ expect(result.val).toBeNull();
66
+ });
67
+
68
+ it("C-5: Up at first option does not go below 0", () => {
69
+ const { c } = make([singleQ]);
70
+ c.render(60);
71
+ c.handleInput(UP);
72
+ // Render and verify cursor still on first (Postgres highlighted)
73
+ const lines = c.render(60);
74
+ expect(lines.some((l) => l.includes(">") && l.includes("Postgres"))).toBe(true);
75
+ });
76
+
77
+ it("C-6: Down does not go beyond Other (last option)", () => {
78
+ const { c } = make([singleQ]);
79
+ // 2 options + Other = 3 rows (indices 0,1,2). Press Down 5 times.
80
+ c.handleInput(DOWN);
81
+ c.handleInput(DOWN);
82
+ c.handleInput(DOWN);
83
+ c.handleInput(DOWN);
84
+ const lines = c.render(60);
85
+ // Cursor should be on Other (last), not beyond
86
+ expect(lines.some((l) => l.includes(">") && l.includes("Other"))).toBe(true);
87
+ });
88
+ });
89
+
90
+ // ── 5b. 多问题 Tab 导航(AC-3 / AC-16)─────────────────
91
+ describe("AskUserComponent — multi question tab nav", () => {
92
+ it("C-7: renders tab bar with headers + Submit", () => {
93
+ const { c } = make(multiQ);
94
+ const lines = c.render(80);
95
+ const t = lines.join("\n");
96
+ expect(t).toContain("First");
97
+ expect(t).toContain("Second");
98
+ expect(t).toContain("Third");
99
+ expect(t).toContain("Submit");
100
+ });
101
+
102
+ it("C-8: navigates tabs and submits all answers", () => {
103
+ const { c, result } = make(multiQ);
104
+ // Q1: select A (Enter)
105
+ c.handleInput(ENTER);
106
+ // Q2: toggle X (Space), confirm (Enter)
107
+ c.handleInput(" ");
108
+ c.handleInput(ENTER);
109
+ // Q3: select M (Enter)
110
+ c.handleInput(ENTER);
111
+ // Submit tab: Enter
112
+ c.handleInput(ENTER);
113
+ expect(result.val!.answers["Q1"]).toBe("A");
114
+ expect(result.val!.answers["Q2"]).toBe("X");
115
+ expect(result.val!.answers["Q3"]).toBe("M");
116
+ });
117
+
118
+ it("C-9: Submit tab blocks when not all confirmed", () => {
119
+ const { c, result } = make(multiQ);
120
+ c.handleInput(TAB); // -> Q2
121
+ c.handleInput(TAB); // -> Q3
122
+ c.handleInput(TAB); // -> Submit
123
+ c.handleInput(ENTER); // should NOT submit
124
+ expect(result.val).toBeUndefined();
125
+ });
126
+
127
+ // C-10 / C-11 已废弃:←/→ 不再切 tab(C-E5/C-E6 覆盖 Tab/Shift+Tab 行为)
128
+
129
+ it("C-16 (AC-16): can re-edit confirmed answer", () => {
130
+ const { c, result } = make(multiQ);
131
+ // Q1: select A
132
+ c.handleInput(ENTER); // → Q2
133
+ // Go back to Q1
134
+ c.handleInput("\x1b[Z"); // Q1
135
+ // Select B instead
136
+ c.handleInput(DOWN);
137
+ c.handleInput(ENTER); // → Q2
138
+ // Skip Q2, Q3 by confirming
139
+ c.handleInput(" ");
140
+ c.handleInput(ENTER); // Q2 → Q3
141
+ c.handleInput(ENTER); // Q3 → Submit
142
+ c.handleInput(ENTER); // Submit
143
+ expect(result.val!.answers["Q1"]).toBe("B");
144
+ });
145
+
146
+ it("C-15: leaving multi-select tab auto-confirms answered selection", () => {
147
+ // Use 2 questions: Q1 single, Q2 multi-select
148
+ const twoQ: Question[] = [
149
+ { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
150
+ { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }], multiSelect: true },
151
+ ];
152
+ const { c, result } = make(twoQ);
153
+ c.handleInput(ENTER); // Q1 select A → Q2
154
+ c.handleInput(" "); // Q2 toggle X (no Enter-confirm)
155
+ c.handleInput(TAB); // → Submit, should auto-confirm Q2
156
+ c.handleInput(ENTER); // Submit (all confirmed)
157
+ expect(result.val!.answers["Q1"]).toBe("A");
158
+ expect(result.val!.answers["Q2"]).toBe("X");
159
+ });
160
+
161
+ it("C-S1: multi-select Enter 同时选中光标项再确认(与单选 Enter 对称)", () => {
162
+ // S-1 锁定:多选模式下光标停在未选项上按 Enter,应把光标项加入选中再确认
163
+ const { c, result } = make([singleQMulti]);
164
+ // singleQMulti: [Auth, Search],光标初始在 Auth(0)
165
+ c.handleInput(DOWN); // 光标移到 Search(1),未 toggle
166
+ c.handleInput(ENTER); // Enter 应同时选中 Search + confirm + allowComment → comment
167
+ // 断言进入了评论模式(说明 Enter 确认了,而非 no-op)
168
+ const lines = c.render(60);
169
+ expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(true);
170
+ c.handleInput(ENTER); // 跳过评论 → submit(单问题)
171
+ expect(result.val!.answers["Which features?"]).toBe("Search");
172
+ });
173
+
174
+ it("C-S3: auto-confirm(←/→ 切 tab)跳过评论输入行,仅 Enter 路径才进评论", () => {
175
+ // S-3 锁定:allowComment 的问题,←/→ 切走只 auto-confirm,不进评论模式
176
+ const twoQMulti: Question[] = [
177
+ { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }], multiSelect: true, allowComment: true },
178
+ { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
179
+ ];
180
+ const { c, result } = make(twoQMulti);
181
+ c.handleInput(" "); // Q1 toggle A
182
+ c.handleInput(TAB); // → Q2,auto-confirm Q1,不进评论
183
+ // 验证:当前在 Q2(非 Q1 的评论模式)。Q2 选 X → Submit
184
+ c.handleInput(ENTER); // Q2 select X → Submit
185
+ c.handleInput(ENTER); // Submit
186
+ expect(result.val!.answers["Q1"]).toBe("A"); // auto-confirm 生效
187
+ expect(result.val!.answers["Q2"]).toBe("X");
188
+ });
189
+
190
+ it("C-REG-R6: Other 录入→重进清空→Submit 应回到未答(confirmed 不变式)", () => {
191
+ // 回归 MUST_FIX: freeform 空 Enter 清空 freeTextValue 后须重置 confirmed=false
192
+ const { c, result } = make(multiQ);
193
+ // Q1 (A/B + Other): 导航到 Other,录入 "custom"
194
+ c.handleInput(DOWN);
195
+ c.handleInput(DOWN); // → Other
196
+ c.handleInput(ENTER); // 打开 freeform
197
+ c.handleInput("c");
198
+ c.handleInput("u");
199
+ c.handleInput("s");
200
+ c.handleInput("t");
201
+ c.handleInput("o");
202
+ c.handleInput("m");
203
+ c.handleInput(ENTER); // 保存 freeText → confirmed=true → advance to Q2
204
+ // 切回 Q1,重进 Other 编辑器,清空后空 Enter
205
+ c.handleInput("\x1b[Z"); // Shift+Tab → Q1
206
+ c.handleInput(DOWN); // idempotent: cursor stays on Other
207
+ c.handleInput(DOWN);
208
+ c.handleInput(ENTER); // 重开 freeform,editorText 预填 "custom"
209
+ c.handleInput(BKSP); // 清空 editorText
210
+ c.handleInput(BKSP);
211
+ c.handleInput(BKSP);
212
+ c.handleInput(BKSP);
213
+ c.handleInput(BKSP);
214
+ c.handleInput(BKSP);
215
+ c.handleInput(ENTER); // 空 Enter → freeTextValue 清空,confirmed 应重置 false
216
+ // 导航到 Submit 并尝试提交 → 应被阻塞(Q1 回到未答)
217
+ c.handleInput(TAB); // → Q2
218
+ c.handleInput(TAB); // → Q3
219
+ c.handleInput(TAB); // → Submit
220
+ c.handleInput(ENTER); // 应被阻塞
221
+ expect(result.val).toBeUndefined();
222
+ });
223
+ });
224
+
225
+ // ── 5c. 单选选择(FR-6)────────────────────────────────
226
+ describe("AskUserComponent — single-select", () => {
227
+ it("C-17: cursor movement does not record answer", () => {
228
+ const { c } = make([singleQ]);
229
+ c.handleInput(DOWN);
230
+ c.render(60);
231
+ // No resolution yet (only Enter resolves single)
232
+ // We verify by checking the question is still interactive — render shows cursor on SQLite
233
+ const lines = c.render(60);
234
+ expect(lines.some((l) => l.includes(">") && l.includes("SQLite"))).toBe(true);
235
+ });
236
+
237
+ it("C-19: re-selecting a normal option clears freeText", () => {
238
+ const { c, result } = make([singleQ]);
239
+ // Go to Other (index 2), open editor, type text
240
+ c.handleInput(DOWN);
241
+ c.handleInput(DOWN); // Other
242
+ c.handleInput(ENTER); // open freeform
243
+ c.handleInput("c");
244
+ c.handleInput("u");
245
+ c.handleInput("s");
246
+ c.handleInput("t");
247
+ c.handleInput("o");
248
+ c.handleInput("m");
249
+ c.handleInput(ENTER); // save freeText → submit (single question)
250
+ expect(result.val!.answers["Which DB?"]).toBe("custom");
251
+ });
252
+ });
253
+
254
+ // ── 5d. 多选 toggle(FR-6 / AC-18)─────────────────────
255
+ describe("AskUserComponent — multi-select toggle", () => {
256
+ it("C-20: Space toggles index into selectedIndices", () => {
257
+ const { c } = make([singleQMulti]);
258
+ c.handleInput(" ");
259
+ const lines = c.render(60);
260
+ expect(lines.some((l) => l.includes("[✓]") && l.includes("Auth"))).toBe(true);
261
+ });
262
+
263
+ it("C-21: Space twice removes from selectedIndices", () => {
264
+ const { c } = make([singleQMulti]);
265
+ c.handleInput(" "); // add
266
+ c.handleInput(" "); // remove
267
+ const lines = c.render(60);
268
+ expect(lines.some((l) => l.includes("[ ]") && l.includes("Auth"))).toBe(true);
269
+ });
270
+
271
+ it("C-24 (AC-18): multi-select toggle does NOT trigger comment mode", () => {
272
+ // singleQMulti has allowComment:true + multiSelect:true
273
+ const { c } = make([singleQMulti]);
274
+ c.handleInput(" "); // toggle — should NOT enter comment mode
275
+ const lines = c.render(60);
276
+ // No comment prompt shown
277
+ expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(false);
278
+ });
279
+ });
280
+
281
+ // ── 5e. Other 自由文本(FR-4.5 / AC-5)─────────────────
282
+ describe("AskUserComponent — Other free-text editor", () => {
283
+ it("C-25: Enter on Other row opens freeform editor (in-place)", () => {
284
+ const { c } = make([singleQ]);
285
+ // Navigate to Other (index 2)
286
+ c.handleInput(DOWN);
287
+ c.handleInput(DOWN);
288
+ c.handleInput(ENTER);
289
+ const lines = c.render(60);
290
+ // freeform 模式:光标行(█)出现,editor 已在 Other 行原地渲染
291
+ expect(lines.some((l) => l.includes("█"))).toBe(true);
292
+ // 不再独立 "Your answer" 提示块
293
+ expect(lines.some((l) => l.includes("Your answer"))).toBe(false);
294
+ });
295
+
296
+ it("C-26: Tab no longer opens freeform editor (Enter-only; Tab is tab-nav now)", () => {
297
+ const { c } = make([singleQ]);
298
+ c.handleInput(DOWN);
299
+ c.handleInput(DOWN);
300
+ c.handleInput(TAB);
301
+ const lines = c.render(60);
302
+ // Tab 不再打开 Other 编辑器(仅 Enter);单问题下 Tab 是 no-op
303
+ expect(lines.some((l) => l.includes("█"))).toBe(false);
304
+ });
305
+
306
+ it("C-27: editor accepts printable characters", () => {
307
+ const { c } = make([singleQ]);
308
+ c.handleInput(DOWN);
309
+ c.handleInput(DOWN);
310
+ c.handleInput(ENTER); // open
311
+ c.handleInput("h");
312
+ c.handleInput("i");
313
+ const lines = c.render(60);
314
+ expect(lines.some((l) => l.includes("hi"))).toBe(true);
315
+ });
316
+
317
+ it("C-28: editor Backspace deletes last char", () => {
318
+ const { c } = make([singleQ]);
319
+ c.handleInput(DOWN);
320
+ c.handleInput(DOWN);
321
+ c.handleInput(ENTER);
322
+ c.handleInput("a");
323
+ c.handleInput("b");
324
+ c.handleInput(BKSP); // delete "b"
325
+ const lines = c.render(60);
326
+ expect(lines.some((l) => l.includes("a") && !l.includes("ab"))).toBe(true);
327
+ });
328
+
329
+ it("C-31: editor Esc returns to options list", () => {
330
+ const { c } = make([singleQ]);
331
+ c.handleInput(DOWN);
332
+ c.handleInput(DOWN);
333
+ c.handleInput(ENTER);
334
+ c.handleInput(ESC);
335
+ const lines = c.render(60);
336
+ // Back in options mode — no cursor block (freeform inactive)
337
+ expect(lines.some((l) => l.includes("█"))).toBe(false);
338
+ });
339
+
340
+ it("C-29: editor Enter with text saves and submits (single)", () => {
341
+ const { c, result } = make([singleQ]);
342
+ c.handleInput(DOWN);
343
+ c.handleInput(DOWN);
344
+ c.handleInput(ENTER);
345
+ c.handleInput("x");
346
+ c.handleInput(ENTER);
347
+ expect(result.val).not.toBeUndefined();
348
+ expect(result.val!.answers["Which DB?"]).toBe("x");
349
+ });
350
+
351
+ it("C-30: editor Enter empty clears freeText (single → stays in form)", () => {
352
+ const { c, result } = make([singleQ]);
353
+ c.handleInput(DOWN);
354
+ c.handleInput(DOWN);
355
+ c.handleInput(ENTER); // open editor
356
+ c.handleInput(ENTER); // FR-6: empty Enter → clear freeText, close editor (NO confirm/submit)
357
+ // Not submitted; still in options list
358
+ expect(result.val).toBeUndefined();
359
+ // Back in options mode — no cursor block (freeform inactive)
360
+ const lines = c.render(60);
361
+ expect(lines.some((l) => l.includes("█"))).toBe(false);
362
+ });
363
+ });
364
+
365
+ // ── 5f. 评论流程(FR-4.6 / FR-11 / AC-6/12/17)─────────
366
+ describe("AskUserComponent — comment flow", () => {
367
+ it("C-33: single-select + allowComment Enter enters comment mode", () => {
368
+ const { c } = make([singleQWithComment]);
369
+ c.handleInput(ENTER); // select Postgres
370
+ const lines = c.render(60);
371
+ expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(true);
372
+ });
373
+
374
+ it("C-34 (AC-12): comment Enter empty skips and submits (single)", () => {
375
+ const { c, result } = make([singleQWithComment]);
376
+ c.handleInput(ENTER); // select → comment mode
377
+ c.handleInput(ENTER); // empty comment → skip → submit
378
+ expect(result.val).not.toBeUndefined();
379
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres");
380
+ });
381
+
382
+ it("C-35: comment Enter with text saves comment", () => {
383
+ const { c, result } = make([singleQWithComment]);
384
+ c.handleInput(ENTER); // select → comment mode
385
+ c.handleInput("f");
386
+ c.handleInput("a");
387
+ c.handleInput("s");
388
+ c.handleInput("t");
389
+ c.handleInput(ENTER); // save comment → submit
390
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — fast");
391
+ });
392
+
393
+ it("C-38: multi-select + allowComment: Enter after toggle enters comment", () => {
394
+ const { c } = make([singleQMulti]);
395
+ c.handleInput(" "); // toggle Auth
396
+ c.handleInput(ENTER); // confirm → comment mode
397
+ const lines = c.render(60);
398
+ expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(true);
399
+ });
400
+
401
+ it("C-39: Other + allowComment: freeText then comment", () => {
402
+ const { c, result } = make([singleQWithComment]);
403
+ // Navigate to Other
404
+ c.handleInput(DOWN);
405
+ c.handleInput(DOWN);
406
+ c.handleInput(ENTER); // open freeform
407
+ c.handleInput("c");
408
+ c.handleInput("u");
409
+ c.handleInput("s");
410
+ c.handleInput("t");
411
+ c.handleInput("o");
412
+ c.handleInput("m");
413
+ c.handleInput(ENTER); // save freeText → allowComment → comment mode
414
+ c.handleInput(ENTER); // empty comment → skip → submit
415
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("custom");
416
+ });
417
+
418
+ it("C-36 (AC-17): comment Esc skips comment and advances (single)", () => {
419
+ const { c, result } = make([singleQWithComment]);
420
+ c.handleInput(ENTER); // select Postgres → comment mode
421
+ c.handleInput(ESC); // Esc in comment = skip comment → advance → submit
422
+ expect(result.val).not.toBeUndefined();
423
+ // commentValue stays null (no prior comment), answer is the selected option
424
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres");
425
+ });
426
+
427
+ it("C-36b (AC-17): comment Esc advances to next tab (multi-question)", () => {
428
+ const { c, result } = make(multiQWithComment);
429
+ // Q1 (allowComment): select A → comment mode
430
+ c.handleInput(ENTER); // select A → comment mode
431
+ c.handleInput(ESC); // Esc in comment = skip → advance to Q2
432
+ // Q2: select X → Submit
433
+ c.handleInput(ENTER); // select X → Submit
434
+ c.handleInput(ENTER); // Submit
435
+ expect(result.val).not.toBeUndefined();
436
+ expect(result.val!.answers["Q1"]).toBe("A");
437
+ expect(result.val!.answers["Q2"]).toBe("X");
438
+ });
439
+
440
+ it("C-36c (AC-17): Esc-in-comment discards typed text (vs Enter which saves)", () => {
441
+ // Contrast: typing then Enter would save commentValue and append " — keep".
442
+ // Esc should discard the typed editor text and advance without attaching it.
443
+ const { c, result } = make([singleQWithComment]);
444
+ c.handleInput(ENTER); // select Postgres → comment mode
445
+ c.handleInput("k");
446
+ c.handleInput("e");
447
+ c.handleInput("e");
448
+ c.handleInput("p");
449
+ c.handleInput(ESC); // Esc in comment = discard typed text → advance → submit
450
+ expect(result.val).not.toBeUndefined();
451
+ // No " — keep" suffix: Esc did not commit the typed text
452
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres");
453
+ });
454
+
455
+ it("C-37: answer + comment combined format 'label — note'", () => {
456
+ const { c, result } = make([singleQWithComment]);
457
+ c.handleInput(ENTER); // select Postgres → comment mode
458
+ c.handleInput("n");
459
+ c.handleInput("o");
460
+ c.handleInput("t");
461
+ c.handleInput("e");
462
+ c.handleInput(ENTER); // save comment → submit
463
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — note");
464
+ });
465
+ });
466
+
467
+ // ── 5g. 防重入(FR-12)─────────────────────────────────
468
+ describe("AskUserComponent — re-entry guard", () => {
469
+ it("C-40: ignores input after resolution (submit)", () => {
470
+ const { c, result } = make([singleQ]);
471
+ c.handleInput(ENTER); // submit
472
+ const firstVal = result.val;
473
+ c.handleInput(ENTER); // ignored
474
+ expect(result.val).toBe(firstVal);
475
+ });
476
+
477
+ it("C-41: ignores input after cancel", () => {
478
+ const { c, result } = make([singleQ]);
479
+ c.handleInput(ESC); // → 确认取消覆盖层
480
+ c.handleInput(ESC); // → 确认取消
481
+ expect(result.val).toBeNull();
482
+ c.handleInput(ENTER); // ignored
483
+ expect(result.val).toBeNull();
484
+ });
485
+ });
486
+
487
+ // ── 5h. 渲染缓存 ───────────────────────────────────────
488
+ describe("AskUserComponent — render cache", () => {
489
+ it("C-42: same width returns same reference", () => {
490
+ const { c } = make([singleQ]);
491
+ const a = c.render(60);
492
+ const b = c.render(60);
493
+ expect(a).toBe(b);
494
+ });
495
+
496
+ it("C-43: input invalidates cache", () => {
497
+ const { c } = make([singleQ]);
498
+ const a = c.render(60);
499
+ c.handleInput(DOWN);
500
+ const b = c.render(60);
501
+ expect(a).not.toBe(b);
502
+ });
503
+
504
+ it("C-44: different width invalidates cache", () => {
505
+ const { c } = make([singleQ]);
506
+ const a = c.render(60);
507
+ const b = c.render(80);
508
+ expect(a).not.toBe(b);
509
+ });
510
+ });
511
+
512
+ // ── 5i. Submit tab 交互(FR-5)──────────────────────────
513
+ describe("AskUserComponent — Submit tab", () => {
514
+ it("C-47: Submit Esc backs to last question (no longer cancels)", () => {
515
+ const { c, result } = make(multiQ);
516
+ c.handleInput(TAB); // Q2
517
+ c.handleInput(TAB); // Q3
518
+ c.handleInput(TAB); // Submit
519
+ c.handleInput(ESC); // 回退到最后一个问题 Q3(不取消)
520
+ expect(result.val).toBeUndefined();
521
+ const lines = c.render(80);
522
+ // 回到 Q3:渲染 Q3 选项 M(非 Submit 视图)
523
+ expect(lines.some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(false);
524
+ expect(lines.some((l) => l.includes("M"))).toBe(true);
525
+ });
526
+
527
+ it("C-46: Submit Enter when all confirmed submits", () => {
528
+ const { c, result } = make(multiQWithComment);
529
+ // Q1 (allowComment): select A → comment mode → skip
530
+ c.handleInput(ENTER); // select A
531
+ c.handleInput(ENTER); // skip comment → Q2
532
+ // Q2: select X
533
+ c.handleInput(ENTER); // → Submit
534
+ c.handleInput(ENTER); // Submit
535
+ expect(result.val).not.toBeUndefined();
536
+ expect(result.val!.answers["Q1"]).toBe("A");
537
+ expect(result.val!.answers["Q2"]).toBe("X");
538
+ });
539
+
540
+ it("C-S12: Submit tab Left navigates to last question tab", () => {
541
+ // S-12 锁定:Submit tab 上按 Left → activeTab = questions.length - 1(最后一个问题)
542
+ const { c } = make(multiQ); // 3 questions → tabs 0,1,2,3=Submit
543
+ // 导航到 Submit
544
+ c.handleInput(TAB); // Q1 → Q2
545
+ c.handleInput(TAB); // Q2 → Q3
546
+ c.handleInput(TAB); // Q3 → Submit
547
+ // 确认当前在 Submit(渲染 Submit 视图)
548
+ let lines = c.render(80);
549
+ expect(lines.some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(true);
550
+ // 在 Submit 上按 Left → 应回到最后一个问题 Q3
551
+ c.handleInput("\x1b[Z");
552
+ lines = c.render(80);
553
+ // Q3 不再是 Submit 视图(无 Ready/Unanswered),且渲染了 Q3 的选项 M
554
+ expect(lines.some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(false);
555
+ expect(lines.some((l) => l.includes("M"))).toBe(true); // Q3 选项 M
556
+ });
557
+ });
558
+
559
+ // ── 5j. 视觉边框 / 按钮栏 / tab 分割(视觉增强)─────────
560
+ describe("AskUserComponent — visual chrome", () => {
561
+ it("C-V1: multi-question render is wrapped in box border (┌┐│└┘)", () => {
562
+ const { c } = make(multiQ);
563
+ const lines = c.render(70);
564
+ const t = lines.join("\n");
565
+ expect(t).toContain("┌"); // 顶左角
566
+ expect(t).toContain("┐"); // 顶右角
567
+ expect(t).toContain("└"); // 底左角
568
+ expect(t).toContain("┘"); // 底右角
569
+ expect(lines.some((l) => l.startsWith("│") || l.includes("│"))).toBe(true); // 左右边框
570
+ });
571
+
572
+ it("C-V2: tab bar separates tabs with │", () => {
573
+ const { c } = make(multiQ);
574
+ const lines = c.render(80);
575
+ // tab 行应含竖线分隔符(First │ Second │ ... Submit)
576
+ expect(lines.some((l) => l.includes("First") && l.includes("│"))).toBe(true);
577
+ expect(lines.some((l) => l.includes("Submit") && l.includes("│"))).toBe(true);
578
+ });
579
+
580
+ it("C-V3: multi-question shows [ Submit ] [ Cancel ] button bar", () => {
581
+ const { c } = make(multiQ);
582
+ const lines = c.render(80);
583
+ const t = lines.join("\n");
584
+ expect(t).toContain("[");
585
+ expect(t).toContain("Submit");
586
+ expect(t).toContain("Cancel");
587
+ expect(t).toContain("]");
588
+ });
589
+
590
+ it("C-V4: single question has NO button bar", () => {
591
+ const { c } = make([singleQ]);
592
+ const lines = c.render(60);
593
+ // 单问题无 Submit/Cancel 按钮栏(Enter 直接提交)
594
+ expect(lines.some((l) => l.includes("Cancel"))).toBe(false);
595
+ });
596
+
597
+ it("C-V5: question view renders divider (─) between sections", () => {
598
+ const { c } = make([singleQ]);
599
+ const lines = c.render(60);
600
+ // 分割线:question 与 options 之间应有 ─ 行(非边框)
601
+ expect(lines.some((l) => l.includes("─"))).toBe(true);
602
+ });
603
+ });
604
+
605
+ // ── 5k. 已完成绿勾 / Esc 回退 / Tab 浏览(行为增强)─────
606
+ describe("AskUserComponent — confirm-checkmark, Esc-back, Tab browsing", () => {
607
+ it("C-E1: confirmed tab shows green ✓ marker", () => {
608
+ const { c } = make(multiQ);
609
+ c.handleInput(ENTER); // Q1 确认 → Q2
610
+ // 回到 Q1 看 tab 栏:Q1 已确认应有 ✓ 标识
611
+ c.handleInput("\x1b[Z"); // Q2 → Q1
612
+ const lines = c.render(80);
613
+ expect(lines.some((l) => l.includes("✓") && l.includes("First"))).toBe(true);
614
+ });
615
+
616
+ it("C-E2: Esc backs to previous question (multi)", () => {
617
+ const { c, result } = make(multiQ);
618
+ c.handleInput(ENTER); // Q1 → Q2
619
+ c.handleInput(ESC); // Q2 → 回退到 Q1(不取消)
620
+ expect(result.val).toBeUndefined();
621
+ const lines = c.render(80);
622
+ expect(lines.some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
623
+ });
624
+
625
+ it("C-E3: Esc on first question shows confirm overlay; Esc again cancels", () => {
626
+ const { c, result } = make(multiQ);
627
+ // 首个问题按 Esc → 确认取消覆盖层
628
+ c.handleInput(ESC);
629
+ expect(result.val).toBeUndefined();
630
+ expect(c.render(80).some((l) => l.includes("Cancel all"))).toBe(true);
631
+ // 覆盖层内再按 Esc → 确认取消
632
+ c.handleInput(ESC);
633
+ expect(result.val).toBeNull();
634
+ });
635
+
636
+ it("C-E4: confirm overlay dismissed by any non-Esc key (stays in form)", () => {
637
+ const { c, result } = make(multiQ);
638
+ c.handleInput(ESC); // 进入确认覆盖层
639
+ c.handleInput(ENTER); // 非 Esc → 退出覆盖层,留在表单
640
+ expect(result.val).toBeUndefined();
641
+ // 覆盖层已关闭:渲染回到正常 tab 视图
642
+ expect(c.render(80).some((l) => l.includes("Cancel all"))).toBe(false);
643
+ });
644
+
645
+ it("C-E5: Tab navigates to next tab; Shift+Tab to previous", () => {
646
+ const { c } = make(multiQ);
647
+ c.handleInput(TAB); // Q1 → Q2
648
+ // 确认在 Q2:渲染含 Q2 的选项 X
649
+ expect(c.render(80).some((l) => l.includes("X"))).toBe(true);
650
+ // Shift+Tab(xterm 序列 ESC[Z)回退到 Q1
651
+ c.handleInput("\x1b[Z");
652
+ expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
653
+ });
654
+
655
+ it("C-E6: Tab wraps Submit → Q1, Shift+Tab wraps Q1 → Submit", () => {
656
+ const { c } = make(multiQ);
657
+ // 到 Submit:Q1→Q2→Q3→Submit
658
+ c.handleInput(ENTER); // Q1→Q2
659
+ c.handleInput(TAB); // Q2→Q3
660
+ c.handleInput(TAB); // Q3→Submit
661
+ expect(c.render(80).some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(true);
662
+ // Submit 上 Tab → 环绕到 Q1
663
+ c.handleInput(TAB);
664
+ expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
665
+ // Q1 上 Shift+Tab → 环绕回 Submit
666
+ c.handleInput("\x1b[Z");
667
+ expect(c.render(80).some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(true);
668
+ });
669
+ });
670
+
671
+ // ── 5l. 新行为:←/→ 不切 tab、Other Enter 切 freeform 原生、Submit tab focus ──
672
+ describe("AskUserComponent — new behavior (post-refactor)", () => {
673
+ it("C-NEW-1: multi-select Other + Enter opens freeform; Other row turns into [ ] <input>█ in-place", () => {
674
+ // singleQMulti: [Auth, Search],多选 + allowComment
675
+ const { c, result } = make([singleQMulti]);
676
+ // 1) Space toggle Auth
677
+ c.handleInput(" ");
678
+ // 2) ↓ 到 Other (cursor=2)
679
+ c.handleInput(DOWN);
680
+ c.handleInput(DOWN);
681
+ // 3) Enter 切 freeform(不再依赖 Space)
682
+ c.handleInput(ENTER);
683
+ // 验证:freeform 模式下,选项列表中应出现 [ ] █ 行(光标 block + 多选 box)
684
+ // 选中的 Auth 仍是 [✓](多选 toggle 未变),Other 行原地变 [ ] <cursor>
685
+ const lines = c.render(60);
686
+ // 独立 "Your answer" 提示行已消失
687
+ expect(lines.some((l) => l.includes("Your answer"))).toBe(false);
688
+ // freeform cursor 出现
689
+ expect(lines.some((l) => l.includes("█"))).toBe(true);
690
+ // 依然能看见 "Auth" "Search"(普通选项不变)
691
+ expect(lines.some((l) => l.includes("Auth"))).toBe(true);
692
+ expect(lines.some((l) => l.includes("Search"))).toBe(true);
693
+ // [✓] 标记的 Auth 仍存在(toggle 状态保留)
694
+ expect(lines.some((l) => l.includes("[✓]") && l.includes("Auth"))).toBe(true);
695
+ // 4) 输 "redis" → Enter 保存 → allowComment → comment mode
696
+ c.handleInput("r");
697
+ c.handleInput("e");
698
+ c.handleInput("d");
699
+ c.handleInput("i");
700
+ c.handleInput("s");
701
+ c.handleInput(ENTER); // 保存 freeText → comment mode
702
+ c.handleInput(ENTER); // 跳过评论 → submit(单问题)
703
+ // 答案含多选 toggle 项 + Other 自定义
704
+ expect(result.val!.answers["Which features?"]).toBe("Auth, redis");
705
+ });
706
+
707
+ it("C-NEW-2: Left/Right on question tab do NOT switch tabs (only Tab/Shift+Tab do)", () => {
708
+ // multiQ: 3 questions → tabs 0,1,2,3=Submit
709
+ const { c } = make(multiQ);
710
+ c.render(80);
711
+ // 1) Q1 上按 Right → 应仍在 Q1(不切 tab)
712
+ c.handleInput(RIGHT);
713
+ expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
714
+ // 2) Left 也不切
715
+ c.handleInput(LEFT);
716
+ expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
717
+ // 3) Tab 仍然可以切到 Q2
718
+ c.handleInput(TAB);
719
+ expect(c.render(80).some((l) => l.includes("Q2") || l.includes("Second"))).toBe(true);
720
+ });
721
+
722
+ it("C-NEW-3: Submit tab Left/Right toggles submitTabFocus (Submit ↔ Cancel)", () => {
723
+ // multiQ: 3 questions → tabs 0,1,2,3=Submit
724
+ const { c } = make(multiQ);
725
+ // 导航到 Submit
726
+ c.handleInput(TAB); // Q1→Q2
727
+ c.handleInput(TAB); // Q2→Q3
728
+ c.handleInput(TAB); // Q3→Submit
729
+ // Submit tab 默认 focus=Submit。验证渲染中 Submit 高亮(accent)
730
+ let lines = c.render(80);
731
+ const focusedLineInitial = lines.find((l) => l.match(/[\[\(]\s*Submit\s*[\]\)]/));
732
+ expect(focusedLineInitial).toBeDefined();
733
+ // 按 Right → focus 切到 Cancel
734
+ c.handleInput(RIGHT);
735
+ lines = c.render(80);
736
+ const focusedLineAfter = lines.find((l) => l.match(/[\[\(]\s*Cancel\s*[\]\)]/));
737
+ expect(focusedLineAfter).toBeDefined();
738
+ // 再按 Left → focus 回 Submit
739
+ c.handleInput(LEFT);
740
+ lines = c.render(80);
741
+ expect(lines.find((l) => l.match(/[\[\(]\s*Submit\s*[\]\)]/))).toBeDefined();
742
+ });
743
+
744
+ it("C-NEW-4: Submit tab Enter on Submit focus (all confirmed) submits", () => {
745
+ // multiQWithComment: Q1 allowComment, Q2 plain
746
+ const { c, result } = make(multiQWithComment);
747
+ // 答完 Q1 + Q2
748
+ c.handleInput(ENTER); // Q1 select A → comment mode
749
+ c.handleInput(ENTER); // skip comment → Q2
750
+ c.handleInput(ENTER); // Q2 select X → Submit tab(Q2 是最后一个问题,advance 到 Submit)
751
+ // 已经在 Submit tab,focus=Submit,按 Enter 提交
752
+ c.handleInput(ENTER);
753
+ expect(result.val).not.toBeUndefined();
754
+ expect(result.val!.answers["Q1"]).toBe("A");
755
+ expect(result.val!.answers["Q2"]).toBe("X");
756
+ });
757
+
758
+ it("C-NEW-5: Submit tab Enter on Cancel focus cancels (no confirm overlay)", () => {
759
+ // multiQ: 3 questions
760
+ const { c, result } = make(multiQ);
761
+ // 答完所有问题
762
+ c.handleInput(ENTER); // Q1 → Q2
763
+ c.handleInput(ENTER); // Q2 → Q3
764
+ c.handleInput(ENTER); // Q3 → Submit tab
765
+ // 切到 Submit 后,按 Right 把 focus 切到 Cancel
766
+ c.handleInput(RIGHT);
767
+ // Enter → 直接 cancel()(Submit tab 上无二次确认)
768
+ c.handleInput(ENTER);
769
+ expect(result.val).toBeNull();
770
+ });
771
+
772
+ it("C-NEW-6: Submit tab Enter on Submit when not all confirmed is a no-op (blocks submit)", () => {
773
+ const { c, result } = make(multiQ);
774
+ // 只答 Q1
775
+ c.handleInput(ENTER); // Q1 → Q2
776
+ // 切到 Submit(Q2 还未答)
777
+ c.handleInput(TAB); // Q2
778
+ c.handleInput(TAB); // Q3
779
+ c.handleInput(TAB); // Submit
780
+ // focus=Submit(默认),按 Enter → 不提交(Q2/Q3 未答)
781
+ c.handleInput(ENTER);
782
+ expect(result.val).toBeUndefined();
783
+ });
784
+ });