@zhushanwen/pi-ask-user 0.0.3 → 0.1.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.
@@ -288,7 +288,7 @@ describe("AskUserComponent — Other free-text editor", () => {
288
288
  c.handleInput(ENTER);
289
289
  const lines = c.render(60);
290
290
  // freeform 模式:光标行(█)出现,editor 已在 Other 行原地渲染
291
- expect(lines.some((l) => l.includes("█"))).toBe(true);
291
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(true);
292
292
  // 不再独立 "Your answer" 提示块
293
293
  expect(lines.some((l) => l.includes("Your answer"))).toBe(false);
294
294
  });
@@ -300,7 +300,7 @@ describe("AskUserComponent — Other free-text editor", () => {
300
300
  c.handleInput(TAB);
301
301
  const lines = c.render(60);
302
302
  // Tab 不再打开 Other 编辑器(仅 Enter);单问题下 Tab 是 no-op
303
- expect(lines.some((l) => l.includes("█"))).toBe(false);
303
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
304
304
  });
305
305
 
306
306
  it("C-27: editor accepts printable characters", () => {
@@ -334,7 +334,7 @@ describe("AskUserComponent — Other free-text editor", () => {
334
334
  c.handleInput(ESC);
335
335
  const lines = c.render(60);
336
336
  // Back in options mode — no cursor block (freeform inactive)
337
- expect(lines.some((l) => l.includes("█"))).toBe(false);
337
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
338
338
  });
339
339
 
340
340
  it("C-29: editor Enter with text saves and submits (single)", () => {
@@ -358,7 +358,7 @@ describe("AskUserComponent — Other free-text editor", () => {
358
358
  expect(result.val).toBeUndefined();
359
359
  // Back in options mode — no cursor block (freeform inactive)
360
360
  const lines = c.render(60);
361
- expect(lines.some((l) => l.includes("█"))).toBe(false);
361
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
362
362
  });
363
363
  });
364
364
 
@@ -374,7 +374,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
374
374
  c.handleInput(ENTER); // 打开 freeform 编辑器
375
375
  c.handleInput("hello world"); // 一次粘贴多字符
376
376
  const lines = c.render(60);
377
- const editorLine = lines.find((l) => l.includes("█"));
377
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
378
378
  expect(editorLine).toBeDefined();
379
379
  // 完整文本保留(非仅首字符 "h")
380
380
  expect(editorLine).toContain("hello world");
@@ -388,7 +388,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
388
388
  c.handleInput(ENTER);
389
389
  c.handleInput("fix the 🐛 bug");
390
390
  const lines = c.render(60);
391
- const editorLine = lines.find((l) => l.includes("█"));
391
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
392
392
  expect(editorLine).toBeDefined();
393
393
  // emoji 完整保留,不丢失
394
394
  expect(editorLine).toContain("fix the 🐛 bug");
@@ -403,7 +403,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
403
403
  c.handleInput(ENTER);
404
404
  c.handleInput("ab\tcd");
405
405
  const lines = c.render(60);
406
- const editorLine = lines.find((l) => l.includes("█"));
406
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
407
407
  expect(editorLine).toBeDefined();
408
408
  // 可打印字符保留,控制字符 \t 被过滤
409
409
  expect(editorLine).toContain("abcd");
@@ -422,7 +422,7 @@ describe("AskUserComponent — multi-char paste in editor", () => {
422
422
  expect(result.val).toBeUndefined();
423
423
  expect(after).toEqual(before);
424
424
  // 编辑器仍在(光标在),editorText 仍为空
425
- expect(after.some((l) => l.includes("█"))).toBe(true);
425
+ expect(after.some((l) => l.includes("\x1b[7m"))).toBe(true);
426
426
  });
427
427
 
428
428
  it("C-PASTE-5: single printable char still works (backward-compat)", () => {
@@ -433,10 +433,43 @@ describe("AskUserComponent — multi-char paste in editor", () => {
433
433
  c.handleInput(ENTER);
434
434
  c.handleInput("x"); // 单字符
435
435
  const lines = c.render(60);
436
- const editorLine = lines.find((l) => l.includes("█"));
436
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
437
437
  expect(editorLine).toBeDefined();
438
438
  expect(editorLine).toContain("x");
439
439
  });
440
+
441
+ it("C-PASTE-6: bracketed paste 序列被剥离,不残留 [200~/[201~", () => {
442
+ // 回归:启用 bracketed paste mode 的终端粘贴时会把内容包裹在
443
+ // \x1b[200~ ... \x1b[201~ 中。ESC 被守卫过滤,但 [200~/[201~ 可见字符
444
+ // 会残留。修复:handleEditorInput 先 replace 剥离这两个序列。
445
+ const { c } = make([singleQ]);
446
+ c.handleInput(DOWN);
447
+ c.handleInput(DOWN);
448
+ c.handleInput(ENTER); // 打开 freeform
449
+ c.handleInput("\x1b[200~hello\x1b[201~");
450
+ const lines = c.render(60);
451
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
452
+ expect(editorLine).toBeDefined();
453
+ expect(editorLine).toContain("hello");
454
+ expect(editorLine).not.toContain("[200~");
455
+ expect(editorLine).not.toContain("[201~");
456
+ });
457
+
458
+ it("C-PASTE-7: bracketed paste 跨 chunk 抵达时每个 chunk 独立剥离", () => {
459
+ // 边界:粘贴内容分多个 data chunk 抵达,每个 chunk 各自带始/末标记。
460
+ // 简单 replace 在此场景仍有效(每个 chunk 独立剥离自己的标记)。
461
+ const { c } = make([singleQ]);
462
+ c.handleInput(DOWN);
463
+ c.handleInput(DOWN);
464
+ c.handleInput(ENTER);
465
+ c.handleInput("\x1b[200~foo ");
466
+ c.handleInput(" bar\x1b[201~");
467
+ const lines = c.render(60);
468
+ const editorLine = lines.find((l) => l.includes("\x1b[7m"));
469
+ expect(editorLine).toContain("foo bar");
470
+ expect(editorLine).not.toContain("[200~");
471
+ expect(editorLine).not.toContain("[201~");
472
+ });
440
473
  });
441
474
 
442
475
  // ── 5f. 评论流程(FR-4.6 / FR-11 / AC-6/12/17)─────────
@@ -559,6 +592,29 @@ describe("AskUserComponent — re-entry guard", () => {
559
592
  c.handleInput(ENTER); // ignored
560
593
  expect(result.val).toBeNull();
561
594
  });
595
+
596
+ // FR-12 竞态:signal abort 可能在用户已 submit 后才触发,此时 cancel() 必须是
597
+ // no-op,避免二次调 done(用户已拿到 submit 结果,不应被 abort 覆盖为 null)。
598
+ it("C-FR12 (FR-12): cancel() after submit does not re-invoke done", () => {
599
+ const { c, result } = make([singleQ]);
600
+ c.handleInput(ENTER); // submit → done(result), _resolved=true
601
+ expect(result.val).toBeDefined();
602
+ expect(result.val!.cancelled).toBe(false);
603
+ const submitVal = result.val;
604
+ // 模拟 signal abort 在 submit 后触发 → cancel() 应被 _resolved 守卫拦截
605
+ c.cancel();
606
+ expect(result.val).toBe(submitVal); // 不变 —— done 未被二次调用
607
+ });
608
+
609
+ it("C-FR12b (FR-12): cancel() after cancel does not re-invoke done", () => {
610
+ const { c, result } = make([singleQ]);
611
+ c.handleInput(ESC); // overlay
612
+ c.handleInput(ESC); // cancel → done(null), _resolved=true
613
+ expect(result.val).toBeNull();
614
+ // 再次 cancel(模拟 abort 触发)应 no-op
615
+ c.cancel();
616
+ expect(result.val).toBeNull(); // 仍为 null,未触发异常
617
+ });
562
618
  });
