| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133 |
- /**
- * 验证「按会话并行生成」的决策函数。
- *
- * 为什么要测:这几个函数决定「能不能发」「停止该停谁」「切会话要不要归一化」——
- * 旧版本的 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);
|