@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.
- package/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +3 -2
- package/src/__tests__/component-keymap.test.ts +553 -0
- package/src/__tests__/component.test.ts +81 -10
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +24 -1
- package/src/__tests__/question-view.test.ts +113 -17
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +14 -1
- package/src/__tests__/w2-draft-hint.test.ts +246 -0
- package/src/__tests__/w3-regression.test.ts +310 -0
- package/src/component.ts +160 -94
- package/src/index.ts +6 -4
- package/src/question-view.ts +80 -41
- package/src/submit-view.ts +17 -7
- package/src/types.ts +24 -2
- package/src/validate.ts +9 -1
|
@@ -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
|
+
});
|