@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.
@@ -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
+ }