verify-chat-task-utils.mjs 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. /**
  2. * 验证「按会话并行生成」的决策函数。
  3. *
  4. * 为什么要测:这几个函数决定「能不能发」「停止该停谁」「切会话要不要归一化」——
  5. * 旧版本的 bug 正是死在"停止该停谁"上(停止打在了用户当前看的会话,而不是
  6. * 真正在生成的那个)。这类错误不写断言很容易再犯。
  7. *
  8. * 怎么跑(在项目根目录):
  9. * npx esbuild harness/tools/_entry-chat-tasks.ts --bundle --format=esm \
  10. * --outfile=harness/tools/_chat-tasks.mjs
  11. * node harness/tools/verify-chat-task-utils.mjs
  12. */
  13. import {
  14. isSessionGenerating,
  15. resolveSendAction,
  16. resolveStopTargetTaskId,
  17. shouldNormalizeSessionHistory,
  18. withTaskAdded,
  19. withTaskRemoved,
  20. ref,
  21. shallowRef,
  22. computed,
  23. } from './_chat-tasks.mjs';
  24. let pass = 0;
  25. let fail = 0;
  26. const check = (name, cond, extra = '') => {
  27. if (cond) {
  28. pass++;
  29. console.log(' ok ' + name);
  30. } else {
  31. fail++;
  32. console.log(' FAIL ' + name + ' ' + extra);
  33. }
  34. };
  35. /** 造一个结构上够用的 stub task(决策函数只读 sessionId) */
  36. const stubTask = (sessionId) => ({
  37. sessionId,
  38. aiMessageId: `ai_${sessionId}`,
  39. coordinator: null,
  40. chunkBuffer: { push() {}, flush() {}, cancel() {} },
  41. dmsAnswerWritten: false,
  42. finished: false,
  43. });
  44. const tasksOf = (...ids) => new Map(ids.map((id) => [id, stubTask(id)]));
  45. console.log('场景:会话 A 正在生成,用户切到会话 B\n');
  46. const withA = tasksOf('A');
  47. console.log('【1】isSessionGenerating');
  48. check('A 在生成 → true', isSessionGenerating(withA, 'A') === true);
  49. check('B 没在生成 → false', isSessionGenerating(withA, 'B') === false);
  50. check('空 Map 不抛错 → false', isSessionGenerating(new Map(), 'A') === false);
  51. check('null Map 不抛错 → false', isSessionGenerating(null, 'A') === false);
  52. check('null 会话 id → false', isSessionGenerating(withA, null) === false);
  53. check('undefined 会话 id → false', isSessionGenerating(withA, undefined) === false);
  54. console.log('\n【2】resolveSendAction(发送守卫只按会话算)');
  55. check('在 B 发送 → 放行(**并行的关键**)', resolveSendAction(withA, 'B') === 'start', resolveSendAction(withA, 'B'));
  56. check('在 A 发送 → 拦截 busy-same-session', resolveSendAction(withA, 'A') === 'busy-same-session', resolveSendAction(withA, 'A'));
  57. check('没有会话(新建)→ 放行', resolveSendAction(withA, null) === 'start');
  58. check('空 Map → 放行', resolveSendAction(new Map(), 'A') === 'start');
  59. check('A、B 同时在生成时,C 仍可发', resolveSendAction(tasksOf('A', 'B'), 'C') === 'start');
  60. console.log('\n【3】resolveStopTargetTaskId(停止只停自己)');
  61. check('当前在 B、B 没生成 → null(按钮此时是"发送")', resolveStopTargetTaskId(withA, 'B') === null, String(resolveStopTargetTaskId(withA, 'B')));
  62. check('当前在 A、A 在生成 → 停 A', resolveStopTargetTaskId(withA, 'A') === 'A', String(resolveStopTargetTaskId(withA, 'A')));
  63. check('**关键:当前在 B 时不会误停 A**', resolveStopTargetTaskId(withA, 'B') !== 'A');
  64. check('null 会话 → null', resolveStopTargetTaskId(withA, null) === null);
  65. check('空 Map → null', resolveStopTargetTaskId(new Map(), 'A') === null);
  66. console.log('\n【4】shouldNormalizeSessionHistory');
  67. check('生成中的会话 → 不归一化', shouldNormalizeSessionHistory(withA, 'A') === false);
  68. check('没在生成的会话 → 归一化', shouldNormalizeSessionHistory(withA, 'B') === true);
  69. check('null 会话 → 归一化(与旧行为一致)', shouldNormalizeSessionHistory(withA, null) === true);
  70. check('空 Map → 归一化', shouldNormalizeSessionHistory(new Map(), 'A') === true);
  71. console.log('\n【5】多会话并行时的组合语义');
  72. const parallel = tasksOf('A', 'B');
  73. check('A、B 同时生成:各自都算"生成中"', isSessionGenerating(parallel, 'A') && isSessionGenerating(parallel, 'B'));
  74. check('A、B 同时生成:在 C 仍可发', resolveSendAction(parallel, 'C') === 'start');
  75. check('A、B 同时生成:停 A 不影响 B', resolveStopTargetTaskId(parallel, 'A') === 'A' && isSessionGenerating(parallel, 'B'));
  76. check('A、B 都在生成 → 两者都不归一化', shouldNormalizeSessionHistory(parallel, 'A') === false && shouldNormalizeSessionHistory(parallel, 'B') === false);
  77. console.log('\n【6】任务表更新(不可变替换,不改原 Map)');
  78. {
  79. const t1 = stubTask('A');
  80. const m0 = new Map();
  81. const m1 = withTaskAdded(m0, 'A', t1);
  82. check('加任务后:新 Map 有、原 Map 没有', m1.has('A') && !m0.has('A'));
  83. check('取出来的就是存进去的那个对象(同一性)', m1.get('A') === t1);
  84. const m2 = withTaskRemoved(m1, 'A');
  85. check('移除后:新 Map 没有、原 Map 仍有', !m2.has('A') && m1.has('A'));
  86. check('null / undefined 入参不抛错', withTaskAdded(null, 'A', t1).has('A') && withTaskRemoved(undefined, 'A').size === 0);
  87. }
  88. console.log('\n【7】⚠️ 响应式陷阱回归(这个坑真踩过)');
  89. {
  90. // 背景:任务表曾写成 `ref(new Map())`,于是 `tasks.value.get(id) !== task` 永远成立
  91. // → 监听器全部提前返回 → mock 流跑完按钮不恢复、真实对话内容一帧不追加。
  92. // 下面几条把「必须用 shallowRef + 整体替换」钉死。
  93. const task = stubTask('A');
  94. const reactiveMap = ref(new Map());
  95. reactiveMap.value.set('A', task);
  96. check(
  97. 'ref(new Map()) 取出来的**不是**原对象(这正是当年的 bug 源)',
  98. reactiveMap.value.get('A') !== task,
  99. '若这条失败说明 Vue 行为变了,需重新评估任务表写法'
  100. );
  101. const shallowMap = shallowRef(new Map());
  102. shallowMap.value = withTaskAdded(shallowMap.value, 'A', task);
  103. check('shallowRef + 整体替换:取出来就是原对象', shallowMap.value.get('A') === task);
  104. const size = computed(() => shallowMap.value.size);
  105. check('初始 size = 1', size.value === 1, String(size.value));
  106. shallowMap.value = withTaskRemoved(shallowMap.value, 'A');
  107. check('移除后 computed 感知到 size 变 0(响应式没丢)', size.value === 0, String(size.value));
  108. check('原地 mutate 不触发响应式(所以必须整体替换)', (() => {
  109. const s = shallowRef(new Map());
  110. const c = computed(() => s.value.size);
  111. const before = c.value; // ⚠️ 先求值填充缓存(computed 是惰性的,不先读就没缓存可失效)
  112. s.value.set('B', task); // 原地 set,不换 Map
  113. return before === 0 && c.value === 0; // 缓存仍在旧值 → 证明徒手 mutate 不会通知
  114. })());
  115. }
  116. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  117. process.exit(fail ? 1 : 0);