@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.
- package/package.json +4 -1
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +0 -31
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/index.test.ts +305 -24
- package/src/__tests__/question-view.test.ts +50 -35
- package/src/__tests__/validate.test.ts +5 -3
- package/src/__tests__/w2-draft-hint.test.ts +0 -89
- package/src/__tests__/w3-regression.test.ts +5 -187
- package/src/answer-format.ts +51 -0
- package/src/component.ts +42 -101
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +193 -69
- package/src/question-view.ts +32 -47
- package/src/submit-view.ts +2 -4
- package/src/validate.ts +11 -0
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
84
|
-
|
|
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
|
|
3
|
-
//
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
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)", () => {
|