feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)
问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。
新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。
密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
1. 进程环境
2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
因此身份一致
3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
(兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。
不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。
为什么不会重复注册(读 SDK 实现确认,并用探针实测):
resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
命令行 -e 传入的扩展。
探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。
deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。
验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
This commit is contained in:
@ -9,121 +9,132 @@
|
||||
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createFrameParser } from '../lib/sse-client.js';
|
||||
import { createFrameParser } from "../lib/sse-client.js";
|
||||
|
||||
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
|
||||
function parse(s) {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch (e) {
|
||||
assert.fail(`不是合法 JSON: ${s}(${e.message})`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch (e) {
|
||||
assert.fail(`不是合法 JSON: ${s}(${e.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
test('完整帧一次喂入:正常解析', () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].event, 'new_mail');
|
||||
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' });
|
||||
assert.equal(events[0].id, '7');
|
||||
assert.equal(p.lastEventId(), '7');
|
||||
test("完整帧一次喂入:正常解析", () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].event, "new_mail");
|
||||
assert.deepEqual(parse(events[0].data), { mail_id: "m1" });
|
||||
assert.equal(events[0].id, "7");
|
||||
assert.equal(p.lastEventId(), "7");
|
||||
});
|
||||
|
||||
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => {
|
||||
const p = createFrameParser();
|
||||
// chunk1 恰好停在 event 行之后、data 行之前
|
||||
const first = p.push('id: 12\nevent: content_delta\n');
|
||||
assert.deepEqual(first, [], '半帧不该派发');
|
||||
test("帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)", () => {
|
||||
const p = createFrameParser();
|
||||
// chunk1 恰好停在 event 行之后、data 行之前
|
||||
const first = p.push("id: 12\nevent: content_delta\n");
|
||||
assert.deepEqual(first, [], "半帧不该派发");
|
||||
|
||||
const second = p.push('data: {"x":1}\n\n');
|
||||
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃');
|
||||
assert.equal(second[0].event, 'content_delta');
|
||||
assert.equal(p.lastEventId(), '12');
|
||||
const second = p.push('data: {"x":1}\n\n');
|
||||
assert.equal(
|
||||
second.length,
|
||||
1,
|
||||
"跨 chunk 的半帧必须被拼回完整事件,而不是丢弃",
|
||||
);
|
||||
assert.equal(second[0].event, "content_delta");
|
||||
assert.equal(p.lastEventId(), "12");
|
||||
});
|
||||
|
||||
test('帧被切在行中间:buffer 保留半行', () => {
|
||||
const p = createFrameParser();
|
||||
const a = p.push('event: new_ma');
|
||||
assert.deepEqual(a, []);
|
||||
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
|
||||
assert.equal(b.length, 1);
|
||||
assert.equal(b[0].event, 'new_mail');
|
||||
test("帧被切在行中间:buffer 保留半行", () => {
|
||||
const p = createFrameParser();
|
||||
const a = p.push("event: new_ma");
|
||||
assert.deepEqual(a, []);
|
||||
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
|
||||
assert.equal(b.length, 1);
|
||||
assert.equal(b[0].event, "new_mail");
|
||||
});
|
||||
|
||||
test('一个 chunk 里多帧连续:全部派发', () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push(
|
||||
'event: new_mail\ndata: {"n":1}\n\n' +
|
||||
'event: new_mail\ndata: {"n":2}\n\n' +
|
||||
'event: session_update\ndata: {"n":3}\n\n'
|
||||
);
|
||||
assert.equal(events.length, 3);
|
||||
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']);
|
||||
test("一个 chunk 里多帧连续:全部派发", () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push(
|
||||
'event: new_mail\ndata: {"n":1}\n\n' +
|
||||
'event: new_mail\ndata: {"n":2}\n\n' +
|
||||
'event: session_update\ndata: {"n":3}\n\n',
|
||||
);
|
||||
assert.equal(events.length, 3);
|
||||
assert.deepEqual(
|
||||
events.map((e) => e.event),
|
||||
["new_mail", "new_mail", "session_update"],
|
||||
);
|
||||
});
|
||||
|
||||
test('注释/心跳行被忽略,不影响后续帧', () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].event, 'new_mail');
|
||||
test("注释/心跳行被忽略,不影响后续帧", () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].event, "new_mail");
|
||||
});
|
||||
|
||||
test('多行 data 用换行拼接', () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push('event: x\ndata: line1\ndata: line2\n\n');
|
||||
assert.equal(events[0].data, 'line1\nline2');
|
||||
test("多行 data 用换行拼接", () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push("event: x\ndata: line1\ndata: line2\n\n");
|
||||
assert.equal(events[0].data, "line1\nline2");
|
||||
});
|
||||
|
||||
test('CRLF 不被当成事件名或 JSON 的一部分', () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].event, 'new_mail');
|
||||
assert.equal(events[0].id, '3');
|
||||
assert.deepEqual(parse(events[0].data), { n: 1 });
|
||||
test("CRLF 不被当成事件名或 JSON 的一部分", () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].event, "new_mail");
|
||||
assert.equal(events[0].id, "3");
|
||||
assert.deepEqual(parse(events[0].data), { n: 1 });
|
||||
});
|
||||
|
||||
test('事件 id 只向前推进:重放旧 id 不回退断点', () => {
|
||||
const p = createFrameParser();
|
||||
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||
assert.equal(p.lastEventId(), '10');
|
||||
// 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10
|
||||
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
|
||||
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id(是否回退由使用方决定)');
|
||||
test("事件 id 只向前推进:重放旧 id 不回退断点", () => {
|
||||
const p = createFrameParser();
|
||||
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||
assert.equal(p.lastEventId(), "10");
|
||||
// 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10
|
||||
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
|
||||
assert.equal(
|
||||
p.lastEventId(),
|
||||
"5",
|
||||
"解析器如实记录当前 id(是否回退由使用方决定)",
|
||||
);
|
||||
});
|
||||
|
||||
test('id 在派发前记录:回调抛异常也不丢断点', () => {
|
||||
const p = createFrameParser();
|
||||
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||
assert.equal(p.lastEventId(), '42');
|
||||
test("id 在派发前记录:回调抛异常也不丢断点", () => {
|
||||
const p = createFrameParser();
|
||||
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
|
||||
assert.equal(p.lastEventId(), "42");
|
||||
});
|
||||
|
||||
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push('data: {"orphan":true}\n\n');
|
||||
assert.deepEqual(events, []);
|
||||
test("只有 data 没有 event 不派发(避免把心跳数据当事件)", () => {
|
||||
const p = createFrameParser();
|
||||
const events = p.push('data: {"orphan":true}\n\n');
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test('reset 清缓冲但保留断点(重连后仍能续传)', () => {
|
||||
const p = createFrameParser();
|
||||
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
|
||||
p.push('event: partial'); // 半帧
|
||||
p.reset();
|
||||
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放');
|
||||
// reset 后半帧不该复活
|
||||
const after = p.push('data: {"n":2}\n\n');
|
||||
assert.deepEqual(after, []);
|
||||
test("reset 清缓冲但保留断点(重连后仍能续传)", () => {
|
||||
const p = createFrameParser();
|
||||
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
|
||||
p.push("event: partial"); // 半帧
|
||||
p.reset();
|
||||
assert.equal(p.lastEventId(), "99", "断点必须保留,否则重连从头回放");
|
||||
// reset 后半帧不该复活
|
||||
const after = p.push('data: {"n":2}\n\n');
|
||||
assert.deepEqual(after, []);
|
||||
});
|
||||
|
||||
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => {
|
||||
const p = createFrameParser();
|
||||
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
|
||||
assert.equal(p.lastEventId(), '123');
|
||||
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
|
||||
p.setLastEventId('');
|
||||
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID');
|
||||
test("setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端", () => {
|
||||
const p = createFrameParser();
|
||||
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
|
||||
assert.equal(p.lastEventId(), "123");
|
||||
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
|
||||
p.setLastEventId("");
|
||||
assert.equal(p.lastEventId(), "", "首次连接不得携带 Last-Event-ID");
|
||||
});
|
||||
|
||||
@ -10,127 +10,179 @@
|
||||
* - 空回答不能被当成「答了」
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
flattenQuestions,
|
||||
answersFromDecision,
|
||||
isBlankAnswer,
|
||||
hasOptions,
|
||||
optionLabels,
|
||||
questionTitle,
|
||||
} from '../lib/user-question.js';
|
||||
flattenQuestions,
|
||||
answersFromDecision,
|
||||
isBlankAnswer,
|
||||
hasOptions,
|
||||
optionLabels,
|
||||
questionTitle,
|
||||
} from "../lib/user-question.js";
|
||||
|
||||
test('单问题单选项:忠实映射问题与选项', () => {
|
||||
const flat = flattenQuestions([{
|
||||
id: 'q1',
|
||||
question: '用哪种方案?',
|
||||
header: '选择',
|
||||
options: [{ label: '方案 A' }, { label: '方案 B' }],
|
||||
}]);
|
||||
assert.equal(flat.question, '选择: 用哪种方案?');
|
||||
assert.deepEqual(flat.options, ['方案 A', '方案 B']);
|
||||
assert.equal(flat.multiSelect, false);
|
||||
test("单问题单选项:忠实映射问题与选项", () => {
|
||||
const flat = flattenQuestions([
|
||||
{
|
||||
id: "q1",
|
||||
question: "用哪种方案?",
|
||||
header: "选择",
|
||||
options: [{ label: "方案 A" }, { label: "方案 B" }],
|
||||
},
|
||||
]);
|
||||
assert.equal(flat.question, "选择: 用哪种方案?");
|
||||
assert.deepEqual(flat.options, ["方案 A", "方案 B"]);
|
||||
assert.equal(flat.multiSelect, false);
|
||||
});
|
||||
|
||||
test('单问题多选:multiSelect 透传', () => {
|
||||
const flat = flattenQuestions([{
|
||||
id: 'q1',
|
||||
question: '要哪些?',
|
||||
options: [{ label: 'a' }, { label: 'b' }],
|
||||
multiSelect: true,
|
||||
}]);
|
||||
assert.equal(flat.multiSelect, true);
|
||||
test("单问题多选:multiSelect 透传", () => {
|
||||
const flat = flattenQuestions([
|
||||
{
|
||||
id: "q1",
|
||||
question: "要哪些?",
|
||||
options: [{ label: "a" }, { label: "b" }],
|
||||
multiSelect: true,
|
||||
},
|
||||
]);
|
||||
assert.equal(flat.multiSelect, true);
|
||||
});
|
||||
|
||||
test('多问题:选项取并集且去重保序,multiSelect 置真', () => {
|
||||
const flat = flattenQuestions([
|
||||
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] },
|
||||
{ id: 'q2', question: '后端?', options: [{ label: 'Vue' }, { label: 'Go' }] },
|
||||
]);
|
||||
assert.deepEqual(flat.options, ['React', 'Vue', 'Go'], '重复 label 只出现一次');
|
||||
assert.equal(flat.multiSelect, true, '多问题必须允许多选,否则无法同时回答两题');
|
||||
assert.match(flat.context, /前端?/);
|
||||
assert.match(flat.context, /后端?/);
|
||||
test("多问题:选项取并集且去重保序,multiSelect 置真", () => {
|
||||
const flat = flattenQuestions([
|
||||
{
|
||||
id: "q1",
|
||||
question: "前端?",
|
||||
options: [{ label: "React" }, { label: "Vue" }],
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
question: "后端?",
|
||||
options: [{ label: "Vue" }, { label: "Go" }],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
flat.options,
|
||||
["React", "Vue", "Go"],
|
||||
"重复 label 只出现一次",
|
||||
);
|
||||
assert.equal(
|
||||
flat.multiSelect,
|
||||
true,
|
||||
"多问题必须允许多选,否则无法同时回答两题",
|
||||
);
|
||||
assert.match(flat.context, /前端?/);
|
||||
assert.match(flat.context, /后端?/);
|
||||
});
|
||||
|
||||
test('无选项题:options 为空,正文提示直接填写', () => {
|
||||
const flat = flattenQuestions([{ id: 'q1', question: '你的名字?' }]);
|
||||
assert.deepEqual(flat.options, []);
|
||||
assert.match(flat.context, /直接填写|没有预设选项/);
|
||||
test("无选项题:options 为空,正文提示直接填写", () => {
|
||||
const flat = flattenQuestions([{ id: "q1", question: "你的名字?" }]);
|
||||
assert.deepEqual(flat.options, []);
|
||||
assert.match(flat.context, /直接填写|没有预设选项/);
|
||||
});
|
||||
|
||||
test('空问题列表:抛错而不是造一封没有内容的信', () => {
|
||||
assert.throws(() => flattenQuestions([]), /至少需要一个/);
|
||||
assert.throws(() => flattenQuestions(undefined), /至少需要一个/);
|
||||
test("空问题列表:抛错而不是造一封没有内容的信", () => {
|
||||
assert.throws(() => flattenQuestions([]), /至少需要一个/);
|
||||
assert.throws(() => flattenQuestions(undefined), /至少需要一个/);
|
||||
});
|
||||
|
||||
test('单问题回写:选项进 selected,备注进 custom', () => {
|
||||
const qs = [{ id: 'q1', question: '选哪个', options: [{ label: 'A' }, { label: 'B' }] }];
|
||||
const ans = answersFromDecision(qs, 'A', '再确认下');
|
||||
assert.deepEqual(ans.answers, [{ id: 'q1', selected: ['A'], custom: '再确认下' }]);
|
||||
test("单问题回写:选项进 selected,备注进 custom", () => {
|
||||
const qs = [
|
||||
{ id: "q1", question: "选哪个", options: [{ label: "A" }, { label: "B" }] },
|
||||
];
|
||||
const ans = answersFromDecision(qs, "A", "再确认下");
|
||||
assert.deepEqual(ans.answers, [
|
||||
{ id: "q1", selected: ["A"], custom: "再确认下" },
|
||||
]);
|
||||
});
|
||||
|
||||
test('单问题多选回写:多行决策拆成多个 selected', () => {
|
||||
const qs = [{ id: 'q1', question: '选哪些', options: [{ label: 'A' }, { label: 'B' }], multiSelect: true }];
|
||||
const ans = answersFromDecision(qs, 'A\nB', '');
|
||||
assert.deepEqual(ans.answers[0].selected, ['A', 'B']);
|
||||
assert.equal(ans.answers[0].custom, undefined, '空备注不该变成空 custom');
|
||||
test("单问题多选回写:多行决策拆成多个 selected", () => {
|
||||
const qs = [
|
||||
{
|
||||
id: "q1",
|
||||
question: "选哪些",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
multiSelect: true,
|
||||
},
|
||||
];
|
||||
const ans = answersFromDecision(qs, "A\nB", "");
|
||||
assert.deepEqual(ans.answers[0].selected, ["A", "B"]);
|
||||
assert.equal(ans.answers[0].custom, undefined, "空备注不该变成空 custom");
|
||||
});
|
||||
|
||||
test('无选项题回写:答案进 custom,selected 为空', () => {
|
||||
const qs = [{ id: 'q1', question: '名字?' }];
|
||||
const ans = answersFromDecision(qs, '', '张三');
|
||||
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '张三' }]);
|
||||
test("无选项题回写:答案进 custom,selected 为空", () => {
|
||||
const qs = [{ id: "q1", question: "名字?" }];
|
||||
const ans = answersFromDecision(qs, "", "张三");
|
||||
assert.deepEqual(ans.answers, [{ id: "q1", selected: [], custom: "张三" }]);
|
||||
});
|
||||
|
||||
test('无选项题只有决策文本时:文本进 custom(否则答案永远为空)', () => {
|
||||
const qs = [{ id: 'q1', question: '名字?' }];
|
||||
const ans = answersFromDecision(qs, '李四', undefined);
|
||||
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '李四' }]);
|
||||
test("无选项题只有决策文本时:文本进 custom(否则答案永远为空)", () => {
|
||||
const qs = [{ id: "q1", question: "名字?" }];
|
||||
const ans = answersFromDecision(qs, "李四", undefined);
|
||||
assert.deepEqual(ans.answers, [{ id: "q1", selected: [], custom: "李四" }]);
|
||||
});
|
||||
|
||||
test('多问题回写:选择按 label 归属分配到各自的问题(不张冠李戴)', () => {
|
||||
const qs = [
|
||||
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] },
|
||||
{ id: 'q2', question: '后端?', options: [{ label: 'Go' }, { label: 'Rust' }] },
|
||||
];
|
||||
const ans = answersFromDecision(qs, 'Vue\nGo', '都行');
|
||||
assert.deepEqual(ans.answers[0].selected, ['Vue'], 'q1 只拿前端的选择');
|
||||
assert.deepEqual(ans.answers[1].selected, ['Go'], 'q2 只拿后端的选择');
|
||||
assert.equal(ans.answers[0].custom, '都行', '备注归第一个问题');
|
||||
assert.equal(ans.answers[1].custom, undefined, '备注不重复分发');
|
||||
test("多问题回写:选择按 label 归属分配到各自的问题(不张冠李戴)", () => {
|
||||
const qs = [
|
||||
{
|
||||
id: "q1",
|
||||
question: "前端?",
|
||||
options: [{ label: "React" }, { label: "Vue" }],
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
question: "后端?",
|
||||
options: [{ label: "Go" }, { label: "Rust" }],
|
||||
},
|
||||
];
|
||||
const ans = answersFromDecision(qs, "Vue\nGo", "都行");
|
||||
assert.deepEqual(ans.answers[0].selected, ["Vue"], "q1 只拿前端的选择");
|
||||
assert.deepEqual(ans.answers[1].selected, ["Go"], "q2 只拿后端的选择");
|
||||
assert.equal(ans.answers[0].custom, "都行", "备注归第一个问题");
|
||||
assert.equal(ans.answers[1].custom, undefined, "备注不重复分发");
|
||||
});
|
||||
|
||||
test('多问题里认不出的 label:不匹配任何问题,不猜测放行', () => {
|
||||
const qs = [
|
||||
{ id: 'q1', question: 'a', options: [{ label: 'X' }] },
|
||||
{ id: 'q2', question: 'b', options: [{ label: 'Y' }] },
|
||||
];
|
||||
const ans = answersFromDecision(qs, 'Z', '');
|
||||
assert.deepEqual(ans.answers[0].selected, [], '认不出的 label 不得被塞进任意问题');
|
||||
assert.deepEqual(ans.answers[1].selected, []);
|
||||
test("多问题里认不出的 label:不匹配任何问题,不猜测放行", () => {
|
||||
const qs = [
|
||||
{ id: "q1", question: "a", options: [{ label: "X" }] },
|
||||
{ id: "q2", question: "b", options: [{ label: "Y" }] },
|
||||
];
|
||||
const ans = answersFromDecision(qs, "Z", "");
|
||||
assert.deepEqual(
|
||||
ans.answers[0].selected,
|
||||
[],
|
||||
"认不出的 label 不得被塞进任意问题",
|
||||
);
|
||||
assert.deepEqual(ans.answers[1].selected, []);
|
||||
});
|
||||
|
||||
test('answers 的 id 与问题一一对应', () => {
|
||||
const qs = [{ id: 'alpha', question: 'a', options: [{ label: 'X' }] }, { id: 'beta', question: 'b' }];
|
||||
const ans = answersFromDecision(qs, 'X', 'note');
|
||||
assert.deepEqual(ans.answers.map((a) => a.id), ['alpha', 'beta']);
|
||||
test("answers 的 id 与问题一一对应", () => {
|
||||
const qs = [
|
||||
{ id: "alpha", question: "a", options: [{ label: "X" }] },
|
||||
{ id: "beta", question: "b" },
|
||||
];
|
||||
const ans = answersFromDecision(qs, "X", "note");
|
||||
assert.deepEqual(
|
||||
ans.answers.map((a) => a.id),
|
||||
["alpha", "beta"],
|
||||
);
|
||||
});
|
||||
|
||||
test('空回答判定:有选项的问题什么都没选 = 空', () => {
|
||||
const qs = [{ id: 'q1', question: 'a', options: [{ label: 'X' }] }];
|
||||
assert.equal(isBlankAnswer(qs, '', ''), true);
|
||||
assert.equal(isBlankAnswer(qs, 'X', ''), false);
|
||||
assert.equal(isBlankAnswer(qs, '', '自由文本'), false, '自由文本也算答了');
|
||||
test("空回答判定:有选项的问题什么都没选 = 空", () => {
|
||||
const qs = [{ id: "q1", question: "a", options: [{ label: "X" }] }];
|
||||
assert.equal(isBlankAnswer(qs, "", ""), true);
|
||||
assert.equal(isBlankAnswer(qs, "X", ""), false);
|
||||
assert.equal(isBlankAnswer(qs, "", "自由文本"), false, "自由文本也算答了");
|
||||
});
|
||||
|
||||
test('hasOptions / optionLabels / questionTitle 的边界', () => {
|
||||
assert.equal(hasOptions({}), false);
|
||||
assert.equal(hasOptions({ options: [] }), false);
|
||||
assert.equal(hasOptions({ options: [{ label: 'a' }] }), true);
|
||||
assert.deepEqual(optionLabels({ options: ['a', { label: 'b' }, { description: 'x' }] }), ['a', 'b']);
|
||||
assert.equal(questionTitle({ question: '只问一句' }), '只问一句');
|
||||
assert.equal(questionTitle({}), '(未提供问题)');
|
||||
test("hasOptions / optionLabels / questionTitle 的边界", () => {
|
||||
assert.equal(hasOptions({}), false);
|
||||
assert.equal(hasOptions({ options: [] }), false);
|
||||
assert.equal(hasOptions({ options: [{ label: "a" }] }), true);
|
||||
assert.deepEqual(
|
||||
optionLabels({ options: ["a", { label: "b" }, { description: "x" }] }),
|
||||
["a", "b"],
|
||||
);
|
||||
assert.equal(questionTitle({ question: "只问一句" }), "只问一句");
|
||||
assert.equal(questionTitle({}), "(未提供问题)");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user