/** * `ask_user_question` ↔ AgentMail 询问邮件 的映射约定。 * * 三桥共用同一份(deploy/check-shared-libs.sh 校验逐字节相同)。 * * 这里钉住的是**会让模型永久挂死或拿到错答案**的边界: * - 摊平多问题时选项不能丢、不能重复 * - 回写 answers 时每个问题必须拿到属于自己的选择(不能张冠李戴) * - 无选项题(自由文本)必须能通过 custom 把话带回去 * - 空回答不能被当成「答了」 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { 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('单问题多选: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('无选项题:options 为空,正文提示直接填写', () => { const flat = flattenQuestions([{ id: 'q1', question: '你的名字?' }]); assert.deepEqual(flat.options, []); assert.match(flat.context, /直接填写|没有预设选项/); }); 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', () => { 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(否则答案永远为空)', () => { 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: '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('空回答判定:有选项的问题什么都没选 = 空', () => { 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({}), '(未提供问题)'); });