@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.
@@ -0,0 +1,357 @@
1
+ // src/__tests__/question-view.test.ts
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { getSplitPaneWidths, renderQuestionView } from "../question-view";
5
+ import { createQuestionState, type Question, type QuestionState, type ThemeLike } from "../types";
6
+
7
+ const stubTheme: ThemeLike = {
8
+ fg: (_t: string, s: string) => s,
9
+ bg: (_t: string, s: string) => s,
10
+ bold: (s: string) => s,
11
+ };
12
+
13
+ const singleQ: Question = {
14
+ question: "Which database?",
15
+ options: [
16
+ { label: "Postgres", description: "Battle-tested" },
17
+ { label: "SQLite", description: "Embedded" },
18
+ ],
19
+ };
20
+
21
+ const makeState = (over: Partial<QuestionState> = {}): QuestionState => ({
22
+ ...createQuestionState(),
23
+ ...over,
24
+ });
25
+
26
+ // Helper: join all lines for substring search
27
+ const text = (lines: string[]): string => lines.join("\n");
28
+
29
+ // ── Q-1 ~ Q-6: 基础渲染 ──────────────────────────────────
30
+ describe("renderQuestionView — basics", () => {
31
+ it("Q-1: renders question text", () => {
32
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
33
+ expect(text(lines)).toContain("Which database?");
34
+ });
35
+
36
+ it("Q-2: renders all options + Other", () => {
37
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
38
+ const t = text(lines);
39
+ expect(t).toContain("Postgres");
40
+ expect(t).toContain("SQLite");
41
+ expect(t).toContain("Other");
42
+ });
43
+
44
+ it("Q-3: renders cursor > on first option", () => {
45
+ const lines = renderQuestionView(singleQ, makeState({ cursorIndex: 0 }), stubTheme, 60, true, "");
46
+ const t = text(lines);
47
+ expect(t).toContain(">");
48
+ expect(t).toContain("Postgres");
49
+ });
50
+
51
+ it("Q-4: renders single-select check on confirmed selection", () => {
52
+ const lines = renderQuestionView(singleQ, makeState({ selectedIndex: 1 }), stubTheme, 60, true, "");
53
+ expect(text(lines)).toContain("✓");
54
+ expect(text(lines)).toContain("SQLite");
55
+ });
56
+
57
+ it("Q-5: renders multi-select checkboxes", () => {
58
+ const multiQ: Question = {
59
+ question: "Which features?",
60
+ options: [{ label: "Auth" }, { label: "Search" }],
61
+ multiSelect: true,
62
+ };
63
+ const lines = renderQuestionView(
64
+ multiQ,
65
+ makeState({ selectedIndices: new Set([0]) }),
66
+ stubTheme,
67
+ 60,
68
+ true,
69
+ "",
70
+ );
71
+ const t = text(lines);
72
+ expect(t).toContain("[✓]");
73
+ expect(t).toContain("[ ]");
74
+ });
75
+
76
+ it("Q-6: renders descriptions in muted", () => {
77
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
78
+ expect(text(lines)).toContain("Battle-tested");
79
+ });
80
+ });
81
+
82
+ // ── Q-7 ~ Q-14: 分屏 ────────────────────────────────────
83
+ describe("renderQuestionView — split pane", () => {
84
+ it("Q-7: getSplitPaneWidths returns null on narrow terminal", () => {
85
+ expect(getSplitPaneWidths(60)).toBeNull();
86
+ });
87
+
88
+ it("Q-8: getSplitPaneWidths returns widths on wide terminal", () => {
89
+ const result = getSplitPaneWidths(100);
90
+ expect(result).not.toBeNull();
91
+ expect(result!.left).toBeGreaterThan(0);
92
+ expect(result!.right).toBeGreaterThan(0);
93
+ });
94
+
95
+ it("Q-12: getSplitPaneWidths null at boundary width=83", () => {
96
+ expect(getSplitPaneWidths(83)).toBeNull();
97
+ });
98
+
99
+ it("Q-13: getSplitPaneWidths non-null at boundary width=84", () => {
100
+ expect(getSplitPaneWidths(84)).not.toBeNull();
101
+ });
102
+
103
+ it("Q-9: split-pane left column hides descriptions", () => {
104
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 100, true, "");
105
+ const t = text(lines);
106
+ // In split mode, descriptions appear only in the right pane preview of the focused item.
107
+ // The focused item (cursorIndex=0=Postgres) shows "Battle-tested" in the right pane,
108
+ // but SQLite's "Embedded" should NOT appear (no single-column description blocks).
109
+ expect(t).toContain("Postgres");
110
+ expect(t).toContain("Battle-tested");
111
+ // SQLite label still present in left column, but its description "Embedded" not shown
112
+ // (only the focused option's description is previewed)
113
+ expect(t).toContain("SQLite");
114
+ });
115
+
116
+ it("Q-10: split-pane right pane shows focused option detail", () => {
117
+ const lines = renderQuestionView(singleQ, makeState({ cursorIndex: 1 }), stubTheme, 100, true, "");
118
+ const t = text(lines);
119
+ // Focused on SQLite → right pane should show SQLite's description
120
+ expect(t).toContain("Embedded");
121
+ });
122
+
123
+ it("Q-11: split-pane right pane on Other shows custom-answer hint", () => {
124
+ // cursorIndex = last option (Other) = 2
125
+ const lines = renderQuestionView(singleQ, makeState({ cursorIndex: 2 }), stubTheme, 100, true, "");
126
+ const t = text(lines);
127
+ expect(t).toContain("custom");
128
+ });
129
+
130
+ it("Q-14: single-column mode shows indented descriptions", () => {
131
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
132
+ // Both descriptions shown inline (single-column mode)
133
+ const t = text(lines);
134
+ expect(t).toContain("Battle-tested");
135
+ expect(t).toContain("Embedded");
136
+ });
137
+ });
138
+
139
+ // ── Q-15 ~ Q-17: Other 编辑器模式 ───────────────────────
140
+ describe("renderQuestionView — Other editor mode", () => {
141
+ it("Q-15: freeform mode renders Other row in-place with draft + cursor", () => {
142
+ const lines = renderQuestionView(
143
+ singleQ,
144
+ makeState({ mode: "freeform", cursorIndex: 2 }),
145
+ stubTheme,
146
+ 60,
147
+ true,
148
+ "my draft",
149
+ );
150
+ const t = text(lines);
151
+ // Other 行原地变 [ ] <input>█(freeform 模式不依赖 buildEditorBlock 独立编辑块)
152
+ expect(t).toContain("my draft");
153
+ expect(t).toContain("█");
154
+ // 不再独立 "Your answer" 提示行
155
+ expect(t).not.toContain("Your answer");
156
+ });
157
+
158
+ it("Q-16: Other with saved free-text shows checkmark + preview", () => {
159
+ const lines = renderQuestionView(
160
+ singleQ,
161
+ makeState({ cursorIndex: 2, freeTextValue: "saved text" }),
162
+ stubTheme,
163
+ 60,
164
+ true,
165
+ "",
166
+ );
167
+ const t = text(lines);
168
+ expect(t).toContain("✓");
169
+ expect(t).toContain("saved text");
170
+ });
171
+
172
+ it("Q-17: Other row focused shows Enter hint (Tab now navigates tabs)", () => {
173
+ const lines = renderQuestionView(
174
+ singleQ,
175
+ makeState({ cursorIndex: 2 }),
176
+ stubTheme,
177
+ 60,
178
+ true,
179
+ "",
180
+ );
181
+ // help 行:Other 焦点时显示 "Enter open editor"
182
+ expect(text(lines)).toContain("Enter open editor");
183
+ });
184
+
185
+ // ── Q-28 ~ Q-31: Other 多行换行(输入态 + 已保存预览) ──
186
+ it("Q-28: freeform 长输入超过屏宽时软换行,完整内容不丢失", () => {
187
+ const width = 30;
188
+ // lead = "> [ ] " (单选 box=" ") = 6 列 → avail = 24
189
+ const long = "x".repeat(60);
190
+ const lines = renderQuestionView(
191
+ singleQ,
192
+ makeState({ mode: "freeform", cursorIndex: 2 }),
193
+ stubTheme,
194
+ width,
195
+ true,
196
+ long,
197
+ );
198
+ const t = text(lines);
199
+ // 60 字符按 avail=24 换行 → 至少 3 行,且全部 60 个 x 都在输出里(不截断)
200
+ const xCount = t.split("").filter((c) => c === "x").length;
201
+ expect(xCount).toBe(60);
202
+ expect(lines.some((l) => l.includes("x"))).toBe(true);
203
+ // 光标仍在末尾出现
204
+ expect(t).toContain("█");
205
+ // 每个含 x 的行都不超过总宽 width(stubTheme 无 ANSI,长度=可见宽)
206
+ for (const l of lines) {
207
+ if (l.includes("x")) expect(l.length).toBeLessThanOrEqual(width);
208
+ }
209
+ });
210
+
211
+ it("Q-29: freeform 输入超 5 行时截断到 5 行并加省略号", () => {
212
+ const width = 30;
213
+ // avail=24,200 字符 → 9 行,超过 5 行上限
214
+ const huge = "y".repeat(200);
215
+ const lines = renderQuestionView(
216
+ singleQ,
217
+ makeState({ mode: "freeform", cursorIndex: 2 }),
218
+ stubTheme,
219
+ width,
220
+ true,
221
+ huge,
222
+ );
223
+ // 统计含 'y' 的行数 = input 渲染行数(应被截到 5 行)
224
+ const yLines = lines.filter((l) => l.includes("y"));
225
+ expect(yLines.length).toBe(5);
226
+ // 最后一行带省略号(表示还有更多,光标已被省略号取代)
227
+ expect(yLines[yLines.length - 1]).toContain("…");
228
+ });
229
+
230
+ it("Q-30: 已保存 freeText 预览超屏宽时多行换行展示", () => {
231
+ const width = 40;
232
+ // 预览 lead=" "(5 列) → avail=35;预览文本带引号
233
+ const long = "z".repeat(80);
234
+ const lines = renderQuestionView(
235
+ singleQ,
236
+ makeState({ cursorIndex: 2, freeTextValue: long }),
237
+ stubTheme,
238
+ width,
239
+ true,
240
+ "",
241
+ );
242
+ const t = text(lines);
243
+ // 80 个 z 全部展示(不再单行截断丢失)
244
+ const zCount = t.split("").filter((c) => c === "z").length;
245
+ expect(zCount).toBe(80);
246
+ // 预览首行带引号开头
247
+ expect(lines.some((l) => l.includes('"'))).toBe(true);
248
+ });
249
+
250
+ it("Q-31: 已保存 freeText 预览超 5 行时截断并加省略号", () => {
251
+ const width = 30;
252
+ // 预览 avail = 30-5 = 25,300 字符 → >5 行
253
+ const huge = "w".repeat(300);
254
+ const lines = renderQuestionView(
255
+ singleQ,
256
+ makeState({ cursorIndex: 2, freeTextValue: huge }),
257
+ stubTheme,
258
+ width,
259
+ true,
260
+ "",
261
+ );
262
+ const wLines = lines.filter((l) => l.includes("w"));
263
+ expect(wLines.length).toBe(5);
264
+ expect(wLines[wLines.length - 1]).toContain("…");
265
+ });
266
+ });
267
+
268
+ // ── Q-18 ~ Q-19: 评论模式 ───────────────────────────────
269
+ describe("renderQuestionView — comment mode", () => {
270
+ it("Q-18: comment mode renders editor with note text", () => {
271
+ const lines = renderQuestionView(
272
+ singleQ,
273
+ makeState({ mode: "comment", selectedIndex: 0 }),
274
+ stubTheme,
275
+ 60,
276
+ true,
277
+ "my note",
278
+ );
279
+ const t = text(lines);
280
+ expect(t.toLowerCase()).toContain("comment");
281
+ expect(t).toContain("my note");
282
+ });
283
+
284
+ it("Q-19: comment prompt includes (optional)", () => {
285
+ const lines = renderQuestionView(
286
+ singleQ,
287
+ makeState({ mode: "comment", selectedIndex: 0 }),
288
+ stubTheme,
289
+ 60,
290
+ true,
291
+ "",
292
+ );
293
+ expect(text(lines)).toContain("(optional)");
294
+ });
295
+ });
296
+
297
+ // ── Q-20 ~ Q-23: 帮助行 ─────────────────────────────────
298
+ describe("renderQuestionView — help line", () => {
299
+ it("Q-20: single-select help shows 'Enter select'", () => {
300
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
301
+ expect(text(lines)).toContain("Enter select");
302
+ });
303
+
304
+ it("Q-21: multi-select help shows 'Space toggle'", () => {
305
+ const multiQ: Question = {
306
+ question: "Pick",
307
+ options: [{ label: "A" }, { label: "B" }],
308
+ multiSelect: true,
309
+ };
310
+ const lines = renderQuestionView(multiQ, makeState(), stubTheme, 60, true, "");
311
+ expect(text(lines)).toContain("Space toggle");
312
+ });
313
+
314
+ it("Q-22: single question omits tab-switch hint", () => {
315
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
316
+ expect(text(lines)).not.toContain("switch tabs");
317
+ });
318
+
319
+ it("Q-23: multi question includes tab-switch hint", () => {
320
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, false, "");
321
+ expect(text(lines)).toContain("switch tabs");
322
+ });
323
+ });
324
+
325
+ // ── Q-24 ~ Q-25: 上下文 ─────────────────────────────────
326
+ describe("renderQuestionView — context", () => {
327
+ it("Q-24: renders context when present", () => {
328
+ const q: Question = { ...singleQ, context: "Background info here" };
329
+ const lines = renderQuestionView(q, makeState(), stubTheme, 60, true, "");
330
+ expect(text(lines)).toContain("Background info here");
331
+ });
332
+
333
+ it("Q-25: no context field → no extra context text", () => {
334
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
335
+ // No "context" label text in output (only question/options/help)
336
+ expect(text(lines)).toContain("Which database?");
337
+ });
338
+ });
339
+
340
+ // ── Q-26 ~ Q-27: 边界 ───────────────────────────────────
341
+ describe("renderQuestionView — edge cases", () => {
342
+ it("Q-26: very narrow terminal width=20 does not crash", () => {
343
+ const lines = renderQuestionView(singleQ, makeState(), stubTheme, 20, true, "");
344
+ expect(lines.length).toBeGreaterThan(0);
345
+ });
346
+
347
+ it("Q-27: long option label gets truncated", () => {
348
+ const q: Question = {
349
+ question: "Q",
350
+ options: [{ label: "A".repeat(80), description: "desc" }, { label: "B" }],
351
+ };
352
+ const lines = renderQuestionView(q, makeState(), stubTheme, 40, true, "");
353
+ // Should not contain the full 80-char label on a 40-col terminal
354
+ const t = text(lines);
355
+ expect(t).not.toContain("A".repeat(80));
356
+ });
357
+ });
@@ -0,0 +1,192 @@
1
+ // src/__tests__/submit-view.test.ts
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { buildResult, getAnswerText, renderSubmitView } from "../submit-view";
5
+ import { createQuestionState, type Question, type QuestionState, type ThemeLike } from "../types";
6
+
7
+ const stubTheme: ThemeLike = {
8
+ fg: (_t: string, s: string) => s,
9
+ bg: (_t: string, s: string) => s,
10
+ bold: (s: string) => s,
11
+ };
12
+
13
+ const q1: Question = {
14
+ question: "Which DB?",
15
+ header: "Database",
16
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
17
+ };
18
+
19
+ const makeState = (over: Partial<QuestionState> = {}): QuestionState => ({
20
+ ...createQuestionState(),
21
+ ...over,
22
+ });
23
+
24
+ // ── S-1 ~ S-4: renderSubmitView ──────────────────────────
25
+ describe("renderSubmitView", () => {
26
+ it("S-1: shows 'Ready to submit' when all confirmed", () => {
27
+ const states = [makeState({ confirmed: true, selectedIndex: 0 })];
28
+ const lines = renderSubmitView([q1], states, stubTheme, 60);
29
+ expect(lines.some((l) => l.includes("Ready to submit"))).toBe(true);
30
+ expect(lines.some((l) => l.includes("All questions answered"))).toBe(true);
31
+ });
32
+
33
+ it("S-2: shows 'Unanswered' when not all confirmed", () => {
34
+ const states = [makeState({ confirmed: false })];
35
+ const lines = renderSubmitView([q1], states, stubTheme, 60);
36
+ expect(lines.some((l) => l.includes("Unanswered"))).toBe(true);
37
+ expect(lines.some((l) => l.includes("Database"))).toBe(true);
38
+ });
39
+
40
+ it("S-13: shows 'Still needed: <headers>' line when not all confirmed", () => {
41
+ // S-13 锁定未全确认时渲染的 'Still needed: <missing headers>' 行
42
+ const questions: Question[] = [
43
+ { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
44
+ { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
45
+ ];
46
+ // Q1 已答,Q2 未答 → Still needed 应列出 Second
47
+ const states = [
48
+ makeState({ confirmed: true, selectedIndex: 0 }),
49
+ makeState({ confirmed: false }),
50
+ ];
51
+ const lines = renderSubmitView(questions, states, stubTheme, 60);
52
+ const t = lines.join("\n");
53
+ expect(t).toContain("Still needed");
54
+ expect(t).toContain("Second");
55
+ expect(t).not.toContain("Still needed: First"); // First 已答,不谈入
56
+ });
57
+
58
+ it("S-3: lists answered header: answer", () => {
59
+ const states = [makeState({ confirmed: true, selectedIndex: 0 })];
60
+ const lines = renderSubmitView([q1], states, stubTheme, 60);
61
+ expect(lines.some((l) => l.includes("Database") && l.includes("Postgres"))).toBe(true);
62
+ });
63
+
64
+ it("S-4: shows dash for unanswered", () => {
65
+ const states = [makeState({ confirmed: false })];
66
+ const lines = renderSubmitView([q1], states, stubTheme, 60);
67
+ expect(lines.some((l) => l.includes("Database") && l.includes("—"))).toBe(true);
68
+ });
69
+
70
+ // S-13: 提示行已上移至组件级按钮栏(renderButtonBar)
71
+ it("S-13: submit view does not render its own submit/cancel hints (moved to button bar)", () => {
72
+ const states = [makeState({ confirmed: true, selectedIndex: 0 })];
73
+ const lines = renderSubmitView([q1], states, stubTheme, 60);
74
+ const t = lines.join("\n");
75
+ // 旧的内联提示不再由 submit-view 渲染(由 component.renderButtonBar 负责)
76
+ expect(t).not.toContain("switch tabs");
77
+ expect(t).not.toContain("Press Enter to submit");
78
+ });
79
+ });
80
+
81
+ // ── S-5 ~ S-10: getAnswerText ────────────────────────────
82
+ describe("getAnswerText", () => {
83
+ it("S-5: single-select returns label", () => {
84
+ const s = makeState({ confirmed: true, selectedIndex: 0 });
85
+ expect(getAnswerText(q1, s)).toBe("Postgres");
86
+ });
87
+
88
+ it("S-6: multi-select returns labels joined in index order", () => {
89
+ const multiQ: Question = {
90
+ question: "Features",
91
+ multiSelect: true,
92
+ options: [{ label: "A" }, { label: "B" }, { label: "C" }],
93
+ };
94
+ const s = makeState({ confirmed: true, selectedIndices: new Set([0, 1]) });
95
+ expect(getAnswerText(multiQ, s)).toBe("A, B");
96
+ });
97
+
98
+ it("S-7: multi-select out-of-order toggle still sorts by index", () => {
99
+ const multiQ: Question = {
100
+ question: "Features",
101
+ multiSelect: true,
102
+ options: [{ label: "A" }, { label: "B" }, { label: "C" }],
103
+ };
104
+ // toggle order: 1, then 0, then 2 → Set may iterate 1,0,2 but output should be A, B, C
105
+ const s = makeState({ confirmed: true, selectedIndices: new Set([1, 0, 2]) });
106
+ expect(getAnswerText(multiQ, s)).toBe("A, B, C");
107
+ });
108
+
109
+ it("S-7b: multi-select out-of-range indices are filtered out (defensive branch)", () => {
110
+ // 锁定 getAnswerText 的防御性 .filter:selectedIndices 含越界 index(超出 options 范围)
111
+ // 时,q.options[idx]?.label 为 undefined,被过滤掉,不污染答案。
112
+ const multiQ: Question = {
113
+ question: "Features",
114
+ multiSelect: true,
115
+ options: [{ label: "A" }, { label: "B" }],
116
+ };
117
+ // index 5 越界(只有 2 个选项),应被过滤;只保留 A
118
+ const s = makeState({ confirmed: true, selectedIndices: new Set([0, 5]) });
119
+ expect(getAnswerText(multiQ, s)).toBe("A");
120
+ });
121
+
122
+ it("S-8: Other free-text appended", () => {
123
+ const s = makeState({ confirmed: true, selectedIndex: null, freeTextValue: "custom" });
124
+ expect(getAnswerText(q1, s)).toBe("custom");
125
+ });
126
+
127
+ it("S-9: comment appended with separator", () => {
128
+ const s = makeState({ confirmed: true, selectedIndex: 0, commentValue: "fast" });
129
+ expect(getAnswerText(q1, s)).toBe("Postgres — fast");
130
+ });
131
+
132
+ it("S-10: unconfirmed returns null", () => {
133
+ const s = makeState({ confirmed: false });
134
+ expect(getAnswerText(q1, s)).toBeNull();
135
+ });
136
+
137
+ it("S-11: confirmed but empty (no selection, no freeText, empty multi) returns null", () => {
138
+ // S-11 锁定防御分支:confirmed=true 但无任何答案内容时 getAnswerText 返回 null
139
+ // 场景:单选 selectedIndex=null + freeTextValue=null
140
+ const sEmptySingle = makeState({ confirmed: true, selectedIndex: null, freeTextValue: null });
141
+ expect(getAnswerText(q1, sEmptySingle)).toBeNull();
142
+ // 场景:多选 selectedIndices 空集合
143
+ const multiQ: Question = {
144
+ question: "Features",
145
+ multiSelect: true,
146
+ options: [{ label: "A" }, { label: "B" }],
147
+ };
148
+ const sEmptyMulti = makeState({ confirmed: true, selectedIndices: new Set<number>() });
149
+ expect(getAnswerText(multiQ, sEmptyMulti)).toBeNull();
150
+ });
151
+
152
+ it("S-9b: multi-select + comment combined", () => {
153
+ const multiQ: Question = {
154
+ question: "Features",
155
+ multiSelect: true,
156
+ allowComment: true,
157
+ options: [{ label: "A" }, { label: "B" }],
158
+ };
159
+ const s = makeState({
160
+ confirmed: true,
161
+ selectedIndices: new Set([0, 1]),
162
+ commentValue: "nice",
163
+ });
164
+ expect(getAnswerText(multiQ, s)).toBe("A, B — nice");
165
+ });
166
+ });
167
+
168
+ // ── S-11 ~ S-12: buildResult ─────────────────────────────
169
+ describe("buildResult", () => {
170
+ it("S-11: builds answers for all confirmed questions", () => {
171
+ const questions = [q1];
172
+ const states = [makeState({ confirmed: true, selectedIndex: 1 })];
173
+ const result = buildResult(questions, states);
174
+ expect(result.cancelled).toBe(false);
175
+ expect(result.answers["Which DB?"]).toBe("SQLite");
176
+ expect(result.questions).toBe(questions);
177
+ });
178
+
179
+ it("S-12: omits unconfirmed questions from answers", () => {
180
+ const questions: Question[] = [
181
+ { question: "Q1", header: "H1", options: [{ label: "A" }, { label: "B" }] },
182
+ { question: "Q2", header: "H2", options: [{ label: "X" }, { label: "Y" }] },
183
+ ];
184
+ const states = [
185
+ makeState({ confirmed: true, selectedIndex: 0 }),
186
+ makeState({ confirmed: false }),
187
+ ];
188
+ const result = buildResult(questions, states);
189
+ expect(result.answers["Q1"]).toBe("A");
190
+ expect(result.answers["Q2"]).toBeUndefined();
191
+ });
192
+ });
@@ -0,0 +1,71 @@
1
+ // src/__tests__/types.test.ts
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import {
5
+ createQuestionState,
6
+ InputSchema,
7
+ OTHER_LABEL,
8
+ QuestionSchema,
9
+ SPLIT_PANE_MIN_WIDTH,
10
+ } from "../types";
11
+
12
+ describe("types", () => {
13
+ it("InputSchema accepts valid single question", () => {
14
+ const valid = {
15
+ questions: [
16
+ {
17
+ question: "Which DB?",
18
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
19
+ },
20
+ ],
21
+ };
22
+ expect(InputSchema).toBeDefined();
23
+ // Schema is a typebox object; verify it has the questions property structure
24
+ expect((InputSchema as { properties: Record<string, unknown> }).properties.questions).toBeDefined();
25
+ expect(valid.questions).toHaveLength(1);
26
+ });
27
+
28
+ it("OTHER_LABEL constant is the free-text option label", () => {
29
+ expect(OTHER_LABEL).toBe("Other");
30
+ });
31
+
32
+ it("SPLIT_PANE_MIN_WIDTH is 84", () => {
33
+ expect(SPLIT_PANE_MIN_WIDTH).toBe(84);
34
+ });
35
+
36
+ it("createQuestionState returns initial state with mode 'options'", () => {
37
+ const s = createQuestionState();
38
+ expect(s.cursorIndex).toBe(0);
39
+ expect(s.selectedIndex).toBeNull();
40
+ expect(s.selectedIndices).toBeInstanceOf(Set);
41
+ expect(s.confirmed).toBe(false);
42
+ expect(s.freeTextValue).toBeNull();
43
+ expect(s.commentValue).toBeNull();
44
+ expect(s.mode).toBe("options");
45
+ });
46
+
47
+ // T-5: 每次调用返回独立的 Set 实例
48
+ it("createQuestionState returns independent Set each call", () => {
49
+ const s1 = createQuestionState();
50
+ const s2 = createQuestionState();
51
+ s1.selectedIndices.add(0);
52
+ expect(s2.selectedIndices.has(0)).toBe(false);
53
+ expect(s1.selectedIndices).not.toBe(s2.selectedIndices);
54
+ });
55
+
56
+ // T-6: QuestionSchema options 数量约束
57
+ it("QuestionSchema enforces options minItems=2, maxItems=4", () => {
58
+ const opts = (QuestionSchema as { properties: { options: { minItems: number; maxItems: number } } })
59
+ .properties.options;
60
+ expect(opts.minItems).toBe(2);
61
+ expect(opts.maxItems).toBe(4);
62
+ });
63
+
64
+ // T-7: InputSchema questions 数量约束
65
+ it("InputSchema enforces questions minItems=1, maxItems=4", () => {
66
+ const qs = (InputSchema as { properties: { questions: { minItems: number; maxItems: number } } })
67
+ .properties.questions;
68
+ expect(qs.minItems).toBe(1);
69
+ expect(qs.maxItems).toBe(4);
70
+ });
71
+ });