/** * 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)。 * * 批 3(队列)追加:位次徽标只读服务端的 queuePosition(读不到就明说「#?」而不是藏起来)、 * blocked 原因前缀与 work-graph-db 的 UPSTREAM_BLOCK_PREFIX 逐字一致、⋯ 菜单按状态给操作、 * 「新目标」的队列提示一个位次都不编。 */ import { describe, it, expect } from 'vitest'; import { taskStyle, goalStyle, elapsedLabel, taskSubtitle, goalProgressLabel, dependsLabel, pickDefaultSelection, findTask, runStateToTaskPatch, RUN_TERMINAL_TO_TASK_STATUS, queueBadge, parseBlockedReason, blockedLabel, taskActions, queueStat, queueNoteText, dispatchedTaskId, pendingRunHint, hasStartedRun, UPSTREAM_BLOCK_PREFIX, readSlots, } 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']); }); }); // ──────────────────────────────────────────────────────────────────────────── // 队列(27-P3C 批 3)—— 全部盯「读不到 / 画错但不报错」那一类 // ──────────────────────────────────────────────────────────────────────────── describe('排队位次徽标', () => { it('位次只读服务端给的 queuePosition,不数下标', () => { const b = queueBadge({ status: 'queued', queuePosition: 3 }); expect(b.text).toBe('排队 #3'); expect(b.title).toContain('priority'); }); it('服务端没给位次 → 显示「排队 #?」而不是悄悄退成「排队中」', () => { // 这正是当下的真实形态:company.js 的 queuePositions 定义了却没有一处调用方, // taskView 的 positions 恒为 null。藏起来等于让人以为一切正常。 for (const t of [ { status: 'queued' }, { status: 'queued', queuePosition: null }, { status: 'queued', queuePosition: 0 }, { status: 'queued', queuePosition: 'x' }, ]) { expect(queueBadge(t).text).toBe('排队 #?'); } }); it('非 queued 的行不出徽标(照常显示状态标签)', () => { expect(queueBadge({ status: 'running', queuePosition: 2 })).toBeNull(); expect(queueBadge(null)).toBeNull(); }); }); describe('blocked 原因', () => { /** * 前端为了给出「强制放行」必须从 blocked_reason 里取出上游 id,也就必须复述那个前缀。 * 这条红了说明 work-graph-db 改了 UPSTREAM_BLOCK_PREFIX:症状是原因退成一串原文、 * 「强制放行」永久置灰,而线上不会报任何错。 */ it('UPSTREAM_BLOCK_PREFIX 逐字等于 work-graph-db 的那个常量', () => { const backend = require('../../services/work-graph-db').UPSTREAM_BLOCK_PREFIX; expect(typeof backend).toBe('string'); expect(backend.length).toBeGreaterThan(0); // 后端改成不导出 = 这条测试静默空跑 expect(UPSTREAM_BLOCK_PREFIX).toBe(backend); }); it('认得的原因拆出上游 id;认不出的原样带走,绝不吞成空白', () => { expect(parseBlockedReason(`${UPSTREAM_BLOCK_PREFIX}T-9`)).toEqual({ kind: 'upstream', upstreamId: 'T-9', raw: `${UPSTREAM_BLOCK_PREFIX}T-9`, }); expect(parseBlockedReason('budget-exhausted')).toEqual({ kind: 'other', upstreamId: '', raw: 'budget-exhausted', }); expect(parseBlockedReason('')).toBeNull(); }); it('文案把上游 id 翻成 #seq 标题;翻不出(跨 goal / 已归档)就显示原始 id', () => { const sibs = [{ taskId: 'T-9', seq: 1, title: '搭骨架' }]; expect(blockedLabel({ blockedReason: `${UPSTREAM_BLOCK_PREFIX}T-9` }, sibs)).toBe('上游 #1 搭骨架 未完成'); expect(blockedLabel({ blockedReason: `${UPSTREAM_BLOCK_PREFIX}T-x` }, sibs)).toBe('上游 T-x 未完成'); expect(blockedLabel({ blockedReason: 'budget-exhausted' }, sibs)).toBe('budget-exhausted'); expect(blockedLabel({ status: 'blocked' }, sibs)).toBe(''); }); }); describe('task 行的 ⋯ 菜单', () => { const keys = (t) => taskActions(t).map((a) => a.key); it('按状态给操作:不给能点却必然 4xx 的入口', () => { expect(keys({ status: 'running', runId: 'r1' })).toEqual(['stop']); // 「立即开始」是 manual 任务唯一的起跑入口(§五.2bis:调度器故意不自动出队 manual)。 // 少了它,dispatch 完第一条跑完之后剩下的会永远躺在队里 —— 界面上和正常排队一模一样 expect(keys({ status: 'queued' })).toEqual(['start', 'cancel']); // blocked 的第三个 `cancel` 是**保底出路**:上游被归档 / 被删号时前两个一个都点不动 // (重跑上游 404、放行无依赖可删),少了它这一行就是一条谁也动不了的死任务。 expect(keys({ status: 'blocked' })).toEqual(['rerun-upstream', 'release', 'cancel']); expect(keys({ status: 'completed' })).toEqual(['rerun', 'archive']); expect(keys({ status: 'draft' })).toEqual(['archive']); }); it('running 但没有 runId:按钮置灰(/stop 打不了),而不是点了吃 404', () => { const stop = taskActions({ status: 'running' })[0]; expect(stop.disabled).toBe(true); expect(stop.title).toContain('runId'); }); it('「强制放行」只在原因指向的上游确实还在 dependsOn 里时才可点', () => { const on = taskActions({ status: 'blocked', blockedReason: `${UPSTREAM_BLOCK_PREFIX}T-9`, dependsOn: ['T-9'] }); expect(on.find((a) => a.key === 'release').disabled).toBe(false); // 依赖刚被改过:删下去什么都不会变,而 PATCH 照样回 200 —— 正是要挡住的那类静默 const stale = taskActions({ status: 'blocked', blockedReason: `${UPSTREAM_BLOCK_PREFIX}T-9`, dependsOn: [] }); expect(stale.find((a) => a.key === 'release').disabled).toBe(true); // 预算耗尽那种 blocked 没有可删的依赖,放行不适用 const other = taskActions({ status: 'blocked', blockedReason: 'budget-exhausted', dependsOn: ['T-9'] }); expect(other.find((a) => a.key === 'release').disabled).toBe(true); }); it('「重跑」可点:批 3 落地了 POST /:cid/tasks/:id/rerun(新起一条 run,旧的留在历史里)', () => { for (const s of ['completed', 'failed', 'cancelled']) { expect(taskActions({ status: s }).find((a) => a.key === 'rerun').disabled).toBeFalsy(); } }); it('「重跑上游」把上游 id 挂在动作上,解不出上游的 blocked 才置灰', () => { const up = taskActions({ status: 'blocked', blockedReason: `${UPSTREAM_BLOCK_PREFIX}T-9`, dependsOn: ['T-9'] }) .find((a) => a.key === 'rerun-upstream'); expect(up.disabled).toBe(false); // id 由动作携带:组件不再自己解一遍 blocked_reason(解析口径只归 parseBlockedReason 一处) expect(up.upstreamId).toBe('T-9'); // 预算耗尽那种 blocked 没有上游可重跑 const none = taskActions({ status: 'blocked', blockedReason: 'budget-exhausted' }) .find((a) => a.key === 'rerun-upstream'); expect(none.disabled).toBe(true); expect(none.upstreamId).toBe(''); }); }); describe('「新目标」的队列提示', () => { it('只报服务端给的三个数,一个位次都不编', () => { const txt = queueNoteText({ running: 1, queued: 2, blocked: 1 }); expect(txt).toContain('1 个任务在跑'); expect(txt).toContain('2 个在排队'); expect(txt).toContain('1 个卡在上游'); // 「将排在第 M 位」就是前端自造取值域:一条还没建出来的任务排第几,只有服务端算得准 expect(txt).not.toMatch(/第\s*\d+\s*位/); }); it('队里没人就不说话;只报非零的那几项', () => { expect(queueNoteText({ running: 0, queued: 0, blocked: 0 })).toBe(''); expect(queueNoteText(null)).toBe(''); expect(queueNoteText({ running: 2 })).toContain('2 个任务在跑'); expect(queueNoteText({ running: 2 })).not.toContain('在排队'); }); it('统计只数后端给的 status,认不出的状态不计入', () => { const goals = [ { tasks: [{ status: 'running' }, { status: 'queued' }, { status: 'queued' }] }, { tasks: [{ status: 'blocked' }, { status: 'completed' }, { status: 'weird' }] }, {}, ]; expect(queueStat(goals)).toEqual({ running: 1, queued: 2, blocked: 1 }); }); }); describe('执行位(批 4 · 一家公司可以同时跑几条)', () => { it('三个数全部读服务端的,free 连减法都不做 —— 一做减法就是第二个持有者', () => { expect(readSlots({ max: 3, used: 2, free: 1 })).toEqual({ max: 3, used: 2, free: 1 }); expect(readSlots({ max: 1, used: 0, free: 1 })).toEqual({ max: 1, used: 0, free: 1 }); // max 刚被改小时服务端给的是截断过的 free(slotState 里的 Math.max(0, …)),原样带走 expect(readSlots({ max: 3, used: 5, free: 0 })).toEqual({ max: 3, used: 5, free: 0 }); // holders 一起来也不碍事:这里只读三个数 expect(readSlots({ max: 3, used: 3, free: 0, holders: [{ runId: 'r1' }] })) .toEqual({ max: 3, used: 3, free: 0 }); }); it('形状不对一律 null —— 界面宁可少说一句,也不能凭空说出一个执行位总数', () => { expect(readSlots(null)).toBeNull(); expect(readSlots({})).toBeNull(); expect(readSlots({ max: 3, used: 1 })).toBeNull(); // 缺 free expect(readSlots({ used: 1, free: 2 })).toBeNull(); // 缺 max expect(readSlots({ max: '3', used: '1', free: '2' })).toBeNull(); // 字符串不认 expect(readSlots({ max: 3, used: 1.5, free: 1.5 })).toBeNull(); expect(readSlots({ max: 0, used: 0, free: 0 })).toBeNull(); // max=0 = 永远不出队,不是能显示的值 // 这两条钉的是「不许走 Number() 转换」:Number(null)/Number('') 都是 0, // 一个字段缺失的响应会被读成「0 个执行位在用」并原样显示出去 expect(readSlots({ max: 3, used: null, free: 3 })).toBeNull(); expect(readSlots({ max: 3, used: '', free: 3 })).toBeNull(); }); it('形状逐字同服务端唯一那一份(routes/graph-run.js#slotState 的返回值)', () => { // slotState(companyId) → { max, used, free, holders };这里连字段名都不翻译 const fromServer = { max: 3, used: 3, free: 0, holders: [{ runId: 'r1', taskId: 'T-1' }] }; expect(readSlots(fromServer)).toEqual({ max: 3, used: 3, free: 0 }); }); }); describe('并行下的队列文案', () => { it('有 slots 就报「几分之几个执行位」——「1 个在跑」在 3 坑公司里读起来像满了', () => { const txt = queueNoteText({ running: 1, queued: 2, blocked: 0 }, { max: 3, used: 1, free: 2 }); expect(txt).toContain('1/3 个执行位在用'); expect(txt).toContain('2 个在排队'); expect(txt).toContain('还空着 2 个执行位'); expect(txt).not.toMatch(/第\s*\d+\s*位/); // 位次照旧一个都不编 }); it('坑满时不说「还空着」,退回「排在它们后面」', () => { const txt = queueNoteText({ running: 3, queued: 1, blocked: 0 }, { max: 3, used: 3, free: 0 }); expect(txt).toContain('3/3 个执行位在用'); expect(txt).not.toContain('还空着'); expect(txt).toContain('坑一空就自动开跑'); }); it('slots 给不出时逐字退回批 3 原样 —— 今天服务端还没吐这个字段,走的就是这一支', () => { const before = queueNoteText({ running: 1, queued: 2, blocked: 1 }); expect(before).toBe(queueNoteText({ running: 1, queued: 2, blocked: 1 }, null)); expect(before).toBe(queueNoteText({ running: 1, queued: 2, blocked: 1 }, { max: 3 })); expect(before).toContain('1 个任务在跑'); expect(before).not.toContain('执行位'); }); it('used 与 running 对不上时两个都报,不替服务端断言它们该相等', () => { // used 数的是活跃 root run(含 ready/paused),running 数的是 task 行 —— 不是同一件事 const txt = queueNoteText({ running: 1, queued: 0, blocked: 0 }, { max: 3, used: 2, free: 1 }); expect(txt).toContain('2/3 个执行位在用'); expect(txt).toContain('1 个任务在跑'); }); it('队里空但有执行位在用时也要说话(在跑的那条不在 queueStat 的三个数里也算数)', () => { expect(queueNoteText({ running: 0, queued: 0, blocked: 0 }, { max: 3, used: 1, free: 2 })) .toContain('1/3 个执行位在用'); // 真的一个都没有:还是不说话 expect(queueNoteText({ running: 0, queued: 0, blocked: 0 }, { max: 3, used: 0, free: 3 })).toBe(''); }); }); describe('排队中的任务:还要等多久', () => { it('位次 ≤ 空位 = 下一轮就开跑,不是「要等前面的收敛」', () => { const h = pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 2 }, { max: 3, used: 1, free: 2 }); expect(h).toContain('排在第 2 位'); expect(h).toContain('还空着 2 个'); expect(h).toContain('下一轮调度'); expect(h).not.toContain('要等'); }); it('坑满时说「还要等几个执行位腾出来」,不是「前面还有几个任务」', () => { // 位次 4、3 个坑全满:空位会先被前 3 条占掉,我要等 4 个坑腾出来才轮到 const h = pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 4 }, { max: 3, used: 3, free: 0 }); expect(h).toContain('还要等 4 个执行位腾出来'); expect(h).not.toContain('要等 0 个'); // 队首 + 满坑 = 等 1 个(旧文案在这里会说「前面还有 0 个」) expect(pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 1 }, { max: 1, used: 1, free: 0 })) .toContain('还要等 1 个执行位腾出来'); }); it('manual 的排队任务:空位再多也不会自己动 —— 调度器只出队 auto', () => { const h = pendingRunHint({ status: 'queued', mode: 'manual', queuePosition: 1 }, { max: 3, used: 0, free: 3 }); expect(h).toContain('手动'); expect(h).toContain('立即开始'); expect(h).not.toContain('自动开跑'); // 没有 slots 时同样要说,这条与并行无关 expect(pendingRunHint({ status: 'queued', mode: 'manual', queuePosition: 1 })).toContain('立即开始'); }); it('slots 给不出时逐字退回批 3 原样', () => { expect(pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 3 })) .toContain('前面还有 2 个任务'); expect(pendingRunHint({ status: 'queued', mode: 'auto', queuePosition: 1 })) .toContain('排在队首'); expect(pendingRunHint({ status: 'queued', mode: 'auto' }, { max: 3, used: 1, free: 2 })) .toContain('服务端没给位次'); }); }); describe('并行下左栏每一行', () => { it('位次只对 queued 有意义:N 条同时 running 一个位次都不标', () => { expect(queueBadge({ status: 'running', queuePosition: 1 })).toBeNull(); expect(queueBadge({ status: 'running', queuePosition: 2 })).toBeNull(); expect(queueBadge({ status: 'blocked', queuePosition: 3 })).toBeNull(); expect(queueBadge({ status: 'queued', queuePosition: 3 }).text).toBe('排队 #3'); }); it('N 条并行时默认选中最新那条,且重复调用结果稳定(不会每轮轮询跳一条)', () => { const goals = [{ goalId: 'G-1', tasks: [ { taskId: 'T-a', status: 'running', createdAt: '2026-08-18T10:00:00Z' }, { taskId: 'T-b', status: 'running', createdAt: '2026-08-18T12:00:00Z' }, { taskId: 'T-c', status: 'running', createdAt: '2026-08-18T11:00:00Z' }, ], }]; expect(pickDefaultSelection(goals)).toEqual({ goalId: 'G-1', taskId: 'T-b' }); // 数组顺序变了也选同一条:排序只看 createdAt,不吃调用方的顺序 const shuffled = [{ goalId: 'G-1', tasks: [goals[0].tasks[1], goals[0].tasks[2], goals[0].tasks[0]] }]; expect(pickDefaultSelection(shuffled)).toEqual({ goalId: 'G-1', taskId: 'T-b' }); }); }); describe('dispatch 响应 → 选中态', () => { it('起了 run(started)与入了队(queued[])两种形状都认;认不出就返回空串,不猜一条出来', () => { expect(dispatchedTaskId({ started: { taskId: 'T-1' } })).toBe('T-1'); expect(dispatchedTaskId({ queued: [{ taskId: 'T-2' }] })).toBe('T-2'); expect(dispatchedTaskId({ queued: ['T-3'] })).toBe('T-3'); expect(dispatchedTaskId({ taskId: 'T-4' })).toBe('T-4'); expect(dispatchedTaskId({ success: true })).toBe(''); expect(dispatchedTaskId(null)).toBe(''); }); }); describe('选中一条还没在跑的 task', () => { it('排队中的 task 有 runId 也不画 run 面板(那条 run 是 queued,画出来全灰还不刷新)', () => { expect(hasStartedRun({ status: 'queued', runId: 'r1' })).toBe(false); expect(hasStartedRun({ status: 'draft' })).toBe(false); expect(hasStartedRun({ status: 'running', runId: 'r1' })).toBe(true); expect(hasStartedRun({ status: 'blocked', runId: 'r1' })).toBe(true); // blocked 的 run 要看得见(等人加预算) expect(hasStartedRun(null)).toBe(false); }); it('顶上那句话:排队中说清前面还有几个,其余状态用中文标签而不是英文原文', () => { expect(pendingRunHint({ status: 'queued', queuePosition: 3 })).toContain('前面还有 2 个任务'); expect(pendingRunHint({ status: 'queued', queuePosition: 1 })).toContain('排在队首'); expect(pendingRunHint({ status: 'queued' })).toContain('服务端没给位次'); expect(pendingRunHint({ status: 'draft' })).toContain('草稿'); expect(pendingRunHint({ status: 'draft' })).not.toContain('draft'); }); });