@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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/__tests__/w2-draft-hint.test.ts
|
|
2
|
+
// W2: draftText migration (#2) + handleInput split (#3) + hint line (#4)
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { AskUserComponent } from "../component";
|
|
6
|
+
import type { Question, Result } from "../types";
|
|
7
|
+
import {
|
|
8
|
+
DOWN,
|
|
9
|
+
ENTER,
|
|
10
|
+
ESC,
|
|
11
|
+
LEFT,
|
|
12
|
+
mockTui,
|
|
13
|
+
multiQ,
|
|
14
|
+
RIGHT,
|
|
15
|
+
singleQ,
|
|
16
|
+
singleQWithComment,
|
|
17
|
+
stubTheme,
|
|
18
|
+
UP,
|
|
19
|
+
} from "./fixtures";
|
|
20
|
+
|
|
21
|
+
const make = (
|
|
22
|
+
questions: Question[],
|
|
23
|
+
): { c: AskUserComponent; result: { val: Result | null | undefined } } => {
|
|
24
|
+
const result = { val: undefined as Result | null | undefined };
|
|
25
|
+
const c = new AskUserComponent(questions, mockTui, stubTheme, (r) => (result.val = r));
|
|
26
|
+
return { c, result };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ── draftText migration: per-question draft persistence ──
|
|
30
|
+
describe("W2 — draftText migration", () => {
|
|
31
|
+
it("C-DRAFT-1: freeform draft persists when switching tabs away and back", () => {
|
|
32
|
+
const { c } = make(multiQ);
|
|
33
|
+
// Q1: navigate to Other (index 2), open freeform, type "abc"
|
|
34
|
+
c.handleInput(DOWN); // 0→1
|
|
35
|
+
c.handleInput(DOWN); // 1→2 (Other)
|
|
36
|
+
c.handleInput(ENTER);
|
|
37
|
+
c.handleInput("abc");
|
|
38
|
+
c.handleInput(ESC); // back to options
|
|
39
|
+
// Switch to Q2 and back to Q1
|
|
40
|
+
c.handleInput(RIGHT);
|
|
41
|
+
c.handleInput(LEFT);
|
|
42
|
+
// Re-enter freeform
|
|
43
|
+
c.handleInput(DOWN);
|
|
44
|
+
c.handleInput(DOWN);
|
|
45
|
+
c.handleInput(ENTER);
|
|
46
|
+
const lines = c.render(80);
|
|
47
|
+
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
48
|
+
expect(editorLine).toBeDefined();
|
|
49
|
+
expect(editorLine).toContain("abc");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("C-DRAFT-2: drafts on different questions are independent", () => {
|
|
53
|
+
const { c } = make(multiQ);
|
|
54
|
+
// Q1: type "aaa"
|
|
55
|
+
c.handleInput(DOWN);
|
|
56
|
+
c.handleInput(DOWN);
|
|
57
|
+
c.handleInput(ENTER);
|
|
58
|
+
c.handleInput("aaa");
|
|
59
|
+
c.handleInput(ESC);
|
|
60
|
+
// Navigate to Q3
|
|
61
|
+
c.handleInput(RIGHT);
|
|
62
|
+
c.handleInput(RIGHT);
|
|
63
|
+
// Q3: type "ccc"
|
|
64
|
+
c.handleInput(DOWN);
|
|
65
|
+
c.handleInput(DOWN);
|
|
66
|
+
c.handleInput(ENTER);
|
|
67
|
+
c.handleInput("ccc");
|
|
68
|
+
c.handleInput(ESC);
|
|
69
|
+
// Go back to Q1
|
|
70
|
+
c.handleInput(LEFT);
|
|
71
|
+
c.handleInput(LEFT);
|
|
72
|
+
// Re-enter freeform on Q1
|
|
73
|
+
c.handleInput(DOWN);
|
|
74
|
+
c.handleInput(DOWN);
|
|
75
|
+
c.handleInput(ENTER);
|
|
76
|
+
const lines1 = c.render(80);
|
|
77
|
+
const editorLine1 = lines1.find((l) => l.includes("\x1b[7m"));
|
|
78
|
+
expect(editorLine1).toContain("aaa");
|
|
79
|
+
expect(editorLine1).not.toContain("ccc");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("C-BC4C: comment flow submits comment with answer", () => {
|
|
83
|
+
const { c, result } = make([singleQWithComment]);
|
|
84
|
+
c.handleInput(ENTER); // select A → comment mode
|
|
85
|
+
c.handleInput("my note");
|
|
86
|
+
c.handleInput(ENTER); // submit
|
|
87
|
+
expect(result.val).toBeDefined();
|
|
88
|
+
expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — my note");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── hint line: append-only UX hint ──
|
|
93
|
+
describe("W2 — hint line", () => {
|
|
94
|
+
it("C-HINT-1: freeform editor hint contains all expected hints", () => {
|
|
95
|
+
const { c } = make([singleQ]);
|
|
96
|
+
c.handleInput(DOWN);
|
|
97
|
+
c.handleInput(DOWN);
|
|
98
|
+
c.handleInput(ENTER);
|
|
99
|
+
const lines = c.render(80);
|
|
100
|
+
const hintLine = lines.find((l: string) => l.includes("move") && l.includes("Backspace deletes"));
|
|
101
|
+
expect(hintLine).toBeDefined();
|
|
102
|
+
expect(hintLine).toContain("Enter submit");
|
|
103
|
+
expect(hintLine).toContain("Esc back");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("C-HINT-2: comment editor hint contains all expected hints", () => {
|
|
107
|
+
const { c } = make([singleQWithComment]);
|
|
108
|
+
c.handleInput(ENTER);
|
|
109
|
+
const lines = c.render(80);
|
|
110
|
+
const hintLine = lines.find((l: string) => l.includes("move") && l.includes("Backspace deletes"));
|
|
111
|
+
expect(hintLine).toBeDefined();
|
|
112
|
+
expect(hintLine).toContain("Enter submit");
|
|
113
|
+
expect(hintLine).toContain("Esc back");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── freeDraft 隔离:丢弃的 freeform 草稿不污染答案、不触发 auto-confirm ──
|
|
118
|
+
describe("C-FREEDRAFT: discarded draft isolation", () => {
|
|
119
|
+
it("C-FREEDRAFT-1: discarded freeform draft blocks submit (Q1 stays unconfirmed)", () => {
|
|
120
|
+
const { c, result } = make(multiQ);
|
|
121
|
+
// Q1: 打开 freeform,输入草稿 "abc",ESC 丢弃(存入 freeDraft,但不 confirmed)
|
|
122
|
+
c.handleInput(DOWN); // 0→1
|
|
123
|
+
c.handleInput(DOWN); // 1→2 (Other)
|
|
124
|
+
c.handleInput(ENTER); // 打开 freeform
|
|
125
|
+
c.handleInput("abc");
|
|
126
|
+
c.handleInput(ESC); // freeDraft="abc",回到 options,Q1 仍未答
|
|
127
|
+
// 跳到 Submit tab 尝试提交
|
|
128
|
+
c.handleInput(RIGHT); // → Q2
|
|
129
|
+
c.handleInput(RIGHT); // → Q3
|
|
130
|
+
c.handleInput(RIGHT); // → Submit tab
|
|
131
|
+
c.handleInput(ENTER); // allConfirmed=false → 提交被阻塞
|
|
132
|
+
expect(result.val).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("C-FREEDRAFT-2: discarded draft does not appear in submitted answers", () => {
|
|
136
|
+
const { c, result } = make(multiQ);
|
|
137
|
+
// Q1: freeform 草稿 "abc" → ESC 丢弃 → 改选选项 A
|
|
138
|
+
c.handleInput(DOWN);
|
|
139
|
+
c.handleInput(DOWN);
|
|
140
|
+
c.handleInput(ENTER); // freeform
|
|
141
|
+
c.handleInput("abc");
|
|
142
|
+
c.handleInput(ESC); // 丢弃草稿,光标回到 Other
|
|
143
|
+
c.handleInput(UP); // 2→1
|
|
144
|
+
c.handleInput(UP); // 1→0 (A)
|
|
145
|
+
c.handleInput(ENTER); // 选 A → confirmed → advance Q2
|
|
146
|
+
// Q2 (multiSelect): 选 X → confirmed → advance Q3
|
|
147
|
+
c.handleInput(ENTER);
|
|
148
|
+
// Q3: 选 M → confirmed → advance Submit tab
|
|
149
|
+
c.handleInput(ENTER);
|
|
150
|
+
// Submit
|
|
151
|
+
c.handleInput(ENTER);
|
|
152
|
+
expect(result.val).toBeDefined();
|
|
153
|
+
expect(result.val!.answers["Q1"]).toBe("A");
|
|
154
|
+
// 丢弃的草稿 "abc" 不应出现在任何答案中
|
|
155
|
+
expect(JSON.stringify(result.val!.answers)).not.toContain("abc");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/__tests__/w3-regression.test.ts
|
|
2
|
+
// W3: Forward regression — freeform/comment/bksp edge cases.
|
|
3
|
+
// No-op keymap coverage moved to component-keymap.test.ts (deduplicated).
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { AskUserComponent } from "../component";
|
|
7
|
+
import type { Question, Result } from "../types";
|
|
8
|
+
import {
|
|
9
|
+
BACKSPACE,
|
|
10
|
+
DOWN,
|
|
11
|
+
ENTER,
|
|
12
|
+
ESC,
|
|
13
|
+
HOME,
|
|
14
|
+
LEFT,
|
|
15
|
+
mockTui,
|
|
16
|
+
multiQ,
|
|
17
|
+
multiQWithComment,
|
|
18
|
+
RIGHT,
|
|
19
|
+
singleQ,
|
|
20
|
+
stubTheme,
|
|
21
|
+
} from "./fixtures";
|
|
22
|
+
|
|
23
|
+
const make = (
|
|
24
|
+
questions: Question[] = [singleQ],
|
|
25
|
+
): { c: AskUserComponent; result: { val: Result | null | undefined } } => {
|
|
26
|
+
const result = { val: undefined as Result | null | undefined };
|
|
27
|
+
const c = new AskUserComponent(questions, mockTui, stubTheme, (r) => (result.val = r));
|
|
28
|
+
return { c, result };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Helper: open freeform editor on singleQ (navigate to Other → Enter) */
|
|
32
|
+
function openFreeform(c: AskUserComponent): void {
|
|
33
|
+
c.handleInput(DOWN); // 0→1
|
|
34
|
+
c.handleInput(DOWN); // 1→2 (Other)
|
|
35
|
+
c.handleInput(ENTER); // open freeform
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── C-BC4B: freeform Enter clears selectedIndex (BC-4b regression) ──
|
|
39
|
+
describe("W3 — freeform Enter clears selectedIndex (C-BC4B)", () => {
|
|
40
|
+
it("C-BC4B: freeform text overrides selected option in result", () => {
|
|
41
|
+
const { c, result } = make([singleQ]);
|
|
42
|
+
// Navigate to Other (index 2) and open freeform
|
|
43
|
+
c.handleInput(DOWN); // 0→1
|
|
44
|
+
c.handleInput(DOWN); // 1→2 (Other)
|
|
45
|
+
c.handleInput(ENTER); // open freeform
|
|
46
|
+
c.handleInput("custom answer");
|
|
47
|
+
c.handleInput(ENTER); // confirm freeform → afterConfirm → submit
|
|
48
|
+
expect(result.val).toBeDefined();
|
|
49
|
+
// The answer should be the freeform text
|
|
50
|
+
expect(result.val!.answers["Which DB?"]).toBe("custom answer");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("C-BC4B-SELECT: freeform on same question clears selectedIndex", () => {
|
|
54
|
+
const { c, result } = make(multiQ);
|
|
55
|
+
// Q1: stay on cursorIndex=0 (A), open freeform via Other
|
|
56
|
+
c.handleInput(DOWN); // 0→1
|
|
57
|
+
c.handleInput(DOWN); // 1→2 (Other)
|
|
58
|
+
c.handleInput(ENTER); // open freeform
|
|
59
|
+
c.handleInput("override");
|
|
60
|
+
c.handleInput(ENTER); // confirm freeform → afterConfirm → advance to Q2
|
|
61
|
+
// Q2: select X
|
|
62
|
+
c.handleInput(ENTER); // select X
|
|
63
|
+
c.handleInput(ENTER); // confirm → advance to Q3
|
|
64
|
+
// Q3: select M
|
|
65
|
+
c.handleInput(ENTER); // select M
|
|
66
|
+
c.handleInput(ENTER); // confirm → advance to submit
|
|
67
|
+
// Submit
|
|
68
|
+
c.handleInput(ENTER);
|
|
69
|
+
expect(result.val).toBeDefined();
|
|
70
|
+
// Q1 answer should be the freeform text only
|
|
71
|
+
expect(result.val!.answers["Q1"]).toBe("override");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── C-BC4C: comment edge cases ──
|
|
76
|
+
// C-BC4C-REEDIT (initial comment submit) removed — duplicate of w2-draft-hint C-BC4C.
|
|
77
|
+
describe("W3 — comment re-edit (C-BC4C-CLEAR)", () => {
|
|
78
|
+
it("C-BC4C-CLEAR: Esc in comment mode skips comment, keeps existing commentValue", () => {
|
|
79
|
+
const { c, result } = make(multiQWithComment);
|
|
80
|
+
// Q1: select A → comment mode
|
|
81
|
+
c.handleInput(ENTER);
|
|
82
|
+
c.handleInput("my note");
|
|
83
|
+
c.handleInput(ENTER); // submit comment → advance to Q2
|
|
84
|
+
// Q2: select X
|
|
85
|
+
c.handleInput(ENTER);
|
|
86
|
+
c.handleInput(ENTER); // confirm Q2 → advance to submit tab
|
|
87
|
+
// Go back to Q1
|
|
88
|
+
c.handleInput(LEFT);
|
|
89
|
+
// Q1 is already confirmed, re-select A to trigger comment again
|
|
90
|
+
c.handleInput(ENTER); // re-select A → afterConfirm → comment mode
|
|
91
|
+
c.handleInput(ESC); // skip comment
|
|
92
|
+
// Submit
|
|
93
|
+
c.handleInput(RIGHT);
|
|
94
|
+
c.handleInput(RIGHT); // navigate to submit tab
|
|
95
|
+
c.handleInput(ENTER);
|
|
96
|
+
expect(result.val).toBeDefined();
|
|
97
|
+
// Comment should still be "my note" (Esc preserved commentValue)
|
|
98
|
+
expect(result.val!.answers["Q1"]).toBe("A — my note");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── C-BKSP-EDGE: backspace at cursorIndex=0 ──
|
|
103
|
+
|
|
104
|
+
describe("W3 — backspace at cursorIndex=0 (C-BKSP-EDGE)", () => {
|
|
105
|
+
it("C-BKSP-EDGE: backspace at cursorIndex=0 is no-op on empty text", () => {
|
|
106
|
+
const { c } = make();
|
|
107
|
+
// 打开 freeform 编辑器
|
|
108
|
+
openFreeform(c);
|
|
109
|
+
c.handleInput(BACKSPACE); // 空文本 + cursorIndex=0
|
|
110
|
+
// 不应 crash
|
|
111
|
+
const lines = c.render(60);
|
|
112
|
+
// 编辑器仍然可见(反色空格光标)
|
|
113
|
+
expect(lines.some((l: string) => l.includes("\x1b[7m"))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("C-BKSP-EDGE-2: backspace at cursorIndex=0 on non-empty text is no-op", () => {
|
|
117
|
+
const { c } = make();
|
|
118
|
+
openFreeform(c);
|
|
119
|
+
c.handleInput("abc");
|
|
120
|
+
c.handleInput(HOME); // 移到开头
|
|
121
|
+
c.handleInput(BACKSPACE); // cursorIndex=0,不应删除
|
|
122
|
+
const lines = c.render(60);
|
|
123
|
+
const editorLine = lines.find((l: string) => l.includes("\x1b[7m"));
|
|
124
|
+
// 文本完整保留(去除 ANSI 转义码后检查)
|
|
125
|
+
const stripped = editorLine!.replace(/\x1b\[[0-9;]*m/g, "");
|
|
126
|
+
expect(stripped).toContain("abc");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/answer-format.ts
|
|
2
|
+
// 答案文本格式的唯一权威模块。
|
|
3
|
+
// TUI 路径(submit-view.ts:getAnswerText)和 RPC 路径(index.ts:protoAnswersToResult)
|
|
4
|
+
// 都调 formatAnswer 产出 "label1, label2 — comment" 格式,确保两条路径一致。
|
|
5
|
+
// renderExpandedOptions(index.ts)调 parseAnswerParts 精确反解析选中项。
|
|
6
|
+
import { ANSWER_COMMENT_SEPARATOR } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 把答案各部分拼装为最终文本格式:"part1, part2 — comment"。
|
|
10
|
+
* - parts 为空 → 返回 null(未答)
|
|
11
|
+
* - comment 有值 → 追加 ANSWER_COMMENT_SEPARATOR + comment
|
|
12
|
+
*/
|
|
13
|
+
export function formatAnswer(parts: string[], comment?: string | null): string | null {
|
|
14
|
+
if (parts.length === 0) return null;
|
|
15
|
+
const base = parts.join(", ");
|
|
16
|
+
return comment ? `${base}${ANSWER_COMMENT_SEPARATOR}${comment}` : base;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 从最终答案文本中精确解析出选中的 labels(不依赖子串匹配)。
|
|
21
|
+
* 用于 renderExpandedOptions 反向判定哪些选项被选中。
|
|
22
|
+
*
|
|
23
|
+
* @param answer 最终答案文本(formatAnswer 产出)
|
|
24
|
+
* @param labels 候选 label 列表(q.options 的 label),精确匹配
|
|
25
|
+
* @returns selected=命中的 labels(按 answer 中出现顺序),comment=评论文本(如有)
|
|
26
|
+
*/
|
|
27
|
+
export function parseAnswerParts(
|
|
28
|
+
answer: string,
|
|
29
|
+
labels: string[],
|
|
30
|
+
): { selected: string[]; comment?: string } {
|
|
31
|
+
// 先提取 comment(ANSWER_COMMENT_SEPARATOR 之后的部分)
|
|
32
|
+
let body = answer;
|
|
33
|
+
let comment: string | undefined;
|
|
34
|
+
const sepIdx = answer.indexOf(ANSWER_COMMENT_SEPARATOR);
|
|
35
|
+
if (sepIdx >= 0) {
|
|
36
|
+
body = answer.slice(0, sepIdx);
|
|
37
|
+
comment = answer.slice(sepIdx + ANSWER_COMMENT_SEPARATOR.length).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// body 形如 "label1, label2" → 精确匹配候选 label
|
|
41
|
+
const labelSet = new Set(labels);
|
|
42
|
+
const tokens = body.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
|
43
|
+
const selected: string[] = [];
|
|
44
|
+
// 剩余 tokens 不匹配任何 label → 是 Other 自由文本(不返回,调用方自行处理)
|
|
45
|
+
for (const token of tokens) {
|
|
46
|
+
if (labelSet.has(token)) {
|
|
47
|
+
selected.push(token);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { selected, comment };
|
|
51
|
+
}
|