@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.
@@ -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, 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
- };
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 = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
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 = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
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 = renderQuestionView(singleQ, makeState({ cursorIndex: 0 }), stubTheme, 60, true, "");
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 = renderQuestionView(singleQ, makeState({ selectedIndex: 1 }), stubTheme, 60, true, "");
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 = renderQuestionView(
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 = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
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 = renderQuestionView(singleQ, makeState(), stubTheme, 100, true, "");
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 = renderQuestionView(singleQ, makeState({ cursorIndex: 1 }), stubTheme, 100, true, "");
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 = renderQuestionView(singleQ, makeState({ cursorIndex: 2 }), stubTheme, 100, true, "");
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 = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
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 = renderQuestionView(
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
- // Other 行原地变 [ ] <input>█(freeform 模式不依赖 buildEditorBlock 独立编辑块)
152
- expect(t).toContain("my draft");
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 = renderQuestionView(
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
- expect(t).toContain("custom");
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 = renderQuestionView(
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 = renderQuestionView(
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 = renderQuestionView(
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 的行都不超过总宽 width(stubTheme ANSI,长度=可见宽)
244
+ expect(t).toContain("\x1b[7m");
245
+ // 每个含 x 的行可见宽度不超过 width(strip ANSI 后测量)
232
246
  for (const l of lines) {
233
- if (l.includes("x")) expect(l.length).toBeLessThanOrEqual(width);
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 = renderQuestionView(
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 = renderQuestionView(
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
- const inputLines = lines.filter((l) => l.includes("y") || l.includes("3."));
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 = renderQuestionView(
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 = renderQuestionView(
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 = renderQuestionView(
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 = renderQuestionView(
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 = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
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 = renderQuestionView(multiQ, makeState(), stubTheme, 60, true, "");
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 = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
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 = renderQuestionView(singleQ, makeState(), stubTheme, 60, false, "");
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 = renderQuestionView(
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 = renderQuestionView(
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 = renderQuestionView(
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 = renderQuestionView(
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 = renderQuestionView(
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 = renderQuestionView(q, makeState(), stubTheme, 60, true, "");
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 = renderQuestionView(singleQ, makeState(), stubTheme, 60, true, "");
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 = renderQuestionView(singleQ, makeState(), stubTheme, 20, true, "");
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 = renderQuestionView(q, makeState(), stubTheme, 40, true, "");
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 { Question } from "../types";
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: 不同 question 可以有相同 headerheader 不要求唯一)
84
- it("allows duplicate headers across different questions", () => {
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
  });