pi 逐处对文件后指出:我按"本平台不可达 ⇒ 搬去 test/lib/"把 `lib/user-question.js`
搬走,打红了 `deploy/check-shared-libs.sh` 两处(实测确认,脚本真退出码 1):
共用模块缺失:plugins/pi-mail-bridge/lib/user-question.js
共用测试已分叉:test/user-question.test.mjs(opencode vs pi)
根因不是取舍而是口径:**`lib/` 上挂着两条方向相反的不变量** ——
① 共用模块四方逐字节同源(`check-shared-libs.sh`,连相对路径一起钉);
② 本平台生产可达(我新加的规则)。而 `user-question.js` **是 dsh 桥的生产代码**
(`plugins/dsh-mail-bridge/src/index.ts` 引用它)⇒ 两条必然冲突。
**`lib/` 首先是四桥共用命名空间,其次才是"本平台可达"**;可达性只能当**报告**,
不能当搬家判据。教训的形状:**一条新判据上线时,先找它可能与哪些既有不变量冲突** ——
我只看⻅了自己那条。
改动:
- `user-question.js` 与它的测试回到 `lib/`、`test/`(路径也与 dsh 侧一致),
两边逐字节相同已复验;`check-shared-libs.sh` 退出码 0。
- `reach.mjs` 增加 `sharedLibNames()`:直接从 `check-shared-libs.sh` 的 `ALL_LIBS`
读共用清单做豁免(不手抄常量),并把"进快照但本平台不可达"降级为**报告**。
- `layout-boundaries.test.mjs` 增加回归判据:共用模块必须留在 `lib/`、
测试相对路径与 dsh 一致、两侧逐字节相同。
- 删掉 `reach.mjs` / `docs/DEV-TOOLING.md` 里那句**无据的机制说明**
("user-question 走前缀动态 import"):`localRefs` 的三条正则只认引号字面量,
对模板字面量形状是**盲的** ⇒ 那句若为真,搬走的就是生产代码而两条判据都会绿。
pi 读了 `src/` 下九个文件都找不到引用,我也确认是记忆偏差;理由改用 `addressing.js`
(传递可达、`src` 直接引用数为 0)—— 它已足够证明"直接引用数不是可达性"。
顺带按 pi 的第二条建议:`deploy/check-deploy-drift.mjs` 判据 ① 把
**非运行时差异**摘出来(`jsonTestOnlyChange`,只豁免 `scripts.test` 一类字段,
只对"两边都在、仅内容不同"的文件生效)。理由:一条**永远黄、没人打算为它动手**的判据
唯一的下场是被学会忽略,那时真正的运行时漂移会被一起忽略。
⚠️ 摘的条件很窄 —— **把运行时差异误判成非运行时比恒黄更坏(那是假绿)**,
所以 `main`/`start`/`dependencies` 变了、或解析不了,一律仍算运行时;
纯函数加了六个反/正样本的判据(含三个"必须算运行时"的)。
(该文件同时有另一条会话的改动,未提交、我未触碰;本次只加了我这一段。)
验证:`npm test` 463/463;`check-shared-libs.sh` 退出码 0;`--self-check` 18 条全过。
137 lines
5.8 KiB
JavaScript
137 lines
5.8 KiB
JavaScript
/**
|
||
* `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({}), '(未提供问题)');
|
||
});
|