Files
maestro/test/patch.test.ts
T
wangjia 72b174fd44 feat: 支持依赖(deps)创建后编辑 (tsk_UQ7sNRM0Yi1L)
deps 此前仅建任务时可设,本次让 PATCH /api/tasks/:id 受理 deps,
拆解后可随时调整任务依赖,无需删重建。

- API:PATCH /api/tasks/:id 受理 deps:string[](类型校验)
- Store.patchTask:写 deps 前复用建任务校验(存在/同项目/非自身)
  并加 DFS 环检测(拒绝 A→B→A);越权/缺失/成环 → StoreError(400)
- 仅允许在未进入执行链路的状态改 deps(与 complexity 同一组守卫)
- 写后调 reconcileDeps 重算:未满足 ready→blocked、刚补全 blocked→ready,
  广播 status.changed
- 看板:任务详情加依赖编辑器(多选当前项目其它任务,排除自身与会成环者),
  保存走 PATCH
- 测试:加/删 dep、已 done 依赖放行、环检测、跨项目/缺失拒绝、
  执行链路状态拒绝、status.changed 重算(test/patch.test.ts)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:33:39 +08:00

205 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Store, StoreError } from '../src/store/index.js';
function freshStore(): Store {
return new Store(':memory:');
}
// ---------- patchProject ----------
test('patchProject:更新 autonomy/concurrency/verifyCmd/model/status', () => {
const s = freshStore();
const p = s.createProject({ name: 'pp', repoPath: '/tmp/pp-' + Math.random() });
assert.equal(p.lastSyncAt, null);
const updated = s.patchProject(p.id, {
autonomy: 'auto-easy', concurrency: 3, verifyCmd: 'npm test', model: 'sonnet', status: 'paused',
});
assert.equal(updated.autonomy, 'auto-easy');
assert.equal(updated.concurrency, 3);
assert.equal(updated.verifyCmd, 'npm test');
assert.equal(updated.model, 'sonnet');
assert.equal(updated.status, 'paused');
// 部分字段更新不影响其余字段;null 清空
const again = s.patchProject(p.id, { verifyCmd: null, status: 'active' });
assert.equal(again.verifyCmd, null);
assert.equal(again.autonomy, 'auto-easy');
assert.equal(again.status, 'active');
s.close();
});
test('patchProject:非法值被拒(autonomy/concurrency/status', () => {
const s = freshStore();
const p = s.createProject({ name: 'pv', repoPath: '/tmp/pv-' + Math.random() });
assert.throws(() => s.patchProject(p.id, { autonomy: 'yolo' as never }), StoreError);
assert.throws(() => s.patchProject(p.id, { concurrency: 0 }), StoreError);
assert.throws(() => s.patchProject(p.id, { concurrency: 1.5 }), StoreError);
assert.throws(() => s.patchProject(p.id, { status: 'stopped' as never }), StoreError);
assert.throws(() => s.patchProject('prj_nope', { concurrency: 1 }), StoreError);
s.close();
});
// ---------- patchTask ----------
test('patchTasktitle/priority 更新', () => {
const s = freshStore();
const p = s.createProject({ name: 'pt', repoPath: '/tmp/pt-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: '原标题', complexity: 'easy' });
assert.equal(t.priority, 1); // 默认 P1(中)
const u = s.patchTask(t.id, { title: '新标题', priority: 0 });
assert.equal(u.title, '新标题');
assert.equal(u.priority, 0); // P0 最高
assert.equal(u.status, 'ready'); // 未动 complexity 不重置状态
assert.throws(() => s.patchTask(t.id, { title: ' ' }), StoreError);
assert.throws(() => s.patchTask(t.id, { priority: 5 }), StoreError); // 超出 0..2
assert.throws(() => s.patchTask(t.id, { priority: -1 }), StoreError);
s.close();
});
test('patchTaskcomplexity 修改 → status 重置为新初始态 + status.changed 事件', () => {
const s = freshStore();
const events: Array<{ type: string; payload: Record<string, unknown> }> = [];
s.subscribe((e) => events.push({ type: e.type, payload: e.payload }));
const p = s.createProject({ name: 'pc', repoPath: '/tmp/pc-' + Math.random() });
// easy(ready) → hard:重置为 analyzing
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
const u = s.patchTask(t.id, { complexity: 'hard' });
assert.equal(u.complexity, 'hard');
assert.equal(u.status, 'analyzing');
const sc = events.filter((e) => e.type === 'status.changed').at(-1);
assert.equal(sc?.payload.from, 'ready');
assert.equal(sc?.payload.to, 'analyzing');
// hard(analyzing) → medium:重置为 speccing
const u2 = s.patchTask(t.id, { complexity: 'medium' });
assert.equal(u2.status, 'speccing');
// 同值修改 = no-op,不重置
s.transition(t.id, 'spec_review');
const u3 = s.patchTask(t.id, { complexity: 'medium' });
assert.equal(u3.status, 'spec_review');
// spec_review 在允许列表内:可改
const u4 = s.patchTask(t.id, { complexity: 'easy' });
assert.equal(u4.status, 'ready');
s.close();
});
test('patchTask:执行链路状态下改 complexity 被拒(StoreError → API 400', () => {
const s = freshStore();
const p = s.createProject({ name: 'pr', repoPath: '/tmp/pr-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'y', complexity: 'easy' }); // ready
s.transition(t.id, 'queued');
assert.throws(
() => s.patchTask(t.id, { complexity: 'hard' }),
(e: unknown) => e instanceof StoreError && (e as Error).message.includes('不允许修改复杂度'),
);
// queued 下 title 仍可改
assert.equal(s.patchTask(t.id, { title: 'z' }).title, 'z');
// done(终态)同样拒绝
s.transition(t.id, 'executing');
s.transition(t.id, 'exec_review');
s.decide(t.id, 'accept', 'user');
assert.throws(() => s.patchTask(t.id, { complexity: 'medium' }), StoreError);
s.close();
});
// ---------- patchTaskdeps 编辑 ----------
test('patchTask:加 dep → ready 落位 blocked;删 dep → 重回 ready', () => {
const s = freshStore();
const p = s.createProject({ name: 'pd', repoPath: '/tmp/pd-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' }); // ready
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' }); // ready,无依赖
assert.equal(b.status, 'ready');
// 给 B 加未完成依赖 A → B 应被重算为 blocked
const b1 = s.patchTask(b.id, { deps: [a.id] });
assert.deepEqual(b1.deps, [a.id]);
assert.equal(b1.status, 'blocked');
// 删除依赖 → 依赖已满足,B 重回 ready
const b2 = s.patchTask(b.id, { deps: [] });
assert.deepEqual(b2.deps, []);
assert.equal(b2.status, 'ready');
s.close();
});
test('patchTask:补全依赖(dep 已 done)→ blocked 直接放行 ready', () => {
const s = freshStore();
const p = s.createProject({ name: 'pd2', repoPath: '/tmp/pd2-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
s.forceDone(a.id, 'test'); // A 直接 done
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' }); // ready
// 即便加的是已完成的依赖,B 仍应保持可执行 ready
const b1 = s.patchTask(b.id, { deps: [a.id] });
assert.equal(b1.status, 'ready');
s.close();
});
test('patchTask:环检测拒绝(A→B→A', () => {
const s = freshStore();
const p = s.createProject({ name: 'pcyc', repoPath: '/tmp/pcyc-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] }); // B→A
// 让 A 依赖 B 会成环 → 拒绝
assert.throws(
() => s.patchTask(a.id, { deps: [b.id] }),
(e: unknown) => e instanceof StoreError && (e as Error).message.includes('环'),
);
// 自依赖也拒绝
assert.throws(() => s.patchTask(a.id, { deps: [a.id] }), StoreError);
s.close();
});
test('patchTaskdeps 引用不存在/跨项目任务被拒绝', () => {
const s = freshStore();
const p1 = s.createProject({ name: 'pj1', repoPath: '/tmp/pj1-' + Math.random() });
const p2 = s.createProject({ name: 'pj2', repoPath: '/tmp/pj2-' + Math.random() });
const t = s.createTask({ projectId: p1.id, title: 'T', complexity: 'easy' });
const other = s.createTask({ projectId: p2.id, title: 'O', complexity: 'easy' });
assert.throws(() => s.patchTask(t.id, { deps: ['tsk_nope'] }), StoreError);
assert.throws(() => s.patchTask(t.id, { deps: [other.id] }), StoreError);
s.close();
});
test('patchTask:执行链路状态(executing/exec_review/done)下改 deps 被拒', () => {
const s = freshStore();
const p = s.createProject({ name: 'pdg', repoPath: '/tmp/pdg-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const t = s.createTask({ projectId: p.id, title: 'T', complexity: 'easy' }); // ready
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
assert.throws(
() => s.patchTask(t.id, { deps: [a.id] }),
(e: unknown) => e instanceof StoreError && (e as Error).message.includes('不允许修改依赖'),
);
s.transition(t.id, 'exec_review');
assert.throws(() => s.patchTask(t.id, { deps: [a.id] }), StoreError);
s.decide(t.id, 'accept', 'user'); // done
assert.throws(() => s.patchTask(t.id, { deps: [a.id] }), StoreError);
s.close();
});
test('patchTask:改 deps 广播 status.changedready↔blocked 重算)', () => {
const s = freshStore();
const events: Array<{ taskId: string | null; type: string; payload: Record<string, unknown> }> = [];
s.subscribe((e) => events.push({ taskId: e.taskId, type: e.type, payload: e.payload }));
const p = s.createProject({ name: 'pde', repoPath: '/tmp/pde-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' });
s.patchTask(b.id, { deps: [a.id] });
const sc = events.filter((e) => e.taskId === b.id && e.type === 'status.changed').at(-1);
assert.equal(sc?.payload.from, 'ready');
assert.equal(sc?.payload.to, 'blocked');
s.close();
});