@zhushanwen/pi-ask-user 0.1.0 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  // src/__tests__/question-view.test.ts
2
2
  import { describe, expect, it } from "vitest";
3
3
 
4
- import { getSplitPaneWidths, renderQuestionView } from "../question-view";
4
+ import { getSplitPaneWidths, renderQuestionView, type RenderContext } from "../question-view";
5
5
  import { createQuestionState, type Question, type QuestionState } from "../types";
6
6
  import { stubTheme } from "./fixtures";
7
7
 
@@ -18,18 +18,33 @@ const makeState = (over: Partial<QuestionState> = {}): QuestionState => ({
18
18
  ...over,
19
19
  });
20
20
 
21
+ /** 测试辅助:桥接旧的位置参数签名到新的 RenderContext 签名。
22
+ * draftText 合并进 state(新 API 从 state.draftText 读)。 */
23
+ function rv(
24
+ q: Question,
25
+ state: QuestionState,
26
+ theme: typeof stubTheme,
27
+ width: number,
28
+ isSingle: boolean,
29
+ draftText: string = "",
30
+ ): string[] {
31
+ const mergedState = { ...state, draftText: draftText || state.draftText };
32
+ const ctx: RenderContext = { question: q, state: mergedState, theme, width, isSingle };
33
+ return renderQuestionView(ctx);
34
+ }
35
+
21
36
  // Helper: join all lines for substring search
22
37
  const text = (lines: string[]): string => lines.join("\n");
23
38
 
24
39
  // ── Q-1 ~ Q-6: 基础渲染 ──────────────────────────────────
