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