@zhushanwen/pi-ask-user 0.0.3 → 0.1.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.
@@ -0,0 +1,246 @@
1
+ // src/__tests__/w2-draft-hint.test.ts
2
+ // W2: draftText migration (#2) + handleInput split (#3) + hint line (#4)
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { AskUserComponent } from "../component";
6
+ import type { Question, Result } from "../types";
7
+ import {
8
+ DOWN,
9
+ ENTER,
10
+ ESC,
11
+ LEFT,
12
+ mockTui,
13
+ multiQ,
14
+ RIGHT,
15
+ singleQ,
16
+ singleQWithComment,
17
+ stubTheme,
18
+ UP,
19
+ } from "./fixtures";
20
+
21
+ const make = (
22
+ questions: Question[],
23
+ ): { c: AskUserComponent; result: { val: Result | null | undefined } } => {
24
+ const result = { val: undefined as Result | null | undefined };
25
+ const c = new AskUserComponent(questions, mockTui, stubTheme, (r) => (result.val = r));
26
+ return { c, result };
27
+ };
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
+ // ── draftText migration: per-question draft persistence ──
119
+ describe("W2 — draftText migration", () => {
120
+ it("C-DRAFT-1: freeform draft persists when switching tabs away and back", () => {
121
+ const { c } = make(multiQ);
122
+ // Q1: navigate to Other (index 2), open freeform, type "abc"
123
+ c.handleInput(DOWN); // 0→1
124
+ c.handleInput(DOWN); // 1→2 (Other)
125
+ c.handleInput(ENTER);
126
+ c.handleInput("abc");
127
+ c.handleInput(ESC); // back to options
128
+ // Switch to Q2 and back to Q1
129
+ c.handleInput(RIGHT);
130
+ c.handleInput(LEFT);
131
+ // Re-enter freeform
132
+ c.handleInput(DOWN);
133
+ c.handleInput(DOWN);
134
+ c.handleInput(ENTER);
135
+ const lines = c.render(80);
136
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
137
+ expect(editorLine).toBeDefined();
138
+ expect(editorLine).toContain("abc");
139
+ });
140
+
141
+ it("C-DRAFT-2: drafts on different questions are independent", () => {
142
+ const { c } = make(multiQ);
143
+ // Q1: type "aaa"
144
+ c.handleInput(DOWN);
145
+ c.handleInput(DOWN);
146
+ c.handleInput(ENTER);
147
+ c.handleInput("aaa");
148
+ c.handleInput(ESC);
149
+ // Navigate to Q3
150
+ c.handleInput(RIGHT);
151
+ c.handleInput(RIGHT);
152
+ // Q3: type "ccc"
153
+ c.handleInput(DOWN);
154
+ c.handleInput(DOWN);
155
+ c.handleInput(ENTER);
156
+ c.handleInput("ccc");
157
+ c.handleInput(ESC);
158
+ // Go back to Q1
159
+ c.handleInput(LEFT);
160
+ c.handleInput(LEFT);
161
+ // Re-enter freeform on Q1
162
+ c.handleInput(DOWN);
163
+ c.handleInput(DOWN);
164
+ c.handleInput(ENTER);
165
+ const lines1 = c.render(80);
166
+ const editorLine1 = lines1.find((l) => l.includes("\x1b[7m"));
167
+ expect(editorLine1).toContain("aaa");
168
+ expect(editorLine1).not.toContain("ccc");
169
+ });
170
+
171
+ it("C-BC4C: comment flow submits comment with answer", () => {
172
+ const { c, result } = make([singleQWithComment]);
173
+ c.handleInput(ENTER); // select A → comment mode
174
+ c.handleInput("my note");
175
+ c.handleInput(ENTER); // submit
176
+ expect(result.val).toBeDefined();
177
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — my note");
178
+ });
179
+ });
180
+
181
+ // ── hint line: append-only UX hint ──
182
+ describe("W2 — hint line", () => {
183
+ it("C-HINT-1: freeform editor hint contains all expected hints", () => {
184
+ const { c } = make([singleQ]);
185
+ c.handleInput(DOWN);
186
+ c.handleInput(DOWN);
187
+ c.handleInput(ENTER);
188
+ const lines = c.render(80);
189
+ const hintLine = lines.find((l: string) => l.includes("move") && l.includes("Backspace deletes"));
190
+ expect(hintLine).toBeDefined();
191
+ expect(hintLine).toContain("Enter submit");
192
+ expect(hintLine).toContain("Esc back");
193
+ });
194
+
195
+ it("C-HINT-2: comment editor hint contains all expected hints", () => {
196
+ const { c } = make([singleQWithComment]);
197
+ c.handleInput(ENTER);
198
+ const lines = c.render(80);
199
+ const hintLine = lines.find((l: string) => l.includes("move") && l.includes("Backspace deletes"));
200
+ expect(hintLine).toBeDefined();
201
+ expect(hintLine).toContain("Enter submit");
202
+ expect(hintLine).toContain("Esc back");
203
+ });
204
+ });
205
+
206
+ // ── freeDraft 隔离:丢弃的 freeform 草稿不污染答案、不触发 auto-confirm ──
207
+ describe("C-FREEDRAFT: discarded draft isolation", () => {
208
+ it("C-FREEDRAFT-1: discarded freeform draft blocks submit (Q1 stays unconfirmed)", () => {
209
+ const { c, result } = make(multiQ);
210
+ // Q1: 打开 freeform,输入草稿 "abc",ESC 丢弃(存入 freeDraft,但不 confirmed)
211
+ c.handleInput(DOWN); // 0→1
212
+ c.handleInput(DOWN); // 1→2 (Other)
213
+ c.handleInput(ENTER); // 打开 freeform
214
+ c.handleInput("abc");
215
+ c.handleInput(ESC); // freeDraft="abc",回到 options,Q1 仍未答
216
+ // 跳到 Submit tab 尝试提交
217
+ c.handleInput(RIGHT); // → Q2
218
+ c.handleInput(RIGHT); // → Q3
219
+ c.handleInput(RIGHT); // → Submit tab
220
+ c.handleInput(ENTER); // allConfirmed=false → 提交被阻塞
221
+ expect(result.val).toBeUndefined();
222
+ });
223
+
224
+ it("C-FREEDRAFT-2: discarded draft does not appear in submitted answers", () => {
225
+ const { c, result } = make(multiQ);
226
+ // Q1: freeform 草稿 "abc" → ESC 丢弃 → 改选选项 A
227
+ c.handleInput(DOWN);
228
+ c.handleInput(DOWN);
229
+ c.handleInput(ENTER); // freeform
230
+ c.handleInput("abc");
231
+ c.handleInput(ESC); // 丢弃草稿,光标回到 Other
232
+ c.handleInput(UP); // 2→1
233
+ c.handleInput(UP); // 1→0 (A)
234
+ c.handleInput(ENTER); // 选 A → confirmed → advance Q2
235
+ // Q2 (multiSelect): 选 X → confirmed → advance Q3
236
+ c.handleInput(ENTER);
237
+ // Q3: 选 M → confirmed → advance Submit tab
238
+ c.handleInput(ENTER);
239
+ // Submit
240
+ c.handleInput(ENTER);
241
+ expect(result.val).toBeDefined();
242
+ expect(result.val!.answers["Q1"]).toBe("A");
243
+ // 丢弃的草稿 "abc" 不应出现在任何答案中
244
+ expect(JSON.stringify(result.val!.answers)).not.toContain("abc");
245
+ });
246
+ });
@@ -0,0 +1,310 @@
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.
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import { AskUserComponent } from "../component";
7
+ import type { Question, Result } from "../types";
8
+ import {
9
+ BACKSPACE,
10
+ DELETE,
11
+ DOWN,
12
+ END,
13
+ ENTER,
14
+ ESC,
15
+ F1,
16
+ HOME,
17
+ INSERT,
18
+ LEFT,
19
+ mockTui,
20
+ multiQ,
21
+ multiQWithComment,
22
+ PGDN,
23
+ PGUP,
24
+ RIGHT,
25
+ singleQ,
26
+ singleQWithComment,
27
+ stubTheme,
28
+ } from "./fixtures";
29
+
30
+ const make = (
31
+ questions: Question[] = [singleQ],
32
+ ): { c: AskUserComponent; result: { val: Result | null | undefined } } => {
33
+ const result = { val: undefined as Result | null | undefined };
34
+ const c = new AskUserComponent(questions, mockTui, stubTheme, (r) => (result.val = r));
35
+ return { c, result };
36
+ };
37
+
38
+ /** Helper: open freeform editor on singleQ (navigate to Other → Enter) */
39
+ function openFreeform(c: AskUserComponent): void {
40
+ c.handleInput(DOWN); // 0→1
41
+ c.handleInput(DOWN); // 1→2 (Other)
42
+ c.handleInput(ENTER); // open freeform
43
+ }
44
+
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
+ // ── C-BC4B: freeform Enter clears selectedIndex (BC-4b regression) ──
188
+ describe("W3 — freeform Enter clears selectedIndex (C-BC4B)", () => {
189
+ it("C-BC4B: freeform text overrides selected option in result", () => {
190
+ const { c, result } = make([singleQ]);
191
+ // Navigate to Other (index 2) and open freeform
192
+ c.handleInput(DOWN); // 0→1
193
+ c.handleInput(DOWN); // 1→2 (Other)
194
+ c.handleInput(ENTER); // open freeform
195
+ c.handleInput("custom answer");
196
+ c.handleInput(ENTER); // confirm freeform → afterConfirm → submit
197
+ expect(result.val).toBeDefined();
198
+ // The answer should be the freeform text
199
+ expect(result.val!.answers["Which DB?"]).toBe("custom answer");
200
+ });
201
+
202
+ it("C-BC4B-SELECT: freeform on same question clears selectedIndex", () => {
203
+ const { c, result } = make(multiQ);
204
+ // Q1: stay on cursorIndex=0 (A), open freeform via Other
205
+ c.handleInput(DOWN); // 0→1
206
+ c.handleInput(DOWN); // 1→2 (Other)
207
+ c.handleInput(ENTER); // open freeform
208
+ c.handleInput("override");
209
+ c.handleInput(ENTER); // confirm freeform → afterConfirm → advance to Q2
210
+ // Q2: select X
211
+ c.handleInput(ENTER); // select X
212
+ c.handleInput(ENTER); // confirm → advance to Q3
213
+ // Q3: select M
214
+ c.handleInput(ENTER); // select M
215
+ c.handleInput(ENTER); // confirm → advance to submit
216
+ // Submit
217
+ c.handleInput(ENTER);
218
+ expect(result.val).toBeDefined();
219
+ // Q1 answer should be the freeform text only
220
+ expect(result.val!.answers["Q1"]).toBe("override");
221
+ });
222
+ });
223
+
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
+
237
+ it("C-BC4C-CLEAR: Esc in comment mode skips comment, keeps existing commentValue", () => {
238
+ const { c, result } = make(multiQWithComment);
239
+ // Q1: select A → comment mode
240
+ c.handleInput(ENTER);
241
+ c.handleInput("my note");
242
+ c.handleInput(ENTER); // submit comment → advance to Q2
243
+ // Q2: select X
244
+ c.handleInput(ENTER);
245
+ c.handleInput(ENTER); // confirm Q2 → advance to submit tab
246
+ // Go back to Q1
247
+ c.handleInput(LEFT);
248
+ // Q1 is already confirmed, re-select A to trigger comment again
249
+ c.handleInput(ENTER); // re-select A → afterConfirm → comment mode
250
+ c.handleInput(ESC); // skip comment
251
+ // Submit
252
+ c.handleInput(RIGHT);
253
+ c.handleInput(RIGHT); // navigate to submit tab
254
+ c.handleInput(ENTER);
255
+ expect(result.val).toBeDefined();
256
+ // Comment should still be "my note" (Esc preserved commentValue)
257
+ expect(result.val!.answers["Q1"]).toBe("A — my note");
258
+ });
259
+ });
260
+
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
+ // ── C-BKSP-EDGE: backspace at cursorIndex=0 ──
285
+
286
+ describe("W3 — backspace at cursorIndex=0 (C-BKSP-EDGE)", () => {
287
+ it("C-BKSP-EDGE: backspace at cursorIndex=0 is no-op on empty text", () => {
288
+ const { c } = make();
289
+ // 打开 freeform 编辑器
290
+ openFreeform(c);
291
+ c.handleInput(BACKSPACE); // 空文本 + cursorIndex=0
292
+ // 不应 crash
293
+ const lines = c.render(60);
294
+ // 编辑器仍然可见(反色空格光标)
295
+ expect(lines.some((l: string) => l.includes("\x1b[7m"))).toBe(true);
296
+ });
297
+
298
+ it("C-BKSP-EDGE-2: backspace at cursorIndex=0 on non-empty text is no-op", () => {
299
+ const { c } = make();
300
+ openFreeform(c);
301
+ c.handleInput("abc");
302
+ c.handleInput(HOME); // 移到开头
303
+ c.handleInput(BACKSPACE); // cursorIndex=0,不应删除
304
+ const lines = c.render(60);
305
+ const editorLine = lines.find((l: string) => l.includes("\x1b[7m"));
306
+ // 文本完整保留(去除 ANSI 转义码后检查)
307
+ const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
308
+ expect(stripped).toContain("abc");
309
+ });
310
+ });