25
40
  describe("renderQuestionView — basics", () => {
26
41
  it("Q-1: renders question text", () => {
27
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
42
+ const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
28
43
  expect(text(lines)).toContain("Which database?");
29
44
  });
30
45
 
31
46
  it("Q-2: renders all options + Other", () => {
32
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
47
+ const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
33
48
  const t = text(lines);
34
49
  expect(t).toContain("Postgres");
35
50
  expect(t).toContain("SQLite");
@@ -37,14 +52,14 @@ describe("renderQuestionView — basics", () => {
37
52
  });
38
53
 
39
54
  it("Q-3: renders cursor > on first option", () => {
40
- const lines = renderQuestionView(singleQ, makeState({ cursorIndex: 0 }), stubTheme, 60, true, "");
55
+ const lines = rv(singleQ, makeState({ cursorIndex: 0 }), stubTheme, 60, true, "");
41
56
  const t = text(lines);
42
57
  expect(t).toContain(">");
43
58
  expect(t).toContain("Postgres");
44
59
  });
45
60
 
46
61
  it("Q-4: renders single-select check on confirmed selection", () => {
47
- const lines = renderQuestionView(singleQ, makeState({ selectedIndex: 1 }), stubTheme, 60, true, "");
62
+ const lines = rv(singleQ, makeState({ selectedIndex: 1 }), stubTheme, 60, true, "");
48
63
  expect(text(lines)).toContain("✓");
49
64
  expect(text(lines)).toContain("SQLite");
50
65
  });
@@ -55,7 +70,7 @@ describe("renderQuestionView — basics", () => {
55
70
  options: [{ label: "Auth" }, { label: "Search" }],
56
71
  multiSelect: true,
57
72
  };
58
- const lines = renderQuestionView(
73
+ const lines = rv(
59
74
  multiQ,
60
75
  makeState({ selectedIndices: new Set([0]) }),
61
76
  stubTheme,
@@ -69,7 +84,7 @@ describe("renderQuestionView — basics", () => {
69
84
  });
70
85
 
71
86
  it("Q-6: renders descriptions in muted", () => {
72
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
87
+ const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
73
88
  expect(text(lines)).toContain("Battle-tested");
74
89
  });
75
90
  });
@@ -96,7 +111,7 @@ describe("renderQuestionView — split pane", () => {
96
111
  });
97
112
 
98
113
  it("Q-9: split-pane left column hides descriptions", () => {
99
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 100, true, "");
114
+ const lines = rv(singleQ, makeState(), stubTheme, 100, true, "");
100
115
  const t = text(lines);
101
116
  // In split mode, descriptions appear only in the right pane preview of the focused item.
102
117
  // The focused item (cursorIndex=0=Postgres) shows "Battle-tested" in the right pane,
@@ -109,7 +124,7 @@ describe("renderQuestionView — split pane", () => {
109
124
  });
110
125
 
111
126
  it("Q-10: split-pane right pane shows focused option detail", () => {
112
- const lines = renderQuestionView(singleQ, makeState({ cursorIndex: 1 }), stubTheme, 100, true, "");
127
+ const lines = rv(singleQ, makeState({ cursorIndex: 1 }), stubTheme, 100, true, "");
113
128
  const t = text(lines);
114
129
  // Focused on SQLite → right pane should show SQLite's description
115
130
  expect(t).toContain("Embedded");
@@ -117,13 +132,13 @@ describe("renderQuestionView — split pane", () => {
117
132
 
118
133
  it("Q-11: split-pane right pane on Other shows custom-answer hint", () => {
119
134
  // cursorIndex = last option (Other) = 2
120
- const lines = renderQuestionView(singleQ, makeState({ cursorIndex: 2 }), stubTheme, 100, true, "");
135
+ const lines = rv(singleQ, makeState({ cursorIndex: 2 }), stubTheme, 100, true, "");
121
136
  const t = text(lines);
122
137
  expect(t).toContain("custom");
123
138
  });
124
139
 
125
140
  it("Q-14: single-column mode shows indented descriptions", () => {
126
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
141
+ const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
127
142
  // Both descriptions shown inline (single-column mode)
128
143
  const t = text(lines);
129
144
  expect(t).toContain("Battle-tested");
@@ -134,7 +149,7 @@ describe("renderQuestionView — split pane", () => {
134
149
  // ── Q-15 ~ Q-17: Other 编辑器模式 ───────────────────────
135
150
  describe("renderQuestionView — Other editor mode", () => {
136
151
  it("Q-15: freeform mode renders Other row in-place with draft + cursor + number prefix", () => {
137
- const lines = renderQuestionView(
152
+ const lines = rv(
138
153
  singleQ,
139
154
  makeState({ mode: "freeform", cursorIndex: 2 }),
140
155
  stubTheme,
@@ -161,7 +176,7 @@ describe("renderQuestionView — Other editor mode", () => {
161
176
  multiSelect: true,
162
177
  options: [{ label: "Auth" }, { label: "Search" }],
163
178
  };
164
- const lines = renderQuestionView(
179
+ const lines = rv(
165
180
  multiQ,
166
181
  makeState({ mode: "freeform", cursorIndex: 2 }),
167
182
  stubTheme,
@@ -181,7 +196,7 @@ describe("renderQuestionView — Other editor mode", () => {
181
196
  });
182
197
 
183
198
  it("Q-16: Other with saved free-text shows checkmark + preview", () => {
184
- const lines = renderQuestionView(
199
+ const lines = rv(
185
200
  singleQ,
186
201
  makeState({ cursorIndex: 2, freeTextValue: "saved text" }),
187
202
  stubTheme,
@@ -195,7 +210,7 @@ describe("renderQuestionView — Other editor mode", () => {
195
210
  });
196
211
 
197
212
  it("Q-17: Other row focused shows Enter hint (Tab now navigates tabs)", () => {
198
- const lines = renderQuestionView(
213
+ const lines = rv(
199
214
  singleQ,
200
215
  makeState({ cursorIndex: 2 }),
201
216
  stubTheme,
@@ -212,7 +227,7 @@ describe("renderQuestionView — Other editor mode", () => {
212
227
  const width = 30;
213
228
  // lead = "> [ ] " (单选 box=" ") = 6 列 → avail = 24
214
229
  const long = "x".repeat(60);
215
- const lines = renderQuestionView(
230
+ const lines = rv(
216
231
  singleQ,
217
232
  makeState({ mode: "freeform", cursorIndex: 2 }),
218
233
  stubTheme,
@@ -245,7 +260,7 @@ describe("renderQuestionView — Other editor mode", () => {
245
260
  // 单选 lead visLen=5 → avail = width - 5 = 95。用 60 字符(< 95)应单行装下。
246
261
  // 修复前若用 split.left≈40,avail≈35 → 60 字符会换 2 行。
247
262
  const input = "a".repeat(60);
248
- const lines = renderQuestionView(
263
+ const lines = rv(
249
264
  singleQ,
250
265
  makeState({ mode: "freeform", cursorIndex: 2 }),
251
266
  stubTheme,
@@ -265,7 +280,7 @@ describe("renderQuestionView — Other editor mode", () => {
265
280
  const width = 30;
266
281
  // avail=24,200 字符 → 9 行,超过 5 行上限
267
282
  const huge = "y".repeat(200);
268
- const lines = renderQuestionView(
283
+ const lines = rv(
269
284
  singleQ,
270
285
  makeState({ mode: "freeform", cursorIndex: 2 }),
271
286
  stubTheme,
@@ -286,7 +301,7 @@ describe("renderQuestionView — Other editor mode", () => {
286
301
  const width = 40;
287
302
  // 预览 lead=" "(5 列) → avail=35;预览文本带引号
288
303
  const long = "z".repeat(80);
289
- const lines = renderQuestionView(
304
+ const lines = rv(
290
305
  singleQ,
291
306
  makeState({ cursorIndex: 2, freeTextValue: long }),
292
307
  stubTheme,
@@ -306,7 +321,7 @@ describe("renderQuestionView — Other editor mode", () => {
306
321
  const width = 30;
307
322
  // 预览 avail = 30-5 = 25,300 字符 → >5 行
308
323
  const huge = "w".repeat(300);
309
- const lines = renderQuestionView(
324
+ const lines = rv(
310
325
  singleQ,
311
326
  makeState({ cursorIndex: 2, freeTextValue: huge }),
312
327
  stubTheme,
@@ -323,7 +338,7 @@ describe("renderQuestionView — Other editor mode", () => {
323
338
  // ── Q-18 ~ Q-19: 评论模式 ───────────────────────────────
324
339
  describe("renderQuestionView — comment mode", () => {
325
340
  it("Q-18: comment mode renders editor with note text", () => {
326
- const lines = renderQuestionView(
341
+ const lines = rv(
327
342
  singleQ,
328
343
  makeState({ mode: "comment", selectedIndex: 0 }),
329
344
  stubTheme,
@@ -337,7 +352,7 @@ describe("renderQuestionView — comment mode", () => {
337
352
  });
338
353
 
339
354
  it("Q-19: comment prompt includes (optional)", () => {
340
- const lines = renderQuestionView(
355
+ const lines = rv(
341
356
  singleQ,
342
357
  makeState({ mode: "comment", selectedIndex: 0 }),
343
358
  stubTheme,
@@ -352,7 +367,7 @@ describe("renderQuestionView — comment mode", () => {
352
367
  // ── Q-20 ~ Q-23: 帮助行 ─────────────────────────────────
353
368
  describe("renderQuestionView — help line", () => {
354
369
  it("Q-20: single-select help shows 'Enter select'", () => {
355
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
370
+ const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
356
371
  expect(text(lines)).toContain("Enter select");
357
372
  });
358
373
 
@@ -362,17 +377,17 @@ describe("renderQuestionView — help line", () => {
362
377
  options: [{ label: "A" }, { label: "B" }],
363
378
  multiSelect: true,
364
379
  };
365
- const lines = renderQuestionView(multiQ, makeState(), stubTheme, 60, true, "");
380
+ const lines = rv(multiQ, makeState(), stubTheme, 60, true, "");
366
381
  expect(text(lines)).toContain("Space toggle");
367
382
  });
368
383
 
369
384
  it("Q-22: single question omits tab-switch hint", () => {
370
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
385
+ const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
371
386
  expect(text(lines)).not.toContain("switch tabs");
372
387
  });
373
388
 
374
389
  it("Q-23: multi question includes tab-switch hint", () => {
375
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, false, "");
390
+ const lines = rv(singleQ, makeState(), stubTheme, 60, false, "");
376
391
  expect(text(lines)).toContain("switch tabs");
377
392
  });
378
393
  });
@@ -381,7 +396,7 @@ describe("renderQuestionView — help line", () => {
381
396
  describe("renderQuestionView — Other row alignment", () => {
382
397
  it("Q-32: 单选 Other freeform 编辑行编号与普通选项对齐", () => {
383
398
  // 回归:单选 freeform 占位从 \" \"(2列) 改为 \" \"(1列),编号列与普通单选对齐
384
- const lines = renderQuestionView(
399
+ const lines = rv(
385
400
  singleQ,
386
401
  makeState({ mode: "freeform", cursorIndex: 2 }),
387
402
  stubTheme,
@@ -402,7 +417,7 @@ describe("renderQuestionView — Other row alignment", () => {
402
417
  multiSelect: true,
403
418
  options: [{ label: "Auth" }, { label: "Search" }],
404
419
  };
405
- const lines = renderQuestionView(
420
+ const lines = rv(
406
421
  multiQ,
407
422
  makeState({ cursorIndex: 2 }),
408
423
  stubTheme,
@@ -417,7 +432,7 @@ describe("renderQuestionView — Other row alignment", () => {
417
432
 
418
433
  it("Q-34: 单选 Other freeText 预览缩进对齐到 label 起始列", () => {
419
434
  // 回归:预览 lead 从硬编码 6 列改为动态计算(单选个位 = 7 列)
420
- const lines = renderQuestionView(
435
+ const lines = rv(
421
436
  singleQ,
422
437
  makeState({ cursorIndex: 2, freeTextValue: "saved" }),
423
438
  stubTheme,
@@ -437,7 +452,7 @@ describe("renderQuestionView — Other row alignment", () => {
437
452
  multiSelect: true,
438
453
  options: [{ label: "Auth" }, { label: "Search" }],
439
454
  };
440
- const lines = renderQuestionView(
455
+ const lines = rv(
441
456
  multiQ,
442
457
  makeState({ cursorIndex: 2, freeTextValue: "saved" }),
443
458
  stubTheme,
@@ -456,7 +471,7 @@ describe("renderQuestionView — Other row alignment", () => {
456
471
  question: "Q",
457
472
  options: Array.from({ length: 10 }, (_, k) => ({ label: `Opt${k + 1}` })),
458
473
  };
459
- const lines = renderQuestionView(
474
+ const lines = rv(
460
475
  bigQ,
461
476
  makeState({ cursorIndex: 10, freeTextValue: "x" }),
462
477
  stubTheme,
@@ -474,12 +489,12 @@ describe("renderQuestionView — Other row alignment", () => {
474
489
  describe("renderQuestionView — context", () => {
475
490
  it("Q-24: renders context when present", () => {
476
491
  const q: Question = { ...singleQ, context: "Background info here" };
477
- const lines = renderQuestionView(q, makeState(), stubTheme, 60, true, "");
492
+ const lines = rv(q, makeState(), stubTheme, 60, true, "");
478
493
  expect(text(lines)).toContain("Background info here");
479
494
  });
480
495
 
481
496
  it("Q-25: no context field → no extra context text", () => {
482
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
497
+ const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
483
498
  // No "context" label text in output (only question/options/help)
484
499
  expect(text(lines)).toContain("Which database?");
485
500
  });
@@ -488,7 +503,7 @@ describe("renderQuestionView — context", () => {
488
503
  // ── Q-26 ~ Q-27: 边界 ───────────────────────────────────
489
504
  describe("renderQuestionView — edge cases", () => {
490
505
  it("Q-26: very narrow terminal width=20 does not crash", () => {
491
- const lines = renderQuestionView(singleQ, makeState(), stubTheme, 20, true, "");
506
+ const lines = rv(singleQ, makeState(), stubTheme, 20, true, "");
492
507
  expect(lines.length).toBeGreaterThan(0);
493
508
  });
494
509
 
@@ -497,7 +512,7 @@ describe("renderQuestionView — edge cases", () => {
497
512
  question: "Q",
498
513
  options: [{ label: "A".repeat(80), description: "desc" }, { label: "B" }],
499
514
  };
500
- const lines = renderQuestionView(q, makeState(), stubTheme, 40, true, "");
515
+ const lines = rv(q, makeState(), stubTheme, 40, true, "");
501
516
  // Should not contain the full 80-char label on a 40-col terminal
502
517
  const t = text(lines);
503
518
  expect(t).not.toContain("A".repeat(80));
@@ -80,13 +80,15 @@ describe("validateInput", () => {
80
80
  expect(result).toBeNull();
81
81
  });
82
82
 
83
- // V-10: 不同 question 可以有相同 headerheader 不要求唯一)
84
- it("allows duplicate headers across different questions", () => {
83
+ // V-10/S3: question header 必须唯一——重复 header 会导致 askUserKey 碰撞
84
+ // (协议 helper header answers 读取 key,后一个覆盖前一个的 Other/comment)
85
+ it("rejects duplicate headers across different questions", () => {
85
86
  const result = validateInput([
86
87
  q({ question: "Q1", header: "Same" }),
87
88
  q({ question: "Q2", header: "Same" }),
88
89
  ]);
89
- expect(result).toBeNull();
90
+ expect(result).not.toBeNull();
91
+ expect(result).toContain("Duplicate header");
90
92
  });
91
93
 
92
94
  // V-11: option description 不参与唯一性校验
@@ -26,95 +26,6 @@ const make = (
26
26
  return { c, result };
27
27
  };
28
28
 
29
- // ── parseKey guard: arrow keys no-op in editor ──
30
- describe("W2 — parseKey guard (arrow/keys no-op in editor)", () => {
31
- it("C-ARROW-1: three right arrows do not leak escape fragments", () => {
32
- const { c } = make([singleQ]);
33
- c.handleInput(DOWN);
34
- c.handleInput(DOWN);
35
- c.handleInput(ENTER); // open freeform
36
- c.handleInput(RIGHT);
37
- c.handleInput(RIGHT);
38
- c.handleInput(RIGHT);
39
- const lines = c.render(60);
40
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
41
- expect(editorLine).toBeDefined();
42
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
43
- });
44
-
45
- it("C-ARROW-2: arrow keys between typed chars with cursor movement", () => {
46
- const { c } = make([singleQ]);
47
- c.handleInput(DOWN);
48
- c.handleInput(DOWN);
49
- c.handleInput(ENTER); // open freeform
50
- c.handleInput(DOWN); // no-op
51
- c.handleInput(RIGHT); // no-op (empty text)
52
- c.handleInput("a"); // insert at 0 → "a", cursor=1
53
- c.handleInput(UP); // no-op
54
- c.handleInput(LEFT); // cursor 1→0
55
- c.handleInput("b"); // insert at 0 → "ba", cursor=1
56
- const lines = c.render(60);
57
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
58
- expect(editorLine).toBeDefined();
59
- // cursor at 1: "b█a" — 去 ANSI 后精确文本 "ba"(证明 insert 位置:b 在 a 前)
60
- const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
61
- expect(stripped).toContain("ba");
62
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
63
- });
64
-
65
- it("C-KEYMAP-SPACE: space input appends a space character", () => {
66
- const { c } = make([singleQ]);
67
- c.handleInput(DOWN);
68
- c.handleInput(DOWN);
69
- c.handleInput(ENTER);
70
- c.handleInput("a");
71
- c.handleInput(" ");
72
- c.handleInput("b");
73
- const lines = c.render(60);
74
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
75
- expect(editorLine).toContain("a b");
76
- });
77
-
78
- it("C-KEYMAP-UP: up arrow is no-op in freeform editor", () => {
79
- const { c } = make([singleQ]);
80
- c.handleInput(DOWN);
81
- c.handleInput(DOWN);
82
- c.handleInput(ENTER);
83
- c.handleInput("a");
84
- c.handleInput(UP);
85
- c.handleInput("b");
86
- const lines = c.render(60);
87
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
88
- expect(editorLine).toContain("ab");
89
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
90
- });
91
-
92
- it.each([
93
- ["ctrl+up", "\x1b[1;5A"],
94
- ["ctrl+down", "\x1b[1;5B"],
95
- ["ctrl+left", "\x1b[1;5D"],
96
- ["ctrl+right", "\x1b[1;5C"],
97
- ["alt+up", "\x1b[1;3A"],
98
- ["alt+down", "\x1b[1;3B"],
99
- ["shift+up", "\x1b[1;2A"],
100
- ["shift+down", "\x1b[1;2B"],
101
- ["shift+left", "\x1b[1;2D"],
102
- ["shift+right", "\x1b[1;2C"],
103
- ])("C-KEYMAP-MOD: %s is no-op in editor", (_name, seq) => {
104
- const { c } = make([singleQ]);
105
- c.handleInput(DOWN);
106
- c.handleInput(DOWN);
107
- c.handleInput(ENTER);
108
- c.handleInput("a");
109
- c.handleInput(seq);
110
- c.handleInput("b");
111
- const lines = c.render(60);
112
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
113
- expect(editorLine).toContain("ab");
114
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
115
- });
116
- });
117
-
118
29
  // ── draftText migration: per-question draft persistence ──
119
30
  describe("W2 — draftText migration", () => {
120
31
  it("C-DRAFT-1: freeform draft persists when switching tabs away and back", () => {
@@ -1,29 +1,22 @@
1
1
  // src/__tests__/w3-regression.test.ts
2
- // W3: Forward regression + full validation (#5)
3
- // Comprehensive no-op key coverage + draftText edge cases + full regression.
2
+ // W3: Forward regression freeform/comment/bksp edge cases.
3
+ // No-op keymap coverage moved to component-keymap.test.ts (deduplicated).
4
4
  import { describe, expect, it } from "vitest";
5
5
 
6
6
  import { AskUserComponent } from "../component";
7
7
  import type { Question, Result } from "../types";
8
8
  import {
9
9
  BACKSPACE,
10
- DELETE,
11
10
  DOWN,
12
- END,
13
11
  ENTER,
14
12
  ESC,
15
- F1,
16
13
  HOME,
17
- INSERT,
18
14
  LEFT,
19
15
  mockTui,
20
16
  multiQ,
21
17
  multiQWithComment,
22
- PGDN,
23
- PGUP,
24
18
  RIGHT,
25
19
  singleQ,
26
- singleQWithComment,
27
20
  stubTheme,
28
21
  } from "./fixtures";
29
22
 
@@ -42,148 +35,6 @@ function openFreeform(c: AskUserComponent): void {
42
35
  c.handleInput(ENTER); // open freeform
43
36
  }
44
37
 
45
- // ── C-KEYMAP-*: extended no-op key coverage ──
46
- describe("W3 — extended no-op key coverage (C-KEYMAP-*)", () => {
47
- it("C-KEYMAP-DOWN: down arrow is no-op in freeform editor", () => {
48
- const { c } = make([singleQ]);
49
- openFreeform(c);
50
- c.handleInput("a");
51
- c.handleInput(DOWN);
52
- c.handleInput("b");
53
- const lines = c.render(60);
54
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
55
- expect(editorLine).toContain("ab");
56
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
57
- });
58
-
59
- it("C-KEYMAP-LEFT: left arrow moves cursor in freeform editor", () => {
60
- const { c } = make([singleQ]);
61
- openFreeform(c);
62
- c.handleInput("ab"); // "ab", cursor=2
63
- c.handleInput(LEFT); // cursor 2→1
64
- c.handleInput("c"); // insert at 1 → "acb", cursor=2
65
- const lines = c.render(60);
66
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
67
- // cursor at 2: "ac█b" — 去 ANSI 后精确文本 "acb"(证明 c 插在 a 与 b 之间)
68
- const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
69
- expect(stripped).toContain("acb");
70
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
71
- });
72
-
73
- it("C-KEYMAP-HOME: Home key moves cursor to start in freeform editor", () => {
74
- const { c } = make([singleQ]);
75
- openFreeform(c);
76
- c.handleInput("abc"); // fallback: "abc", cursor=3
77
- c.handleInput(HOME); // cursor 3→0
78
- c.handleInput("d"); // insert at 0 → "dabc", cursor=1
79
- const lines = c.render(60);
80
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
81
- // cursor at 1: "d\x1b[7ma\x1b[27mbc" — 去 ANSI 后精确文本 "dabc"(证明 d 插在开头)
82
- const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
83
- expect(stripped).toContain("dabc");
84
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
85
- expect(editorLine).not.toContain("\x1b[H");
86
- });
87
-
88
- it("C-KEYMAP-END: End key moves cursor to end of text", () => {
89
- const { c } = make();
90
- openFreeform(c);
91
- c.handleInput("abc");
92
- c.handleInput(HOME); // 移到开头
93
- c.handleInput(END); // 移到末尾
94
- // 验证在末尾输入 "d" 被追加到末尾
95
- c.handleInput("d");
96
- const lines = c.render(60);
97
- const editorLine = lines.find((l: string) => l.includes("\x1b[7m"));
98
- // 去除 ANSI 转义码后检查
99
- const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
100
- expect(stripped).toContain("abcd");
101
- });
102
-
103
- it("C-KEYMAP-INSERT: Insert key is no-op in freeform editor", () => {
104
- const { c } = make([singleQ]);
105
- openFreeform(c);
106
- c.handleInput("a");
107
- c.handleInput(INSERT);
108
- c.handleInput("b");
109
- const lines = c.render(60);
110
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
111
- expect(editorLine).toContain("ab");
112
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
113
- expect(editorLine).not.toContain("~");
114
- });
115
-
116
- it("C-KEYMAP-PGUP: Page Up is no-op in freeform editor", () => {
117
- const { c } = make([singleQ]);
118
- openFreeform(c);
119
- c.handleInput("a");
120
- c.handleInput(PGUP);
121
- c.handleInput("b");
122
- const lines = c.render(60);
123
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
124
- expect(editorLine).toContain("ab");
125
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
126
- expect(editorLine).not.toContain("~");
127
- });
128
-
129
- it("C-KEYMAP-PGDN: Page Down is no-op in freeform editor", () => {
130
- const { c } = make([singleQ]);
131
- openFreeform(c);
132
- c.handleInput("a");
133
- c.handleInput(PGDN);
134
- c.handleInput("b");
135
- const lines = c.render(60);
136
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
137
- expect(editorLine).toContain("ab");
138
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
139
- expect(editorLine).not.toContain("~");
140
- });
141
-
142
- it("C-KEYMAP-F1: F1 key is no-op in freeform editor", () => {
143
- const { c } = make([singleQ]);
144
- openFreeform(c);
145
- c.handleInput("a");
146
- c.handleInput(F1);
147
- c.handleInput("b");
148
- const lines = c.render(60);
149
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
150
- expect(editorLine).toContain("ab");
151
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
152
- expect(editorLine).not.toContain("\x1bO");
153
- });
154
-
155
- it("C-KEYMAP-DELETE: Delete key is no-op in freeform editor (not backspace)", () => {
156
- const { c } = make([singleQ]);
157
- openFreeform(c);
158
- c.handleInput("abc");
159
- c.handleInput(DELETE); // delete ≠ backspace — should NOT delete
160
- const lines = c.render(60);
161
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
162
- expect(editorLine).toContain("abc"); // unchanged
163
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
164
- expect(editorLine).not.toContain("~");
165
- });
166
-
167
- it.each([
168
- ["super+up", "\x1b[1;9A"],
169
- ["super+down", "\x1b[1;9B"],
170
- ["super+left", "\x1b[1;9D"],
171
- ["super+right", "\x1b[1;9C"],
172
- ["ctrl+shift+up", "\x1b[1;6A"],
173
- ["ctrl+shift+down", "\x1b[1;6B"],
174
- ])("C-KEYMAP-MOD (extended): %s is no-op in editor", (_name, seq) => {
175
- const { c } = make([singleQ]);
176
- openFreeform(c);
177
- c.handleInput("a");
178
- c.handleInput(seq);
179
- c.handleInput("b");
180
- const lines = c.render(60);
181
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
182
- expect(editorLine).toContain("ab");
183
- expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
184
- });
185
- });
186
-
187
38
  // ── C-BC4B: freeform Enter clears selectedIndex (BC-4b regression) ──
188
39
  describe("W3 — freeform Enter clears selectedIndex (C-BC4B)", () => {
189
40
  it("C-BC4B: freeform text overrides selected option in result", () => {
@@ -221,19 +72,9 @@ describe("W3 — freeform Enter clears selectedIndex (C-BC4B)", () => {
221
72
  });
222
73
  });
223
74
 
224
- // ── C-BC4C-REEDIT: comment re-edit prefills old comment ──
225
- describe("W3 comment re-edit prefills old comment (C-BC4C-REEDIT)", () => {
226
- it("C-BC4C-REEDIT: editing comment after initial submission prefills old text", () => {
227
- const { c, result } = make([singleQWithComment]);
228
- // Select option A → enter comment mode
229
- c.handleInput(ENTER);
230
- // Type initial comment
231
- c.handleInput("initial comment");
232
- c.handleInput(ENTER); // submit → auto-advance → submit all
233
- expect(result.val).toBeDefined();
234
- expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — initial comment");
235
- });
236
-
75
+ // ── C-BC4C: comment edge cases ──
76
+ // C-BC4C-REEDIT (initial comment submit) removed duplicate of w2-draft-hint C-BC4C.
77
+ describe("W3 comment re-edit (C-BC4C-CLEAR)", () => {
237
78
  it("C-BC4C-CLEAR: Esc in comment mode skips comment, keeps existing commentValue", () => {
238
79
  const { c, result } = make(multiQWithComment);
239
80
  // Q1: select A → comment mode
@@ -258,29 +99,6 @@ describe("W3 — comment re-edit prefills old comment (C-BC4C-REEDIT)", () => {
258
99
  });
259
100
  });
260
101
 
261
- // ── C-PASTE-EQUIV: single char via parseKey path (not undefined path) ──
262
- describe("W3 — single char append via parseKey path (C-PASTE-5 equiv)", () => {
263
- it("single char 'x' is appended correctly (parseKey returns 'x')", () => {
264
- const { c } = make([singleQ]);
265
- openFreeform(c);
266
- c.handleInput("x");
267
- const lines = c.render(60);
268
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
269
- expect(editorLine).toContain("x");
270
- });
271
-
272
- it("single printable chars in sequence build string correctly", () => {
273
- const { c } = make([singleQ]);
274
- openFreeform(c);
275
- for (const ch of "hello") {
276
- c.handleInput(ch);
277
- }
278
- const lines = c.render(60);
279
- const editorLine = lines.find((l) => l.includes("\x1b[7m"));
280
- expect(editorLine).toContain("hello");
281
- });
282
- });
283
-
284
102
  // ── C-BKSP-EDGE: backspace at cursorIndex=0 ──
285
103
 
286
104
  describe("W3 — backspace at cursorIndex=0 (C-BKSP-EDGE)", () => {