563
619
 
564
620
  // ── 5h. 渲染缓存 ───────────────────────────────────────
@@ -765,7 +821,7 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
765
821
  // 独立 "Your answer" 提示行已消失
766
822
  expect(lines.some((l) => l.includes("Your answer"))).toBe(false);
767
823
  // freeform cursor 出现
768
- expect(lines.some((l) => l.includes("█"))).toBe(true);
824
+ expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(true);
769
825
  // 依然能看见 "Auth" "Search"(普通选项不变)
770
826
  expect(lines.some((l) => l.includes("Auth"))).toBe(true);
771
827
  expect(lines.some((l) => l.includes("Search"))).toBe(true);
@@ -859,4 +915,19 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
859
915
  c.handleInput(ENTER);
860
916
  expect(result.val).toBeUndefined();
861
917
  });
918
+
919
+ it("C-TAB-NOLEAK: Tab in options mode with single question is no-op", () => {
920
+ const c = new AskUserComponent(
921
+ [{ question: "Pick one", options: [{ label: "A" }, { label: "B" }] }],
922
+ mockTui,
923
+ stubTheme,
924
+ () => {},
925
+ );
926
+ c.handleInput(DOWN);
927
+ c.handleInput(TAB);
928
+ // 不应 crash,选项仍可见
929
+ const lines = c.render(60);
930
+ expect(lines.some((l: string) => l.includes("A"))).toBe(true);
931
+ expect(lines.some((l: string) => l.includes("B"))).toBe(true);
932
+ });
862
933
  });
