@zhushanwen/pi-ask-user 6.0.1 → 7.0.2

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,275 +0,0 @@
1
- // src/__tests__/answer-format.test.ts
2
- //
3
- // answer-format.ts 独立单元测试。
4
- // 覆盖审查 S5 发现的覆盖盲区:
5
- // - parseAnswerParts 子串误匹配("A" 不应命中 "AB")
6
- // - formatAnswer 空 parts → null
7
- // - comment 分隔符边界
8
-
9
- import { describe, expect, it } from "vitest";
10
-
11
- import { formatAnswer, parseAnswerParts } from "../answer-format.js";
12
- import { ANSWER_COMMENT_SEPARATOR } from "../types.js";
13
-
14
- describe("formatAnswer", () => {
15
- it("returns null for empty parts (unanswered)", () => {
16
- expect(formatAnswer([])).toBeNull();
17
- });
18
-
19
- it("comment-only: empty parts + comment → separator-prefixed comment (MF-1)", () => {
20
- // parts 空但有 comment = allowComment 的「无选项适用但想说明原因」场景,
21
- // 不能返回 null(会把评论一起丢弃);以 ANSWER_COMMENT_SEPARATOR 开头保持往返可解析
22
- expect(formatAnswer([], "some comment")).toBe(`${ANSWER_COMMENT_SEPARATOR}some comment`);
23
- });
24
-
25
- it("joins single part without separator", () => {
26
- expect(formatAnswer(["yes"])).toBe("yes");
27
- });
28
-
29
- it("joins multiple parts with ', '", () => {
30
- expect(formatAnswer(["A", "B", "C"])).toBe("A, B, C");
31
- });
32
-
33
- it("appends comment with ANSWER_COMMENT_SEPARATOR", () => {
34
- const result = formatAnswer(["A", "B"], "my comment");
35
- expect(result).toBe(`A, B${ANSWER_COMMENT_SEPARATOR}my comment`);
36
- });
37
-
38
- it("handles null comment (no separator appended)", () => {
39
- expect(formatAnswer(["A"], null)).toBe("A");
40
- });
41
- });
42
-
43
- describe("parseAnswerParts", () => {
44
- it("extracts selected labels by exact match", () => {
45
- const labels = ["yes", "no", "maybe"];
46
- const result = parseAnswerParts("yes, no", labels);
47
- expect(result.selected).toEqual(["yes", "no"]);
48
- expect(result.comment).toBeUndefined();
49
- });
50
-
51
- // S5 核心:防子串误匹配——"A" 不应命中 label "AB"
52
- it("does NOT match substring labels (A vs AB)", () => {
53
- const labels = ["A", "AB", "ABC"];
54
- // 答案 "A, AB" 应精确匹配两个 label,而非 "A" 匹配三次
55
- const result = parseAnswerParts("A, AB", labels);
56
- expect(result.selected).toEqual(["A", "AB"]);
57
- });
58
-
59
- it("does NOT match 'A' when only 'AB' is in answer", () => {
60
- const labels = ["A", "AB"];
61
- // 答案 "AB" 只应命中 "AB",不应命中 "A"
62
- const result = parseAnswerParts("AB", labels);
63
- expect(result.selected).toEqual(["AB"]);
64
- });
65
-
66
- it("preserves order of appearance in answer (not label order)", () => {
67
- const labels = ["A", "B", "C"];
68
- // 用户选择顺序可能与 options 定义顺序不同
69
- const result = parseAnswerParts("C, A", labels);
70
- expect(result.selected).toEqual(["C", "A"]);
71
- });
72
-
73
- it("extracts comment after ANSWER_COMMENT_SEPARATOR", () => {
74
- const labels = ["yes"];
75
- const answer = `yes${ANSWER_COMMENT_SEPARATOR}because reasons`;
76
- const result = parseAnswerParts(answer, labels);
77
- expect(result.selected).toEqual(["yes"]);
78
- expect(result.comment).toBe("because reasons");
79
- });
80
-
81
- it("handles full-width comma (,) as separator", () => {
82
- const labels = ["A", "B"];
83
- const result = parseAnswerParts("A,B", labels);
84
- expect(result.selected).toEqual(["A", "B"]);
85
- });
86
-
87
- it("returns non-matching tokens as neither selected nor comment (Other free text)", () => {
88
- const labels = ["yes", "no"];
89
- // "custom text" 不匹配任何 label → 是 Other 自由文本
90
- const result = parseAnswerParts("custom text", labels);
91
- expect(result.selected).toEqual([]);
92
- expect(result.comment).toBeUndefined();
93
- });
94
-
95
- it("handles empty answer string", () => {
96
- const result = parseAnswerParts("", ["A", "B"]);
97
- expect(result.selected).toEqual([]);
98
- expect(result.comment).toBeUndefined();
99
- });
100
-
101
- it("handles answer with only comment (no selected labels)", () => {
102
- const labels = ["A"];
103
- const answer = `${ANSWER_COMMENT_SEPARATOR}just a comment`;
104
- const result = parseAnswerParts(answer, labels);
105
- expect(result.selected).toEqual([]);
106
- expect(result.comment).toBe("just a comment");
107
- });
108
-
109
- // MF-2 回归:label 自身含 ANSWER_COMMENT_SEPARATOR(LLM 生成 label 常见形态)时,
110
- // 首个分隔符切分会把 label 拦腰截断 → token 无法精确匹配 → 选中值静默丢失
111
- describe("MF-2: label contains ANSWER_COMMENT_SEPARATOR", () => {
112
- const labels = ["Postgres — prod", "SQLite"];
113
-
114
- it("whole answer exactly matches separator-containing label → selected, no comment", () => {
115
- const result = parseAnswerParts("Postgres — prod", labels);
116
- expect(result.selected).toEqual(["Postgres — prod"]);
117
- expect(result.comment).toBeUndefined();
118
- });
119
-
120
- it("selection + comment → later separator becomes split point", () => {
121
- const result = parseAnswerParts("Postgres — prod — fast", labels);
122
- expect(result.selected).toEqual(["Postgres — prod"]);
123
- expect(result.comment).toBe("fast");
124
- });
125
-
126
- it("two selections where first label contains separator", () => {
127
- const result = parseAnswerParts("Postgres — prod, SQLite — my note", labels);
128
- expect(result.selected).toEqual(["Postgres — prod", "SQLite"]);
129
- expect(result.comment).toBe("my note");
130
- });
131
-
132
- it("comment containing separator keeps full remainder", () => {
133
- const result = parseAnswerParts("Postgres — prod — fast — very fast", labels);
134
- expect(result.selected).toEqual(["Postgres — prod"]);
135
- expect(result.comment).toBe("fast — very fast");
136
- });
137
- });
138
-
139
- it("returns non-matching body tokens as otherTokens (Other free text)", () => {
140
- const result = parseAnswerParts("custom text", ["yes", "no"]);
141
- expect(result.selected).toEqual([]);
142
- expect(result.otherTokens).toEqual(["custom text"]);
143
- });
144
-
145
- it("otherTokens excludes matched labels and comment", () => {
146
- const result = parseAnswerParts("A, custom text — note", ["A", "B"]);
147
- expect(result.selected).toEqual(["A"]);
148
- expect(result.otherTokens).toEqual(["custom text"]);
149
- expect(result.comment).toBe("note");
150
- });
151
-
152
- // MF-6 回归:两个 label 都含分隔符时("Postgres — prod, MySQL"),分隔符扫描会把
153
- // 首个分隔符当切分点 → body "Postgres" 无匹配 → 剩余 "prod, MySQL" 被误判为 comment,
154
- // 两个选中项全部静默丢失。纯多选分支(所有逗号 token 均为 label)在扫描前短路。
155
- describe("MF-6: pure multi-select where every label contains separator", () => {
156
- const labels = ["Postgres — prod", "MySQL"];
157
-
158
- it("all comma tokens are labels → selected all, no comment", () => {
159
- const result = parseAnswerParts("Postgres — prod, MySQL", labels);
160
- expect(result.selected).toEqual(["Postgres — prod", "MySQL"]);
161
- expect(result.comment).toBeUndefined();
162
- expect(result.otherTokens).toEqual([]);
163
- });
164
-
165
- it("comment form not broken: non-label token 'MySQL — note' skips the branch", () => {
166
- // "MySQL — note" 非 label → 不进纯多选分支;分隔符扫描取「body 整串可解析为
167
- // 选中项」的最靠后切分("Postgres — prod, MySQL" 两个 token 均 label)→
168
- // 选中两项 + comment "note"(与 MF-2 多选 + comment 语义一致)
169
- const result = parseAnswerParts("Postgres — prod, MySQL — note", labels);
170
- expect(result.selected).toEqual(["Postgres — prod", "MySQL"]);
171
- expect(result.comment).toBe("note");
172
- });
173
-
174
- it("three selections all containing separators", () => {
175
- const labels3 = ["Postgres — prod", "MySQL — prod", "SQLite — prod"];
176
- const result = parseAnswerParts("Postgres — prod, MySQL — prod, SQLite — prod", labels3);
177
- expect(result.selected).toEqual(["Postgres — prod", "MySQL — prod", "SQLite — prod"]);
178
- expect(result.comment).toBeUndefined();
179
- });
180
- });
181
-
182
- // MF-7 回归:重叠前缀 label(短 label 是长 label 的前缀)时,分隔符扫描取「首个 body
183
- // 含 label token」的切分会停在短 label 处,把长 label 的剩余部分误判为 comment。
184
- // 修复:优先「body 整串精确命中 label」的切分点,多个命中取最靠后者(最长匹配)。
185
- describe("MF-7: overlapping prefix labels prefer whole-body label split", () => {
186
- const labels = ["Postgres", "Postgres — prod"];
187
-
188
- it("longest whole-body label wins → 'Postgres — prod' + comment 'x'", () => {
189
- const result = parseAnswerParts("Postgres — prod — x", labels);
190
- expect(result.selected).toEqual(["Postgres — prod"]);
191
- expect(result.comment).toBe("x");
192
- });
193
-
194
- it("short label + comment containing long-label prefix (inherent ambiguity, documented tradeoff)", () => {
195
- // 长 label 不在候选时(labels 只有 "Postgres"),同一文本解析为
196
- // 选短 label + comment "prod — x"——文本层面唯一可解析的归属
197
- const result = parseAnswerParts("Postgres — prod — x", ["Postgres"]);
198
- expect(result.selected).toEqual(["Postgres"]);
199
- expect(result.comment).toBe("prod — x");
200
- });
201
-
202
- it("multi-select + comment with prefix labels → split after all labels", () => {
203
- // "Postgres — prod, SQLite" 两个 token 均 label → 切分点取最后分隔符,
204
- // body 完整保留两项选中,comment "note"(首个整串命中 "Postgres" 不截断)
205
- const labels3 = ["Postgres", "Postgres — prod", "SQLite"];
206
- const result = parseAnswerParts("Postgres — prod, SQLite — note", labels3);
207
- expect(result.selected).toEqual(["Postgres — prod", "SQLite"]);
208
- expect(result.comment).toBe("note");
209
- });
210
- });
211
-
212
- // MF-2(R3 回归):label 含分隔符 + Other 自由文本 + comment 的混合形态。
213
- // R3 的 all-label 单谓词扫描落空 → 回退首分隔符 → 选中项静默丢失;第二级
214
- // 「body 含 ≥1 label token」首个命中落在真正分隔选中/Other 与 comment 的分隔符上。
215
- describe("MF-2: label with separator + Other free text + comment", () => {
216
- const labels = ["Postgres — prod"];
217
-
218
- it("mixed body (label + Other) + comment → label selected, Other kept, comment at later separator", () => {
219
- const result = parseAnswerParts("Postgres — prod, custom text — note", labels);
220
- expect(result.selected).toEqual(["Postgres — prod"]);
221
- expect(result.otherTokens).toEqual(["custom text"]);
222
- expect(result.comment).toBe("note");
223
- });
224
-
225
- it("comment containing separator keeps full remainder (no over-split at second separator)", () => {
226
- // "A, B — note — x":第一级 all-label body 在第一个分隔符命中,短路第二级
227
- // ——若走「≥1 label token 取最后」会在第二个分隔符过切(comment 错位)
228
- const result = parseAnswerParts("A, B — note — x", ["A", "B"]);
229
- expect(result.selected).toEqual(["A", "B"]);
230
- expect(result.comment).toBe("note — x");
231
- });
232
- });
233
-
234
- // MF-3(pre-existing):label 含分隔符 + Other 自由文本、无 comment 形态。
235
- // 两级扫描均无命中(唯一候选 body "Postgres" 不含 label token)→ 整串含 label
236
- // token 时不切分,整串作 body(选中 + Other)——首分隔符切分会把 label 拦腰截断
237
- // 产生空选中。S-17 冻结边界:整串无任何 label token 时仍走首分隔符切分。
238
- describe("MF-3: label with separator + Other free text, no comment", () => {
239
- const labels = ["Postgres — prod"];
240
-
241
- it("no comment variant → whole string as body, label selected + Other kept", () => {
242
- const result = parseAnswerParts("Postgres — prod, custom text", labels);
243
- expect(result.selected).toEqual(["Postgres — prod"]);
244
- expect(result.otherTokens).toEqual(["custom text"]);
245
- expect(result.comment).toBeUndefined();
246
- });
247
-
248
- it("S-17 freeze intact: whole string without any label token still first-separator split", () => {
249
- // "custom — text" 整串无 label token → 不切分回退不触发,保持首分隔符切分
250
- const result = parseAnswerParts("custom — text", labels);
251
- expect(result.selected).toEqual([]);
252
- expect(result.otherTokens).toEqual(["custom"]);
253
- expect(result.comment).toBe("text");
254
- });
255
- });
256
-
257
- // S-17 行为固化:Other 自由文本自身含 " — "(如 "custom — text")与
258
- // 「选中/Other + comment」形态在纯解析层面固有歧义,保持首个分隔符切分行为
259
- // (body "custom" + comment "text"),不做解析层区分(见 answer-format.ts 注释)。
260
- describe("S-17: Other free text containing separator (behavior freeze)", () => {
261
- it("separator inside Other text → first-separator split, documented ambiguity", () => {
262
- const result = parseAnswerParts("custom — text", ["yes", "no"]);
263
- expect(result.selected).toEqual([]);
264
- expect(result.otherTokens).toEqual(["custom"]);
265
- expect(result.comment).toBe("text");
266
- });
267
-
268
- it("Other text with separator + matched label before it", () => {
269
- const result = parseAnswerParts("yes, custom — text", ["yes", "no"]);
270
- expect(result.selected).toEqual(["yes"]);
271
- expect(result.otherTokens).toEqual(["custom"]);
272
- expect(result.comment).toBe("text");
273
- });
274
- });
275
- });
@@ -1,127 +0,0 @@
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 为空 + comment 有值 → comment-only 答案(allowComment 的「无选项适用但想说明原因」
11
- * 场景):以 ANSWER_COMMENT_SEPARATOR 开头,保持 parseAnswerParts 往返可解析
12
- * - parts 与 comment 都为空 → 返回 null(未答)
13
- * - comment 有值 → 追加 ANSWER_COMMENT_SEPARATOR + comment
14
- */
15
- export function formatAnswer(parts: string[], comment?: string | null): string | null {
16
- if (parts.length === 0) return comment ? `${ANSWER_COMMENT_SEPARATOR}${comment}` : null;
17
- const base = parts.join(", ");
18
- return comment ? `${base}${ANSWER_COMMENT_SEPARATOR}${comment}` : base;
19
- }
20
-
21
- /**
22
- * 从最终答案文本中精确解析出选中的 labels(不依赖子串匹配)。
23
- * 用于 renderExpandedOptions 反向判定哪些选项被选中。
24
- *
25
- * @param answer 最终答案文本(formatAnswer 产出)
26
- * @param labels 候选 label 列表(q.options 的 label),精确匹配
27
- * @returns selected=命中的 labels(按 answer 中出现顺序),comment=评论文本(如有)
28
- */
29
- export function parseAnswerParts(
30
- answer: string,
31
- labels: string[],
32
- ): { selected: string[]; comment?: string; otherTokens: string[] } {
33
- const labelSet = new Set(labels);
34
- const trimmed = answer.trim();
35
-
36
- // 整串精确命中单个 label → 单选该 label、无 comment。label 自身可能含
37
- // ANSWER_COMMENT_SEPARATOR(如 "Postgres — prod"),首个分隔符切分会把它拦腰截断。
38
- if (labelSet.has(trimmed)) return { selected: [trimmed], comment: undefined, otherTokens: [] };
39
-
40
- // MF-6:整串逗号切分的每个 token 都是 label → 纯多选、无 comment。
41
- // 必须置于分隔符扫描之前——"Postgres — prod, MySQL"(两个 label 都含分隔符形态)
42
- // 若进扫描,首个分隔符切在 "Postgres — prod" 中间,剩余 "prod, MySQL" 被误判为
43
- // comment,两个选中项全部静默丢失。仅当全部 token 均为 label 时触发:
44
- // "Postgres — prod, MySQL — note" 中 "MySQL — note" 非 label → 不进此分支,
45
- // 由分隔符扫描解析为选中 "Postgres — prod" + "MySQL" + comment "note"
46
- // (含 comment 的多选形态不破坏)。
47
- const allTokens = trimmed.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
48
- if (allTokens.length > 0 && allTokens.every((t) => labelSet.has(t))) {
49
- return { selected: allTokens, comment: undefined, otherTokens: [] };
50
- }
51
-
52
- // body/comment 切分点:两级扫描 + 不切分回退,语义见各段注释。
53
- // 第一级(MF-7 语义):取「body 整串可解析为选中项」的最靠后分隔符 = 最长选中 body,
54
- // 其后的全部剩余文本为 comment。"可解析为选中项" = 整串精确命中单个 label(含逗号
55
- // label 如 "A, B"),或全部逗号 token 均为 label(多选 body,如 "Postgres — prod, MySQL")。
56
- // 第二级(MF-2 回归修复):第一级无命中时回退「body 含 ≥1 个 label token」的首个分隔符——
57
- // "Postgres — prod, custom text — note"(label 含分隔符 + Other 自由文本 + comment)
58
- // 无任何 all-label body,单谓词扫描落空 → 回退首分隔符把 label 拦腰截断,选中项静默丢失;
59
- // ≥1 谓词在真正分隔选中/Other 与 comment 的第二个分隔符命中。
60
- // 不切分回退(MF-3):两级都无命中但整串含 label token("Postgres — prod, custom text"
61
- // 无 comment 形态)→ 整串作 body(选中 + Other),不切分——首分隔符切分把 label 拦腰
62
- // 截断产生空选中。整串无任何 label token(S-17:"custom — text" Other 自由文本自身含
63
- // 分隔符)→ 保持首个分隔符切分(body "custom" + comment "text"),不做解析层区分,
64
- // 由调用方(TUI encode)按 label 命中情况归属。
65
- // MF-7 固有歧义取舍(第一级):重叠前缀 label 如 ["Postgres", "Postgres — prod"] 时,
66
- // "Postgres — prod — x" 取最靠后的整串命中即最长匹配(选中长 label + comment "x");
67
- // 若用户意图确实是「选短 label + comment 含长 label 前缀」会被误判——文本本身无法区分,
68
- // 精确 label body 优先是更优启发而非完备解;长 label 不在候选时("Postgres — prod" 非
69
- // label)该意图解析正确。
70
- // 注意:分隔符扫描用原始 answer(不 trim)——comment-only 答案以 " — comment" 开头,
71
- // trim 会吃掉前导空格导致 indexOf 失配。
72
- let body = trimmed;
73
- let comment: string | undefined;
74
- let splitIdx = answer.indexOf(ANSWER_COMMENT_SEPARATOR);
75
- // 第一级:取「body 整串可解析为选中项」的最靠后分隔符
76
- let bestSplit = -1;
77
- let scan = splitIdx;
78
- while (scan >= 0) {
79
- const candidateBody = answer.slice(0, scan).trim();
80
- if (labelSet.has(candidateBody)) {
81
- bestSplit = scan;
82
- } else {
83
- const candidateTokens = candidateBody.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
84
- if (candidateTokens.length > 0 && candidateTokens.every((t) => labelSet.has(t))) {
85
- bestSplit = scan;
86
- }
87
- }
88
- scan = answer.indexOf(ANSWER_COMMENT_SEPARATOR, scan + ANSWER_COMMENT_SEPARATOR.length);
89
- }
90
- if (bestSplit < 0) {
91
- // 第二级:取「body 含 ≥1 个 label token」的首个分隔符(R2 的 some() 谓词回归,
92
- // 仅在第一级无命中时运行——"A, B — note — x" 形态第一级已命中,不会过切)
93
- scan = splitIdx;
94
- while (scan >= 0) {
95
- const candidateTokens = answer.slice(0, scan).trim().split(/[,,]/).map((t) => t.trim()).filter(Boolean);
96
- if (candidateTokens.some((t) => labelSet.has(t))) {
97
- bestSplit = scan;
98
- break;
99
- }
100
- scan = answer.indexOf(ANSWER_COMMENT_SEPARATOR, scan + ANSWER_COMMENT_SEPARATOR.length);
101
- }
102
- }
103
- // MF-3 不切分回退:整串含 label token 时以整串为 body(选中 + Other),不切分。
104
- // splitIdx >= 0 前置(无分隔符时本就整串为 body,无需走此分支)。
105
- const noSplit = bestSplit < 0 && splitIdx >= 0 && allTokens.some((t) => labelSet.has(t));
106
- if (!noSplit && splitIdx >= 0) {
107
- if (bestSplit >= 0) {
108
- splitIdx = bestSplit;
109
- }
110
- body = answer.slice(0, splitIdx).trim();
111
- comment = answer.slice(splitIdx + ANSWER_COMMENT_SEPARATOR.length).trim() || undefined;
112
- }
113
-
114
- // body 形如 "label1, label2" → 精确匹配候选 label;剩余 tokens 不匹配任何 label
115
- // → Other 自由文本(otherTokens 返回给调用方,如 channel-handler 提取 __other)
116
- const tokens = body.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
117
- const selected: string[] = [];
118
- const otherTokens: string[] = [];
119
- for (const token of tokens) {
120
- if (labelSet.has(token)) {
121
- selected.push(token);
122
- } else {
123
- otherTokens.push(token);
124
- }
125
- }
126
- return { selected, comment, otherTokens };
127
- }