@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.
- package/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +3 -2
- package/src/__tests__/component-keymap.test.ts +553 -0
- package/src/__tests__/component.test.ts +81 -10
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +24 -1
- package/src/__tests__/question-view.test.ts +113 -17
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +14 -1
- package/src/__tests__/w2-draft-hint.test.ts +246 -0
- package/src/__tests__/w3-regression.test.ts +310 -0
- package/src/component.ts +160 -94
- package/src/index.ts +6 -4
- package/src/question-view.ts +80 -41
- package/src/submit-view.ts +17 -7
- package/src/types.ts +24 -2
- package/src/validate.ts +9 -1
|
@@ -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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
152
|
-
expect(t).toContain("my
|
|
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
|
-
|
|
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
|
|
229
|
+
expect(t).toContain("\x1b[7m");
|
|
230
|
+
// 每个含 x 的行可见宽度不超过 width(strip ANSI 后测量)
|
|
232
231
|
for (const l of lines) {
|
|
233
|
-
if (l.includes("x"))
|
|
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
|
-
|
|
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
|
|
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
|
});
|