goal-task-model.test.js
8.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/**
* 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']);
});
});