/** * 验证「按会话并行生成」的决策函数。 * * 为什么要测:这几个函数决定「能不能发」「停止该停谁」「切会话要不要归一化」—— * 旧版本的 bug 正是死在"停止该停谁"上(停止打在了用户当前看的会话,而不是 * 真正在生成的那个)。这类错误不写断言很容易再犯。 * * 怎么跑(在项目根目录): * npx esbuild harness/tools/_entry-chat-tasks.ts --bundle --format=esm \ * --outfile=harness/tools/_chat-tasks.mjs * node harness/tools/verify-chat-task-utils.mjs */ import { isSessionGenerating, resolveSendAction, resolveStopTargetTaskId, shouldNormalizeSessionHistory, withTaskAdded, withTaskRemoved, ref, shallowRef, computed, } from './_chat-tasks.mjs'; let pass = 0; let fail = 0; const check = (name, cond, extra = '') => { if (cond) { pass++; console.log(' ok ' + name); } else { fail++; console.log(' FAIL ' + name + ' ' + extra); } }; /** 造一个结构上够用的 stub task(决策函数只读 sessionId) */ const stubTask = (sessionId) => ({ sessionId, aiMessageId: `ai_${sessionId}`, coordinator: null, chunkBuffer: { push() {}, flush() {}, cancel() {} }, dmsAnswerWritten: false, finished: false, }); const tasksOf = (...ids) => new Map(ids.map((id) => [id, stubTask(id)])); console.log('场景:会话 A 正在生成,用户切到会话 B\n'); const withA = tasksOf('A'); console.log('【1】isSessionGenerating'); check('A 在生成 → true', isSessionGenerating(withA, 'A') === true); check('B 没在生成 → false', isSessionGenerating(withA, 'B') === false); check('空 Map 不抛错 → false', isSessionGenerating(new Map(), 'A') === false); check('null Map 不抛错 → false', isSessionGenerating(null, 'A') === false); check('null 会话 id → false', isSessionGenerating(withA, null) === false); check('undefined 会话 id → false', isSessionGenerating(withA, undefined) === false); console.log('\n【2】resolveSendAction(发送守卫只按会话算)'); check('在 B 发送 → 放行(**并行的关键**)', resolveSendAction(withA, 'B') === 'start', resolveSendAction(withA, 'B')); check('在 A 发送 → 拦截 busy-same-session', resolveSendAction(withA, 'A') === 'busy-same-session', resolveSendAction(withA, 'A')); check('没有会话(新建)→ 放行', resolveSendAction(withA, null) === 'start'); check('空 Map → 放行', resolveSendAction(new Map(), 'A') === 'start'); check('A、B 同时在生成时,C 仍可发', resolveSendAction(tasksOf('A', 'B'), 'C') === 'start'); console.log('\n【3】resolveStopTargetTaskId(停止只停自己)'); check('当前在 B、B 没生成 → null(按钮此时是"发送")', resolveStopTargetTaskId(withA, 'B') === null, String(resolveStopTargetTaskId(withA, 'B'))); check('当前在 A、A 在生成 → 停 A', resolveStopTargetTaskId(withA, 'A') === 'A', String(resolveStopTargetTaskId(withA, 'A'))); check('**关键:当前在 B 时不会误停 A**', resolveStopTargetTaskId(withA, 'B') !== 'A'); check('null 会话 → null', resolveStopTargetTaskId(withA, null) === null); check('空 Map → null', resolveStopTargetTaskId(new Map(), 'A') === null); console.log('\n【4】shouldNormalizeSessionHistory'); check('生成中的会话 → 不归一化', shouldNormalizeSessionHistory(withA, 'A') === false); check('没在生成的会话 → 归一化', shouldNormalizeSessionHistory(withA, 'B') === true); check('null 会话 → 归一化(与旧行为一致)', shouldNormalizeSessionHistory(withA, null) === true); check('空 Map → 归一化', shouldNormalizeSessionHistory(new Map(), 'A') === true); console.log('\n【5】多会话并行时的组合语义'); const parallel = tasksOf('A', 'B'); check('A、B 同时生成:各自都算"生成中"', isSessionGenerating(parallel, 'A') && isSessionGenerating(parallel, 'B')); check('A、B 同时生成:在 C 仍可发', resolveSendAction(parallel, 'C') === 'start'); check('A、B 同时生成:停 A 不影响 B', resolveStopTargetTaskId(parallel, 'A') === 'A' && isSessionGenerating(parallel, 'B')); check('A、B 都在生成 → 两者都不归一化', shouldNormalizeSessionHistory(parallel, 'A') === false && shouldNormalizeSessionHistory(parallel, 'B') === false); console.log('\n【6】任务表更新(不可变替换,不改原 Map)'); { const t1 = stubTask('A'); const m0 = new Map(); const m1 = withTaskAdded(m0, 'A', t1); check('加任务后:新 Map 有、原 Map 没有', m1.has('A') && !m0.has('A')); check('取出来的就是存进去的那个对象(同一性)', m1.get('A') === t1); const m2 = withTaskRemoved(m1, 'A'); check('移除后:新 Map 没有、原 Map 仍有', !m2.has('A') && m1.has('A')); check('null / undefined 入参不抛错', withTaskAdded(null, 'A', t1).has('A') && withTaskRemoved(undefined, 'A').size === 0); } console.log('\n【7】⚠️ 响应式陷阱回归(这个坑真踩过)'); { // 背景:任务表曾写成 `ref(new Map())`,于是 `tasks.value.get(id) !== task` 永远成立 // → 监听器全部提前返回 → mock 流跑完按钮不恢复、真实对话内容一帧不追加。 // 下面几条把「必须用 shallowRef + 整体替换」钉死。 const task = stubTask('A'); const reactiveMap = ref(new Map()); reactiveMap.value.set('A', task); check( 'ref(new Map()) 取出来的**不是**原对象(这正是当年的 bug 源)', reactiveMap.value.get('A') !== task, '若这条失败说明 Vue 行为变了,需重新评估任务表写法' ); const shallowMap = shallowRef(new Map()); shallowMap.value = withTaskAdded(shallowMap.value, 'A', task); check('shallowRef + 整体替换:取出来就是原对象', shallowMap.value.get('A') === task); const size = computed(() => shallowMap.value.size); check('初始 size = 1', size.value === 1, String(size.value)); shallowMap.value = withTaskRemoved(shallowMap.value, 'A'); check('移除后 computed 感知到 size 变 0(响应式没丢)', size.value === 0, String(size.value)); check('原地 mutate 不触发响应式(所以必须整体替换)', (() => { const s = shallowRef(new Map()); const c = computed(() => s.value.size); const before = c.value; // ⚠️ 先求值填充缓存(computed 是惰性的,不先读就没缓存可失效) s.value.set('B', task); // 原地 set,不换 Map return before === 0 && c.value === 0; // 缓存仍在旧值 → 证明徒手 mutate 不会通知 })()); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);