/** * Unit Tests — 控制台「目标·任务」两级栏的纯逻辑(27-P3C §七) * * 这份文件的前身是 `web/src/components/graph-console/goal-task-model.check.js`:一个用 * `node xxx.check.js` 手跑的 assert 脚本。它**零调用方** —— 根 vitest.config.js 的 include 只覆盖 * `src/ server/ packages/`,package.json 没有脚本引它,pre-commit 也没有。它守的恰恰是 * 「错了不报错、只是画错」那一类,而它自己从来没被跑过。所以断言原样搬到这里,check.js 删掉: * 一个没人跑的检查比没有检查更糟,它会让人以为这块有守卫。 * * 手法对标 `graph-console-node-signals.test.js`:vitest 直接 import `web/` 下的纯 .js 模块。 * * 盯的形态:认不出的状态、没起过 run 的 task(progress=null)、依赖引到已归档的 id、 * 没有 running 时的默认选中回退、run 状态 → task 状态的映射, * 以及**前端那张状态表与后端 task-settle 的那张必须逐字一致**(缺陷 9)。 */ import { describe, it, expect } from 'vitest'; import { taskStyle, goalStyle, elapsedLabel, taskSubtitle, goalProgressLabel, dependsLabel, pickDefaultSelection, findTask, runStateToTaskPatch, RUN_TERMINAL_TO_TASK_STATUS, } from '../../../web/src/components/graph-console/goal-task-model.js'; const T0 = Date.parse('2026-08-18T10:00:00.000Z'); describe('状态样式表', () => { it('认得的状态给出画布同款颜色与中文标签', () => { expect(taskStyle('running').color).toBe('#0069d9'); expect(taskStyle('draft').hollow).toBe(true); expect(taskStyle('completed').label).toBe('已完成'); expect(goalStyle('planning').pulse).toBe(true); expect(goalStyle('done').color).toBe('#1a9c4b'); }); it('认不出的状态退到灰空心 + 原文,绝不 undefined(后端加新状态时列表照样画得出来)', () => { expect(taskStyle('paused')).toEqual({ color: '#bbb', hollow: true, label: 'paused' }); expect(taskStyle(undefined).label).toBe('—'); expect(goalStyle('whatever').hollow).toBe(true); }); }); describe('墙钟', () => { it('startedAt → (finishedAt | now),跨小时进位', () => { expect(elapsedLabel({ startedAt: '2026-08-18T10:00:00.000Z' }, T0 + 12 * 60000)).toBe('12min'); expect(elapsedLabel( { startedAt: '2026-08-18T10:00:00.000Z', finishedAt: '2026-08-18T11:05:00.000Z' }, T0 + 999 * 60000, )).toBe('1h5min'); }); it('没开跑 / 时钟倒挂 → 空串:宁可少一段,也不显示 0min 或 -3min', () => { expect(elapsedLabel({ startedAt: null }, T0)).toBe(''); expect(elapsedLabel({ startedAt: '2026-08-18T10:00:00.000Z' }, T0 - 60000)).toBe(''); expect(elapsedLabel({ startedAt: 'not-a-date' }, T0)).toBe(''); expect(elapsedLabel(null, T0)).toBe(''); }); }); describe('task 行第二排', () => { it('mode · done/total · 墙钟', () => { expect(taskSubtitle( { mode: 'auto', progress: { total: 7, done: 3 }, startedAt: '2026-08-18T10:00:00.000Z' }, T0 + 12 * 60000, )).toBe('auto · 3/7 · 12min'); }); it('progress=null 是后端明确的「还没起 run」信号:整段不出现,不能自己补一个 0/0', () => { expect(taskSubtitle({ mode: 'manual', progress: null, startedAt: null }, T0)).toBe('manual'); expect(taskSubtitle({ mode: 'auto', progress: { total: 0, done: 0 } }, T0)).toBe('auto'); }); }); describe('goal 行进度', () => { it('只数 completed', () => { expect(goalProgressLabel({ tasks: [{ status: 'completed' }, { status: 'draft' }] })).toBe('1/2 任务完成'); }); it('零 task 的 goal(拆分中 / 空草稿)不显示这段,别写成 0/0', () => { expect(goalProgressLabel({ tasks: [] })).toBe(''); expect(goalProgressLabel({})).toBe(''); }); }); describe('依赖文案', () => { const sibs = [ { taskId: 'T-1', seq: 1, title: '搭骨架' }, { taskId: 'T-2', seq: 2, title: '写首页' }, ]; it('同 goal 内的兄弟按 #seq 标题渲染', () => { expect(dependsLabel({ dependsOn: ['T-1'] }, sibs)).toBe('等 #1 搭骨架 完成'); expect(dependsLabel({ dependsOn: ['T-1', 'T-2'] }, sibs)).toBe('等 #1 搭骨架、#2 写首页 完成'); expect(dependsLabel({ dependsOn: [] }, sibs)).toBe(''); }); it('引到看不见的 id(已归档 / 跨 goal)原样显示,不静默吞掉 —— 那正是 dispatch 会 409 的原因', () => { expect(dependsLabel({ dependsOn: ['T-9'] }, sibs)).toBe('等 T-9 完成'); }); }); describe('默认选中', () => { const goals = [ { goalId: 'G-1', tasks: [ { taskId: 'T-a', status: 'completed', createdAt: '2026-08-18T09:00:00Z' }, { taskId: 'T-b', status: 'running', createdAt: '2026-08-18T09:30:00Z' }, ], }, { goalId: 'G-2', tasks: [{ taskId: 'T-c', status: 'draft', createdAt: '2026-08-18T12:00:00Z' }] }, ]; it('running 优先', () => { expect(pickDefaultSelection(goals)).toEqual({ goalId: 'G-1', taskId: 'T-b' }); }); it('没有 running → 跨 goal 比 createdAt 取最新,而不是第一个 goal 的第一条', () => { const noRunning = goals.map((g) => ({ ...g, tasks: g.tasks.map((t) => ({ ...t, status: 'draft' })) })); expect(pickDefaultSelection(noRunning)).toEqual({ goalId: 'G-2', taskId: 'T-c' }); }); it('空树 / 只有空 goal → null', () => { expect(pickDefaultSelection([])).toBeNull(); expect(pickDefaultSelection([{ goalId: 'G-3', tasks: [] }])).toBeNull(); }); it('定位:URL 里带了一条已归档的 task → null,由调用方退回默认', () => { expect(findTask(goals, 'T-c').goal.goalId).toBe('G-2'); expect(findTask(goals, 'T-zzz')).toBeNull(); expect(findTask(goals, null)).toBeNull(); }); }); describe('SSE 快照 → 行补丁', () => { it('状态 + 进度(done 数 success 与 skipped)', () => { expect(runStateToTaskPatch({ run: { run_status: 'running' }, nodes: [{ status: 'success' }, { status: 'skipped' }, { status: 'running' }], })).toEqual({ status: 'running', progress: { total: 3, done: 2 } }); }); it('stopped 在 task 那侧叫 cancelled', () => { expect(runStateToTaskPatch({ run: { run_status: 'stopped' }, nodes: [] }).status).toBe('cancelled'); }); it('run 还在 ready/planning:不给 status(库里那条已经是 running,回一个 ready 是退步)', () => { expect(runStateToTaskPatch({ run: { run_status: 'ready' }, nodes: [{ status: 'pending' }] })) .toEqual({ progress: { total: 1, done: 0 } }); expect(runStateToTaskPatch({ run: { run_status: 'ready' }, nodes: [] })).toBeNull(); }); it('没有 run 的快照 → null,不产生空补丁', () => { expect(runStateToTaskPatch(null)).toBeNull(); expect(runStateToTaskPatch({})).toBeNull(); }); }); describe('前后端状态表一致性(缺陷 9)', () => { /** * 前端复述了后端的取值域,就必须有人钉住它们一致。 * 这条红了说明 `server/services/task-settle.js` 的 RUN_TO_TASK_STATUS 改了而前端没跟 —— * 症状是浏览器把一条已收敛的 task 画成认不出的状态,线上不会报任何错。 * 修法:改 `web/src/components/graph-console/goal-task-model.js` 的 * RUN_TERMINAL_TO_TASK_STATUS,让它逐字等于后端那张表。 */ it('goal-task-model.RUN_TERMINAL_TO_TASK_STATUS 逐字等于 task-settle.RUN_TO_TASK_STATUS', () => { const { RUN_TO_TASK_STATUS: backend } = require('../../services/task-settle'); expect(Object.keys(backend).length).toBeGreaterThan(0); // 后端改成不导出 = 这条测试静默空跑 expect(RUN_TERMINAL_TO_TASK_STATUS).toEqual(backend); }); it('前端多出来的只有 running 一条,且它确实映射成 running', () => { // 前端表 = 后端 4 条 + running。多的这条是 SSE 全程推流才需要的(后端只在收敛时被调), // 允许存在但**只允许这一条** —— 再多一条就是 canDispatch 那次「前端自造取值域」的复发。 const patched = runStateToTaskPatch({ run: { run_status: 'running' }, nodes: [] }); expect(patched).toEqual({ status: 'running' }); const backend = require('../../services/task-settle').RUN_TO_TASK_STATUS; const extras = ['running', 'ready', 'planning', 'paused', 'queued'] .filter((s) => !(s in backend)) .filter((s) => runStateToTaskPatch({ run: { run_status: s }, nodes: [] }) !== null); expect(extras).toEqual(['running']); }); });