@@ -1,6 +1,7 @@
1
1
  // src/__tests__/fixtures.ts
2
2
  // Shared test fixtures — stub theme, mock TUI, sample questions, key sequences.
3
- import type { Question, ThemeLike } from "../types";
3
+ import { AskUserComponent } from "../component";
4
+ import type { Question, Result, ThemeLike } from "../types";
4
5
 
5
6
  // ── Stub theme (passthrough — no ANSI codes, plain text) ──
6
7
  export const stubTheme: ThemeLike = {
@@ -14,7 +15,6 @@ export const mockTui = { requestRender: (): void => {} };
14
15
 
15
16
  // ── Key sequences (real terminal escape codes that matchesKey recognizes) ──
16
17
  export const ENTER = "\r";
17
- export const SPACE = " ";
18
18
  export const ESC = "\x1b";
19
19
  export const UP = "\x1b[A";
20
20
  export const DOWN = "\x1b[B";
@@ -22,6 +22,14 @@ export const RIGHT = "\x1b[C";
22
22
  export const LEFT = "\x1b[D";
23
23
  export const TAB = "\t";
24
24
  export const BKSP = "\x7f";
25
+ export const BACKSPACE = "\x7f";
26
+ export const HOME = "\x1b[H";
27
+ export const END = "\x1b[F";
28
+ export const INSERT = "\x1b[2~";
29
+ export const PGUP = "\x1b[5~";
30
+ export const PGDN = "\x1b[6~";
31
+ export const F1 = "\x1bOP";
32
+ export const DELETE = "\x1b[3~";
25
33
 
26
34
  // ── Sample questions ──
27
35
  export const singleQ: Question = {
@@ -66,3 +74,49 @@ export const multiQWithComment: Question[] = [
66
74
  { question: "Q1", header: "First", allowComment: true, options: [{ label: "A" }, { label: "B" }] },
67
75
  { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
68
76
  ];
77
+
78
+ // ── Aliases (PAGE_UP/PAGE_DOWN for W1 tests, PGUP/PGDN for W3 tests) ──
79
+ export const PAGE_UP = "\x1b[5~";
80
+ export const PAGE_DOWN = "\x1b[6~";
81
+
82
+ // ── Modifier key sequences (ctrl/alt/shift/super + arrow/special) ──
83
+ // CSI u encoding (Kitty/modifyOtherKeys mode 2): ESC [ <code> ; <mod> ~
84
+ // mod = 1 + bitmask: shift=1, alt=2, ctrl=4, super=8
85
+ export const CTRL_UP = "\x1b[1;5A";
86
+ export const CTRL_DOWN = "\x1b[1;5B";
87
+ export const CTRL_LEFT = "\x1b[1;5D";
88
+ export const CTRL_RIGHT = "\x1b[1;5C";
89
+ export const ALT_UP = "\x1b[1;3A";
90
+ export const ALT_DOWN = "\x1b[1;3B";
91
+ export const ALT_LEFT = "\x1b[1;3D";
92
+ export const ALT_RIGHT = "\x1b[1;3C";
93
+ export const SHIFT_UP = "\x1b[1;2A";
94
+ export const SHIFT_DOWN = "\x1b[1;2B";
95
+ export const SHIFT_LEFT = "\x1b[1;2D";
96
+ export const SHIFT_RIGHT = "\x1b[1;2C";
97
+ export const SUPER_UP = "\x1b[1;9A";
98
+ export const SUPER_DOWN = "\x1b[1;9B";
99
+ export const SUPER_LEFT = "\x1b[1;9D";
100
+ export const SUPER_RIGHT = "\x1b[1;9C";
101
+ export const CTRL_SHIFT_UP = "\x1b[1;6A";
102
+ export const CTRL_SHIFT_DOWN = "\x1b[1;6B";
103
+
104
+ // ── Unknown control sequences (terminal spontaneous, parseKey returns undefined) ──
105
+ export const OSC_BEL = "\x1b]11;rgb:aa/bb/cc\x07"; // OSC, BEL 终止
106
+ export const OSC_ST = "\x1b]11;rgb:aa/bb/cc\x1b\\"; // OSC, ST (ESC\\) 终止
107
+ export const DA1 = "\x1b[?6c"; // DA1 响应
108
+ export const DA2 = "\x1b[>0c"; // DA2 响应
109
+ export const DCS = "\x1bP>|tmux 3.4\x1b\\"; // DCS XTVersion 响应
110
+ export const APC = "\x1b_Gi=31\x1b\\"; // APC Kitty graphics 响应
111
+ export const UNKNOWN_CSI = "\x1b[99~"; // 未知 CSI
112
+ export const UNKNOWN_SS3 = "\x1bOZ"; // 未知 SS3
113
+
114
+ // ── Component helpers ──
115
+
116
+ /** 创建默认单问题组件,返回 { c, result } */
117
+ export function make(questions?: Question[]): { c: AskUserComponent; result: { val: Result | null | undefined } } {
118
+ const qs = questions ?? [{ question: "Pick one", options: [{ label: "A" }, { label: "B" }] }];
119
+ const result = { val: undefined as Result | null | undefined };
120
+ const c = new AskUserComponent(qs, mockTui, stubTheme, (r: Result | null) => { result.val = r; });
121
+ return { c, result };
122
+ }
@@ -208,7 +208,9 @@ describe("execute — signal abort (FR-10 / AC-14)", () => {
208
208
  const ctx = makeCtx();
209
209
  ctx.signal = controller.signal;
210
210
  const result = await tool.execute("id", validSingle, controller.signal, undefined, ctx);
211
- expect(result.content[0].text).toContain("cancelled");
211
+ // step3 是 agent abort(goal 取消/compact/session 切换),文案应明确告知 abort,
212
+ // 区别于 step5 的用户取消(A5)
213
+ expect(result.content[0].text).toContain("abort");
212
214
  expect(result.details.cancelled).toBe(true);
213
215
  });
214
216
 
@@ -477,3 +479,24 @@ describe("factory registration (FR-1)", () => {
477
479
  expect(typeof tool.renderResult).toBe("function");
478
480
  });
479
481
  });
482
+
483
+ // ── FR-3: inline 渲染(不传 overlay)───────────────────────
484
+ describe("execute — inline render (FR-3)", () => {
485
+ it("I-FR3: ui.custom called WITHOUT overlay options (inline, not modal)", async () => {
486
+ const tool = getTool();
487
+ let customArgCount = -1;
488
+ const ctx = {
489
+ hasUI: true,
490
+ signal: undefined as AbortSignal | undefined,
491
+ ui: {
492
+ custom: async (...args: unknown[]): Promise<null> => {
493
+ customArgCount = args.length;
494
+ return null; // cancelled — simplest resolve
495
+ },
496
+ },
497
+ };
498
+ await tool.execute("id", validSingle, undefined, undefined, ctx);
499
+ // FR-3: execute 调用 ui.custom 只传 factory(1 个参数),不传 overlay options
500
+ expect(customArgCount).toBe(1);
501
+ });
502
+ });
@@ -2,13 +2,8 @@
2
2
  import { describe, expect, it } from "vitest";
3
3
 
4
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
- };
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?",
@@ -148,9 +143,10 @@ describe("renderQuestionView — Other editor mode", () => {
148
143
  "my draft",
149
144
  );
150
145
  const t = text(lines);
151
- // Other 行原地变 [ ] <input>█(freeform 模式不依赖 buildEditorBlock 独立编辑块)
152
- expect(t).toContain("my draft");
153
- expect(t).toContain("█");
146
+ // cursorIndex=2 → █ 在 "my" 和 " draft" 之间
147
+ expect(t).toContain("my");
148
+ expect(t).toContain("draft");
149
+ expect(t).toContain("\x1b[7m");
154
150
  // 不再独立 "Your answer" 提示行
155
151
  expect(t).not.toContain("Your answer");
156
152
  // 需求4:Other 在 freeform 态也有编号前缀(与其他选项一致)。
@@ -177,8 +173,11 @@ describe("renderQuestionView — Other editor mode", () => {
177
173
  // 多选 box [ ] + 编号 3. 都在编辑行上
178
174
  expect(t).toContain("[ ]");
179
175
  expect(t).toContain("3. ");
180
- expect(t).toContain("custom");
181
- expect(t).toContain("█");
176
+ // cursorIndex=2 → cursor 在 "s" 上: "cu\x1b[7ms\x1b[27mtom" — "stom" 被 cursor 打断
177
+ expect(t).toContain("cu");
178
+ expect(t).toContain("s");
179
+ expect(t).toContain("tom");
180
+ expect(t).toContain("\x1b[7m");
182
181
  });
183
182
 
184
183
  it("Q-16: Other with saved free-text shows checkmark + preview", () => {
@@ -227,10 +226,13 @@ describe("renderQuestionView — Other editor mode", () => {
227
226
  expect(xCount).toBe(60);
228
227
  expect(lines.some((l) => l.includes("x"))).toBe(true);
229
228
  // 光标仍在末尾出现
230
- expect(t).toContain("█");
231
- // 每个含 x 的行都不超过总宽 width(stubTheme 无 ANSI,长度=可见宽)
229
+ expect(t).toContain("\x1b[7m");
230
+ // 每个含 x 的行可见宽度不超过 width(strip ANSI 后测量)
232
231
  for (const l of lines) {
233
- if (l.includes("x")) expect(l.length).toBeLessThanOrEqual(width);
232
+ if (l.includes("x")) {
233
+ const visible = l.replace(/\x1b\[[0-9;]*m/g, "");
234
+ expect(visible.length).toBeLessThanOrEqual(width);
235
+ }
234
236
  }
235
237
  });
236
238
 
@@ -252,7 +254,7 @@ describe("renderQuestionView — Other editor mode", () => {
252
254
  input,
253
255
  );
254
256
  // 用 █ 定位编辑器行(help 行不含 █),排除 “submit” 含 a 的干扰
255
- const editorLines = lines.filter((l) => l.includes("█"));
257
+ const editorLines = lines.filter((l) => l.includes("\x1b[7m"));
256
258
  expect(editorLines.length).toBe(1);
257
259
  // 全部 60 个 a 都在这一行(未被换行拆分)
258
260
  const aCount = editorLines[0]!.split("").filter((c) => c === "a").length;
@@ -273,7 +275,8 @@ describe("renderQuestionView — Other editor mode", () => {
273
275
  );
274
276
  // 统计含输入内容的行数 = input 渲染行数(应被截到 5 行)
275
277
  // 编号 "3. " 现在是 styled 内容的一部分,首行包裹段 "3." 不含 'y'
276
- const inputLines = lines.filter((l) => l.includes("y") || l.includes("3."));
278
+ // 排除 help 行(含 "Type to add" 的提示行也含 'y')
279
+ const inputLines = lines.filter((l) => (l.includes("y") || l.includes("3.")) && !l.includes("Type to add"));
277
280
  expect(inputLines.length).toBe(5);
278
281
  // 最后一行带省略号(表示还有更多,光标已被省略号取代)
279
282
  expect(inputLines[inputLines.length - 1]).toContain("…");
@@ -374,6 +377,99 @@ describe("renderQuestionView — help line", () => {
374
377
  });
375
378
  });
376
379
 
380
+ // ── Q-32 ~ Q-35: Other 选项对齐(单选/多选 freeform、非 freeform、预览) ──
381
+ describe("renderQuestionView — Other row alignment", () => {
382
+ it("Q-32: 单选 Other freeform 编辑行编号与普通选项对齐", () => {
383
+ // 回归:单选 freeform 占位从 \" \"(2列) 改为 \" \"(1列),编号列与普通单选对齐
384
+ const lines = renderQuestionView(
385
+ singleQ,
386
+ makeState({ mode: "freeform", cursorIndex: 2 }),
387
+ stubTheme,
388
+ 60,
389
+ true,
390
+ "custom",
391
+ );
392
+ const normalLine = lines.find((l) => l.includes("Postgres"))!;
393
+ const otherLine = lines.find((l) => l.includes("\x1b[7m"))!;
394
+ // stubTheme 无 ANSI,indexOf 反映可见列位置。普通选项编号 idx === Other 编号 idx
395
+ expect(normalLine.indexOf("1.")).toBe(otherLine.indexOf("3."));
396
+ });
397
+
398
+ it("Q-33: 多选 Other(无 freeText)编号与普通选项对齐", () => {
399
+ // 回归:多选 Other 非 freeform 标记从 check(1列) 改为 box(3列),编号列与普通多选对齐
400
+ const multiQ: Question = {
401
+ question: "Which features?",
402
+ multiSelect: true,
403
+ options: [{ label: "Auth" }, { label: "Search" }],
404
+ };
405
+ const lines = renderQuestionView(
406
+ multiQ,
407
+ makeState({ cursorIndex: 2 }),
408
+ stubTheme,
409
+ 60,
410
+ true,
411
+ "",
412
+ );
413
+ const normalLine = lines.find((l) => l.includes("Auth"))!;
414
+ const otherLine = lines.find((l) => l.includes("Other") && l.includes("3."))!;
415
+ expect(normalLine.indexOf("1.")).toBe(otherLine.indexOf("3."));
416
+ });
417
+
418
+ it("Q-34: 单选 Other freeText 预览缩进对齐到 label 起始列", () => {
419
+ // 回归:预览 lead 从硬编码 6 列改为动态计算(单选个位 = 7 列)
420
+ const lines = renderQuestionView(
421
+ singleQ,
422
+ makeState({ cursorIndex: 2, freeTextValue: "saved" }),
423
+ stubTheme,
424
+ 60,
425
+ true,
426
+ "",
427
+ );
428
+ const labelLine = lines.find((l) => l.includes("Other") && !l.includes('"'))!;
429
+ const previewLine = lines.find((l) => l.includes('"saved"'))!;
430
+ expect(labelLine.indexOf("Other")).toBe(previewLine.indexOf('"'));
431
+ });
432
+
433
+ it("Q-35: 多选 Other freeText 预览缩进对齐到 label 起始列", () => {
434
+ // 回归:多选 marker=3列,预览 lead 应 = 9 列(个位编号),与 label 对齐
435
+ const multiQ: Question = {
436
+ question: "Which features?",
437
+ multiSelect: true,
438
+ options: [{ label: "Auth" }, { label: "Search" }],
439
+ };
440
+ const lines = renderQuestionView(
441
+ multiQ,
442
+ makeState({ cursorIndex: 2, freeTextValue: "saved" }),
443
+ stubTheme,
444
+ 60,
445
+ true,
446
+ "",
447
+ );
448
+ const labelLine = lines.find((l) => l.includes("Other") && !l.includes('"'))!;
449
+ const previewLine = lines.find((l) => l.includes('"saved"'))!;
450
+ expect(labelLine.indexOf("Other")).toBe(previewLine.indexOf('"'));
451
+ });
452
+
453
+ it("Q-35b: 两位数编号时预览缩进随编号宽度自适应", () => {
454
+ // 10 个选项 + Other = 第 11 项,编号 \"11.\" 占 3 列,预览 lead 应比个位多 1 列
455
+ const bigQ: Question = {
456
+ question: "Q",
457
+ options: Array.from({ length: 10 }, (_, k) => ({ label: `Opt${k + 1}` })),
458
+ };
459
+ const lines = renderQuestionView(
460
+ bigQ,
461
+ makeState({ cursorIndex: 10, freeTextValue: "x" }),
462
+ stubTheme,
463
+ 60,
464
+ true,
465
+ "",
466
+ );
467
+ const labelLine = lines.find((l) => l.includes("Other") && !l.includes('"'))!;
468
+ const previewLine = lines.find((l) => l.includes('"x"'))!;
469
+ expect(labelLine.indexOf("Other")).toBe(previewLine.indexOf('"'));
470
+ });
471
+ });
472
+
377
473
  // ── Q-24 ~ Q-25: 上下文 ─────────────────────────────────
378
474
  describe("renderQuestionView — context", () => {
379
475
  it("Q-24: renders context when present", () => {
@@ -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 => ({
@@ -121,4 +121,17 @@ describe("validateInput", () => {
121
121
  expect(result).toContain("control characters");
122
122
  }
123
123
  });
124
+
125
+ // V-15: header 超过 HEADER_MAX_CHARS(12) 上限被拒绝(A3:补 validate 层校验)
126
+ it("rejects header exceeding HEADER_MAX_CHARS (12)", () => {
127
+ const result = validateInput([q({ header: "This header is too long" })]);
128
+ expect(result).not.toBeNull();
129
+ expect(result).toContain("Header exceeds");
130
+ expect(result).toContain(`${HEADER_MAX_CHARS}`);
131
+ });
132
+
133
+ // V-16: header 恰好 12 字符(边界值,合法)
134
+ it("accepts header at exactly HEADER_MAX_CHARS (12)", () => {
135
+ expect(validateInput([q({ header: "123456789012" })])).toBeNull();
136
+ });
124
137
  });