goal-task-model.test.js 18 KB
/**
 * 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,
} 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('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');
  });
});