goal-task-model.test.js 8.37 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)。
 */
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']);
  });
});