@zhushanwen/pi-ask-user 0.0.4 → 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/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +6 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +522 -0
- package/src/__tests__/component.test.ts +50 -12
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +329 -25
- package/src/__tests__/question-view.test.ts +71 -53
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +19 -4
- package/src/__tests__/w2-draft-hint.test.ts +157 -0
- package/src/__tests__/w3-regression.test.ts +128 -0
- package/src/answer-format.ts +51 -0
- package/src/component.ts +132 -129
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +198 -72
- package/src/question-view.ts +81 -65
- package/src/submit-view.ts +19 -11
- package/src/types.ts +24 -2
- package/src/validate.ts +20 -1
|
@@ -1,14 +1,9 @@
|
|
|
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";
|
|
5
|
-
import { createQuestionState, type Question, type QuestionState
|
|
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
|
-
};
|
|
4
|
+
import { getSplitPaneWidths, renderQuestionView, type RenderContext } from "../question-view";
|
|
5
|
+
import { createQuestionState, type Question, type QuestionState } from "../types";
|
|
6
|
+
import { stubTheme } from "./fixtures";
|
|
12
7
|
|
|
13
8
|
const singleQ: Question = {
|
|
14
9
|
question: "Which database?",
|
|
@@ -23,18 +18,33 @@ const makeState = (over: Partial<QuestionState> = {}): QuestionState => ({
|
|
|
23
18
|
...over,
|
|
24
19
|
});
|
|
25
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
|
+
|
|
26
36
|
// Helper: join all lines for substring search
|
|
27
37
|
const text = (lines: string[]): string => lines.join("\n");
|
|
28
38
|
|
|
29
39
|
// ── Q-1 ~ Q-6: 基础渲染 ──────────────────────────────────
|
|
30
40
|
describe("renderQuestionView — basics", () => {
|
|
31
41
|
it("Q-1: renders question text", () => {
|
|
32
|
-
const lines =
|
|
42
|
+
const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
|
|
33
43
|
expect(text(lines)).toContain("Which database?");
|
|
34
44
|
});
|
|
35
45
|
|
|
36
46
|
it("Q-2: renders all options + Other", () => {
|
|
37
|
-
const lines =
|
|
47
|
+
const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
|
|
38
48
|
const t = text(lines);
|
|
39
49
|
expect(t).toContain("Postgres");
|
|
40
50
|
expect(t).toContain("SQLite");
|
|
@@ -42,14 +52,14 @@ describe("renderQuestionView — basics", () => {
|
|
|
42
52
|
});
|
|
43
53
|
|
|
44
54
|
it("Q-3: renders cursor > on first option", () => {
|
|
45
|
-
const lines =
|
|
55
|
+
const lines = rv(singleQ, makeState({ cursorIndex: 0 }), stubTheme, 60, true, "");
|
|
46
56
|
const t = text(lines);
|
|
47
57
|
expect(t).toContain(">");
|
|
48
58
|
expect(t).toContain("Postgres");
|
|
49
59
|
});
|
|
50
60
|
|
|
51
61
|
it("Q-4: renders single-select check on confirmed selection", () => {
|
|
52
|
-
const lines =
|
|
62
|
+
const lines = rv(singleQ, makeState({ selectedIndex: 1 }), stubTheme, 60, true, "");
|
|
53
63
|
expect(text(lines)).toContain("✓");
|
|
54
64
|
expect(text(lines)).toContain("SQLite");
|
|
55
65
|
});
|
|
@@ -60,7 +70,7 @@ describe("renderQuestionView — basics", () => {
|
|
|
60
70
|
options: [{ label: "Auth" }, { label: "Search" }],
|
|
61
71
|
multiSelect: true,
|
|
62
72
|
};
|
|
63
|
-
const lines =
|
|
73
|
+
const lines = rv(
|
|
64
74
|
multiQ,
|
|
65
75
|
makeState({ selectedIndices: new Set([0]) }),
|
|
66
76
|
stubTheme,
|
|
@@ -74,7 +84,7 @@ describe("renderQuestionView — basics", () => {
|
|
|
74
84
|
});
|
|
75
85
|
|
|
76
86
|
it("Q-6: renders descriptions in muted", () => {
|
|
77
|
-
const lines =
|
|
87
|
+
const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
|
|
78
88
|
expect(text(lines)).toContain("Battle-tested");
|
|
79
89
|
});
|
|
80
90
|
});
|
|
@@ -101,7 +111,7 @@ describe("renderQuestionView — split pane", () => {
|
|
|
101
111
|
});
|
|
102
112
|
|
|
103
113
|
it("Q-9: split-pane left column hides descriptions", () => {
|
|
104
|
-
const lines =
|
|
114
|
+
const lines = rv(singleQ, makeState(), stubTheme, 100, true, "");
|
|
105
115
|
const t = text(lines);
|
|
106
116
|
// In split mode, descriptions appear only in the right pane preview of the focused item.
|
|
107
117
|
// The focused item (cursorIndex=0=Postgres) shows "Battle-tested" in the right pane,
|
|
@@ -114,7 +124,7 @@ describe("renderQuestionView — split pane", () => {
|
|
|
114
124
|
});
|
|
115
125
|
|
|
116
126
|
it("Q-10: split-pane right pane shows focused option detail", () => {
|
|
117
|
-
const lines =
|
|
127
|
+
const lines = rv(singleQ, makeState({ cursorIndex: 1 }), stubTheme, 100, true, "");
|
|
118
128
|
const t = text(lines);
|
|
119
129
|
// Focused on SQLite → right pane should show SQLite's description
|
|
120
130
|
expect(t).toContain("Embedded");
|
|
@@ -122,13 +132,13 @@ describe("renderQuestionView — split pane", () => {
|
|
|
122
132
|
|
|
123
133
|
it("Q-11: split-pane right pane on Other shows custom-answer hint", () => {
|
|
124
134
|
// cursorIndex = last option (Other) = 2
|
|
125
|
-
const lines =
|
|
135
|
+
const lines = rv(singleQ, makeState({ cursorIndex: 2 }), stubTheme, 100, true, "");
|
|
126
136
|
const t = text(lines);
|
|
127
137
|
expect(t).toContain("custom");
|
|
128
138
|
});
|
|
129
139
|
|
|
130
140
|
it("Q-14: single-column mode shows indented descriptions", () => {
|
|
131
|
-
const lines =
|
|
141
|
+
const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
|
|
132
142
|
// Both descriptions shown inline (single-column mode)
|
|
133
143
|
const t = text(lines);
|
|
134
144
|
expect(t).toContain("Battle-tested");
|
|
@@ -139,7 +149,7 @@ describe("renderQuestionView — split pane", () => {
|
|
|
139
149
|
// ── Q-15 ~ Q-17: Other 编辑器模式 ───────────────────────
|
|
140
150
|
describe("renderQuestionView — Other editor mode", () => {
|
|
141
151
|
it("Q-15: freeform mode renders Other row in-place with draft + cursor + number prefix", () => {
|
|
142
|
-
const lines =
|
|
152
|
+
const lines = rv(
|
|
143
153
|
singleQ,
|
|
144
154
|
makeState({ mode: "freeform", cursorIndex: 2 }),
|
|
145
155
|
stubTheme,
|
|
@@ -148,9 +158,10 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
148
158
|
"my draft",
|
|
149
159
|
);
|
|
150
160
|
const t = text(lines);
|
|
151
|
-
//
|
|
152
|
-
expect(t).toContain("my
|
|
153
|
-
expect(t).toContain("
|
|
161
|
+
// cursorIndex=2 → █ 在 "my" 和 " draft" 之间
|
|
162
|
+
expect(t).toContain("my");
|
|
163
|
+
expect(t).toContain("draft");
|
|
164
|
+
expect(t).toContain("\x1b[7m");
|
|
154
165
|
// 不再独立 "Your answer" 提示行
|
|
155
166
|
expect(t).not.toContain("Your answer");
|
|
156
167
|
// 需求4:Other 在 freeform 态也有编号前缀(与其他选项一致)。
|
|
@@ -165,7 +176,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
165
176
|
multiSelect: true,
|
|
166
177
|
options: [{ label: "Auth" }, { label: "Search" }],
|
|
167
178
|
};
|
|
168
|
-
const lines =
|
|
179
|
+
const lines = rv(
|
|
169
180
|
multiQ,
|
|
170
181
|
makeState({ mode: "freeform", cursorIndex: 2 }),
|
|
171
182
|
stubTheme,
|
|
@@ -177,12 +188,15 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
177
188
|
// 多选 box [ ] + 编号 3. 都在编辑行上
|
|
178
189
|
expect(t).toContain("[ ]");
|
|
179
190
|
expect(t).toContain("3. ");
|
|
180
|
-
|
|
181
|
-
expect(t).toContain("
|
|
191
|
+
// cursorIndex=2 → cursor 在 "s" 上: "cu\x1b[7ms\x1b[27mtom" — "stom" 被 cursor 打断
|
|
192
|
+
expect(t).toContain("cu");
|
|
193
|
+
expect(t).toContain("s");
|
|
194
|
+
expect(t).toContain("tom");
|
|
195
|
+
expect(t).toContain("\x1b[7m");
|
|
182
196
|
});
|
|
183
197
|
|
|
184
198
|
it("Q-16: Other with saved free-text shows checkmark + preview", () => {
|
|
185
|
-
const lines =
|
|
199
|
+
const lines = rv(
|
|
186
200
|
singleQ,
|
|
187
201
|
makeState({ cursorIndex: 2, freeTextValue: "saved text" }),
|
|
188
202
|
stubTheme,
|
|
@@ -196,7 +210,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
196
210
|
});
|
|
197
211
|
|
|
198
212
|
it("Q-17: Other row focused shows Enter hint (Tab now navigates tabs)", () => {
|
|
199
|
-
const lines =
|
|
213
|
+
const lines = rv(
|
|
200
214
|
singleQ,
|
|
201
215
|
makeState({ cursorIndex: 2 }),
|
|
202
216
|
stubTheme,
|
|
@@ -213,7 +227,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
213
227
|
const width = 30;
|
|
214
228
|
// lead = "> [ ] " (单选 box=" ") = 6 列 → avail = 24
|
|
215
229
|
const long = "x".repeat(60);
|
|
216
|
-
const lines =
|
|
230
|
+
const lines = rv(
|
|
217
231
|
singleQ,
|
|
218
232
|
makeState({ mode: "freeform", cursorIndex: 2 }),
|
|
219
233
|
stubTheme,
|
|
@@ -227,10 +241,13 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
227
241
|
expect(xCount).toBe(60);
|
|
228
242
|
expect(lines.some((l) => l.includes("x"))).toBe(true);
|
|
229
243
|
// 光标仍在末尾出现
|
|
230
|
-
expect(t).toContain("
|
|
231
|
-
// 每个含 x
|
|
244
|
+
expect(t).toContain("\x1b[7m");
|
|
245
|
+
// 每个含 x 的行可见宽度不超过 width(strip ANSI 后测量)
|
|
232
246
|
for (const l of lines) {
|
|
233
|
-
if (l.includes("x"))
|
|
247
|
+
if (l.includes("x")) {
|
|
248
|
+
const visible = l.replace(/\x1b\[[0-9;]*m/g, "");
|
|
249
|
+
expect(visible.length).toBeLessThanOrEqual(width);
|
|
250
|
+
}
|
|
234
251
|
}
|
|
235
252
|
});
|
|
236
253
|
|
|
@@ -243,7 +260,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
243
260
|
// 单选 lead visLen=5 → avail = width - 5 = 95。用 60 字符(< 95)应单行装下。
|
|
244
261
|
// 修复前若用 split.left≈40,avail≈35 → 60 字符会换 2 行。
|
|
245
262
|
const input = "a".repeat(60);
|
|
246
|
-
const lines =
|
|
263
|
+
const lines = rv(
|
|
247
264
|
singleQ,
|
|
248
265
|
makeState({ mode: "freeform", cursorIndex: 2 }),
|
|
249
266
|
stubTheme,
|
|
@@ -252,7 +269,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
252
269
|
input,
|
|
253
270
|
);
|
|
254
271
|
// 用 █ 定位编辑器行(help 行不含 █),排除 “submit” 含 a 的干扰
|
|
255
|
-
const editorLines = lines.filter((l) => l.includes("
|
|
272
|
+
const editorLines = lines.filter((l) => l.includes("\x1b[7m"));
|
|
256
273
|
expect(editorLines.length).toBe(1);
|
|
257
274
|
// 全部 60 个 a 都在这一行(未被换行拆分)
|
|
258
275
|
const aCount = editorLines[0]!.split("").filter((c) => c === "a").length;
|
|
@@ -263,7 +280,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
263
280
|
const width = 30;
|
|
264
281
|
// avail=24,200 字符 → 9 行,超过 5 行上限
|
|
265
282
|
const huge = "y".repeat(200);
|
|
266
|
-
const lines =
|
|
283
|
+
const lines = rv(
|
|
267
284
|
singleQ,
|
|
268
285
|
makeState({ mode: "freeform", cursorIndex: 2 }),
|
|
269
286
|
stubTheme,
|
|
@@ -273,7 +290,8 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
273
290
|
);
|
|
274
291
|
// 统计含输入内容的行数 = input 渲染行数(应被截到 5 行)
|
|
275
292
|
// 编号 "3. " 现在是 styled 内容的一部分,首行包裹段 "3." 不含 'y'
|
|
276
|
-
|
|
293
|
+
// 排除 help 行(含 "Type to add" 的提示行也含 'y')
|
|
294
|
+
const inputLines = lines.filter((l) => (l.includes("y") || l.includes("3.")) && !l.includes("Type to add"));
|
|
277
295
|
expect(inputLines.length).toBe(5);
|
|
278
296
|
// 最后一行带省略号(表示还有更多,光标已被省略号取代)
|
|
279
297
|
expect(inputLines[inputLines.length - 1]).toContain("…");
|
|
@@ -283,7 +301,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
283
301
|
const width = 40;
|
|
284
302
|
// 预览 lead=" "(5 列) → avail=35;预览文本带引号
|
|
285
303
|
const long = "z".repeat(80);
|
|
286
|
-
const lines =
|
|
304
|
+
const lines = rv(
|
|
287
305
|
singleQ,
|
|
288
306
|
makeState({ cursorIndex: 2, freeTextValue: long }),
|
|
289
307
|
stubTheme,
|
|
@@ -303,7 +321,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
303
321
|
const width = 30;
|
|
304
322
|
// 预览 avail = 30-5 = 25,300 字符 → >5 行
|
|
305
323
|
const huge = "w".repeat(300);
|
|
306
|
-
const lines =
|
|
324
|
+
const lines = rv(
|
|
307
325
|
singleQ,
|
|
308
326
|
makeState({ cursorIndex: 2, freeTextValue: huge }),
|
|
309
327
|
stubTheme,
|
|
@@ -320,7 +338,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
320
338
|
// ── Q-18 ~ Q-19: 评论模式 ───────────────────────────────
|
|
321
339
|
describe("renderQuestionView — comment mode", () => {
|
|
322
340
|
it("Q-18: comment mode renders editor with note text", () => {
|
|
323
|
-
const lines =
|
|
341
|
+
const lines = rv(
|
|
324
342
|
singleQ,
|
|
325
343
|
makeState({ mode: "comment", selectedIndex: 0 }),
|
|
326
344
|
stubTheme,
|
|
@@ -334,7 +352,7 @@ describe("renderQuestionView — comment mode", () => {
|
|
|
334
352
|
});
|
|
335
353
|
|
|
336
354
|
it("Q-19: comment prompt includes (optional)", () => {
|
|
337
|
-
const lines =
|
|
355
|
+
const lines = rv(
|
|
338
356
|
singleQ,
|
|
339
357
|
makeState({ mode: "comment", selectedIndex: 0 }),
|
|
340
358
|
stubTheme,
|
|
@@ -349,7 +367,7 @@ describe("renderQuestionView — comment mode", () => {
|
|
|
349
367
|
// ── Q-20 ~ Q-23: 帮助行 ─────────────────────────────────
|
|
350
368
|
describe("renderQuestionView — help line", () => {
|
|
351
369
|
it("Q-20: single-select help shows 'Enter select'", () => {
|
|
352
|
-
const lines =
|
|
370
|
+
const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
|
|
353
371
|
expect(text(lines)).toContain("Enter select");
|
|
354
372
|
});
|
|
355
373
|
|
|
@@ -359,17 +377,17 @@ describe("renderQuestionView — help line", () => {
|
|
|
359
377
|
options: [{ label: "A" }, { label: "B" }],
|
|
360
378
|
multiSelect: true,
|
|
361
379
|
};
|
|
362
|
-
const lines =
|
|
380
|
+
const lines = rv(multiQ, makeState(), stubTheme, 60, true, "");
|
|
363
381
|
expect(text(lines)).toContain("Space toggle");
|
|
364
382
|
});
|
|
365
383
|
|
|
366
384
|
it("Q-22: single question omits tab-switch hint", () => {
|
|
367
|
-
const lines =
|
|
385
|
+
const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
|
|
368
386
|
expect(text(lines)).not.toContain("switch tabs");
|
|
369
387
|
});
|
|
370
388
|
|
|
371
389
|
it("Q-23: multi question includes tab-switch hint", () => {
|
|
372
|
-
const lines =
|
|
390
|
+
const lines = rv(singleQ, makeState(), stubTheme, 60, false, "");
|
|
373
391
|
expect(text(lines)).toContain("switch tabs");
|
|
374
392
|
});
|
|
375
393
|
});
|
|
@@ -378,7 +396,7 @@ describe("renderQuestionView — help line", () => {
|
|
|
378
396
|
describe("renderQuestionView — Other row alignment", () => {
|
|
379
397
|
it("Q-32: 单选 Other freeform 编辑行编号与普通选项对齐", () => {
|
|
380
398
|
// 回归:单选 freeform 占位从 \" \"(2列) 改为 \" \"(1列),编号列与普通单选对齐
|
|
381
|
-
const lines =
|
|
399
|
+
const lines = rv(
|
|
382
400
|
singleQ,
|
|
383
401
|
makeState({ mode: "freeform", cursorIndex: 2 }),
|
|
384
402
|
stubTheme,
|
|
@@ -387,7 +405,7 @@ describe("renderQuestionView — Other row alignment", () => {
|
|
|
387
405
|
"custom",
|
|
388
406
|
);
|
|
389
407
|
const normalLine = lines.find((l) => l.includes("Postgres"))!;
|
|
390
|
-
const otherLine = lines.find((l) => l.includes("
|
|
408
|
+
const otherLine = lines.find((l) => l.includes("\x1b[7m"))!;
|
|
391
409
|
// stubTheme 无 ANSI,indexOf 反映可见列位置。普通选项编号 idx === Other 编号 idx
|
|
392
410
|
expect(normalLine.indexOf("1.")).toBe(otherLine.indexOf("3."));
|
|
393
411
|
});
|
|
@@ -399,7 +417,7 @@ describe("renderQuestionView — Other row alignment", () => {
|
|
|
399
417
|
multiSelect: true,
|
|
400
418
|
options: [{ label: "Auth" }, { label: "Search" }],
|
|
401
419
|
};
|
|
402
|
-
const lines =
|
|
420
|
+
const lines = rv(
|
|
403
421
|
multiQ,
|
|
404
422
|
makeState({ cursorIndex: 2 }),
|
|
405
423
|
stubTheme,
|
|
@@ -414,7 +432,7 @@ describe("renderQuestionView — Other row alignment", () => {
|
|
|
414
432
|
|
|
415
433
|
it("Q-34: 单选 Other freeText 预览缩进对齐到 label 起始列", () => {
|
|
416
434
|
// 回归:预览 lead 从硬编码 6 列改为动态计算(单选个位 = 7 列)
|
|
417
|
-
const lines =
|
|
435
|
+
const lines = rv(
|
|
418
436
|
singleQ,
|
|
419
437
|
makeState({ cursorIndex: 2, freeTextValue: "saved" }),
|
|
420
438
|
stubTheme,
|
|
@@ -434,7 +452,7 @@ describe("renderQuestionView — Other row alignment", () => {
|
|
|
434
452
|
multiSelect: true,
|
|
435
453
|
options: [{ label: "Auth" }, { label: "Search" }],
|
|
436
454
|
};
|
|
437
|
-
const lines =
|
|
455
|
+
const lines = rv(
|
|
438
456
|
multiQ,
|
|
439
457
|
makeState({ cursorIndex: 2, freeTextValue: "saved" }),
|
|
440
458
|
stubTheme,
|
|
@@ -453,7 +471,7 @@ describe("renderQuestionView — Other row alignment", () => {
|
|
|
453
471
|
question: "Q",
|
|
454
472
|
options: Array.from({ length: 10 }, (_, k) => ({ label: `Opt${k + 1}` })),
|
|
455
473
|
};
|
|
456
|
-
const lines =
|
|
474
|
+
const lines = rv(
|
|
457
475
|
bigQ,
|
|
458
476
|
makeState({ cursorIndex: 10, freeTextValue: "x" }),
|
|
459
477
|
stubTheme,
|
|
@@ -471,12 +489,12 @@ describe("renderQuestionView — Other row alignment", () => {
|
|
|
471
489
|
describe("renderQuestionView — context", () => {
|
|
472
490
|
it("Q-24: renders context when present", () => {
|
|
473
491
|
const q: Question = { ...singleQ, context: "Background info here" };
|
|
474
|
-
const lines =
|
|
492
|
+
const lines = rv(q, makeState(), stubTheme, 60, true, "");
|
|
475
493
|
expect(text(lines)).toContain("Background info here");
|
|
476
494
|
});
|
|
477
495
|
|
|
478
496
|
it("Q-25: no context field → no extra context text", () => {
|
|
479
|
-
const lines =
|
|
497
|
+
const lines = rv(singleQ, makeState(), stubTheme, 60, true, "");
|
|
480
498
|
// No "context" label text in output (only question/options/help)
|
|
481
499
|
expect(text(lines)).toContain("Which database?");
|
|
482
500
|
});
|
|
@@ -485,7 +503,7 @@ describe("renderQuestionView — context", () => {
|
|
|
485
503
|
// ── Q-26 ~ Q-27: 边界 ───────────────────────────────────
|
|
486
504
|
describe("renderQuestionView — edge cases", () => {
|
|
487
505
|
it("Q-26: very narrow terminal width=20 does not crash", () => {
|
|
488
|
-
const lines =
|
|
506
|
+
const lines = rv(singleQ, makeState(), stubTheme, 20, true, "");
|
|
489
507
|
expect(lines.length).toBeGreaterThan(0);
|
|
490
508
|
});
|
|
491
509
|
|
|
@@ -494,7 +512,7 @@ describe("renderQuestionView — edge cases", () => {
|
|
|
494
512
|
question: "Q",
|
|
495
513
|
options: [{ label: "A".repeat(80), description: "desc" }, { label: "B" }],
|
|
496
514
|
};
|
|
497
|
-
const lines =
|
|
515
|
+
const lines = rv(q, makeState(), stubTheme, 40, true, "");
|
|
498
516
|
// Should not contain the full 80-char label on a 40-col terminal
|
|
499
517
|
const t = text(lines);
|
|
500
518
|
expect(t).not.toContain("A".repeat(80));
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/__tests__/sdk-contract.test.ts
|
|
2
|
+
//
|
|
3
|
+
// SDK 契约测试:验证 ask-user 扩展的工厂函数和注册接口与 SDK 类型兼容。
|
|
4
|
+
// 核心断言:
|
|
5
|
+
// 1. default export 是接受 ExtensionAPI 的工厂函数
|
|
6
|
+
// 2. registerTool 注册名为 "ask_user" 的 tool,含 description/execute/renderCall/renderResult
|
|
7
|
+
// 3. renderCall 参数数量 >= 1(args)
|
|
8
|
+
// 4. renderResult 参数数量 >= 2(result, options)
|
|
9
|
+
// 5. validateInput 校验逻辑正确
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { validateInput } from "../validate";
|
|
14
|
+
import askUserExtension from "../index";
|
|
15
|
+
|
|
16
|
+
describe("ask-user SDK contract", () => {
|
|
17
|
+
it("default export is a function accepting ExtensionAPI", () => {
|
|
18
|
+
expect(typeof askUserExtension).toBe("function");
|
|
19
|
+
// 工厂函数应接受 1 个参数 (pi: ExtensionAPI)
|
|
20
|
+
expect(askUserExtension.length).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("registerTool receives a valid ToolDefinition named 'ask_user'", () => {
|
|
24
|
+
let toolDef: Record<string, unknown> | undefined;
|
|
25
|
+
const mockPi = {
|
|
26
|
+
registerTool(def: Record<string, unknown>) {
|
|
27
|
+
toolDef = def;
|
|
28
|
+
},
|
|
29
|
+
on() {},
|
|
30
|
+
registerCommand() {},
|
|
31
|
+
getAllTools() { return []; },
|
|
32
|
+
setActiveTools() {},
|
|
33
|
+
} as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI;
|
|
34
|
+
|
|
35
|
+
askUserExtension(mockPi);
|
|
36
|
+
|
|
37
|
+
expect(toolDef).toBeDefined();
|
|
38
|
+
expect(toolDef!.name).toBe("ask_user");
|
|
39
|
+
expect(typeof toolDef!.description).toBe("string");
|
|
40
|
+
expect(typeof toolDef!.execute).toBe("function");
|
|
41
|
+
expect(typeof toolDef!.renderCall).toBe("function");
|
|
42
|
+
expect(typeof toolDef!.renderResult).toBe("function");
|
|
43
|
+
// parameters schema 应存在
|
|
44
|
+
expect(toolDef!.parameters).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renderCall accepts >= 1 args (args, [theme])", () => {
|
|
48
|
+
let toolDef: Record<string, unknown> | undefined;
|
|
49
|
+
const mockPi = {
|
|
50
|
+
registerTool(def: Record<string, unknown>) { toolDef = def; },
|
|
51
|
+
on() {},
|
|
52
|
+
registerCommand() {},
|
|
53
|
+
getAllTools() { return []; },
|
|
54
|
+
setActiveTools() {},
|
|
55
|
+
} as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI;
|
|
56
|
+
askUserExtension(mockPi);
|
|
57
|
+
expect(toolDef!.renderCall!.length).toBeGreaterThanOrEqual(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("renderResult accepts >= 2 args (result, options, [theme])", () => {
|
|
61
|
+
let toolDef: Record<string, unknown> | undefined;
|
|
62
|
+
const mockPi = {
|
|
63
|
+
registerTool(def: Record<string, unknown>) { toolDef = def; },
|
|
64
|
+
on() {},
|
|
65
|
+
registerCommand() {},
|
|
66
|
+
getAllTools() { return []; },
|
|
67
|
+
setActiveTools() {},
|
|
68
|
+
} as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI;
|
|
69
|
+
askUserExtension(mockPi);
|
|
70
|
+
expect(toolDef!.renderResult!.length).toBeGreaterThanOrEqual(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("validateInput accepts empty questions array (schema-level, not runtime)", () => {
|
|
74
|
+
// validateInput 是运行时校验,不检查数组长度(minItems 由 typebox schema 层强制)
|
|
75
|
+
expect(validateInput([])).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("validateInput accepts empty options array (schema-level, not runtime)", () => {
|
|
79
|
+
// 同上:options 长度由 schema 层校验
|
|
80
|
+
expect(
|
|
81
|
+
validateInput([
|
|
82
|
+
{ question: "Q?", options: [] },
|
|
83
|
+
] as unknown as import("../types").Question[]),
|
|
84
|
+
).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("validateInput rejects duplicate question text", () => {
|
|
88
|
+
expect(
|
|
89
|
+
validateInput([
|
|
90
|
+
{ question: "Same?", options: [{ label: "A" }, { label: "B" }] },
|
|
91
|
+
{ question: "Same?", header: "Dup", options: [{ label: "X" }, { label: "Y" }] },
|
|
92
|
+
] as import("../types").Question[]),
|
|
93
|
+
).toBeTruthy();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("validateInput rejects empty option label", () => {
|
|
97
|
+
expect(
|
|
98
|
+
validateInput([
|
|
99
|
+
{ question: "Q?", options: [{ label: "" }, { label: "B" }] },
|
|
100
|
+
] as import("../types").Question[]),
|
|
101
|
+
).toBeTruthy();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("validateInput accepts a valid single question", () => {
|
|
105
|
+
expect(
|
|
106
|
+
validateInput([
|
|
107
|
+
{ question: "Q1?", options: [{ label: "A" }, { label: "B" }] },
|
|
108
|
+
] as import("../types").Question[]),
|
|
109
|
+
).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/__tests__/validate.test.ts
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
|
|
4
|
-
import type
|
|
4
|
+
import { HEADER_MAX_CHARS, type Question } from "../types";
|
|
5
5
|
import { validateInput } from "../validate";
|
|
6
6
|
|
|
7
7
|
const q = (overrides: Partial<Question> = {}): Question => ({
|
|
@@ -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 不参与唯一性校验
|
|
@@ -121,4 +123,17 @@ describe("validateInput", () => {
|
|
|
121
123
|
expect(result).toContain("control characters");
|
|
122
124
|
}
|
|
123
125
|
});
|
|
126
|
+
|
|
127
|
+
// V-15: header 超过 HEADER_MAX_CHARS(12) 上限被拒绝(A3:补 validate 层校验)
|
|
128
|
+
it("rejects header exceeding HEADER_MAX_CHARS (12)", () => {
|
|
129
|
+
const result = validateInput([q({ header: "This header is too long" })]);
|
|
130
|
+
expect(result).not.toBeNull();
|
|
131
|
+
expect(result).toContain("Header exceeds");
|
|
132
|
+
expect(result).toContain(`${HEADER_MAX_CHARS}`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// V-16: header 恰好 12 字符(边界值,合法)
|
|
136
|
+
it("accepts header at exactly HEADER_MAX_CHARS (12)", () => {
|
|
137
|
+
expect(validateInput([q({ header: "123456789012" })])).toBeNull();
|
|
138
|
+
});
|
|
124
139
|
});
|