Files
maestro/test/store.test.ts
T
wangjia fa472a06a7 feat: 同步引擎+Agent配置+依赖自动落位+看板大改(预览/md渲染/筛选/徽章组)
后端:
- src/sync/todo-sync.ts: todo.json 单向同步引擎(导入=首次同步,幂等,source_ref 映射,
  subs 复杂度修正为 easy,旧侧 done 历史事实优先 forceDone)
- 依赖自动落位:ready 意图按 deps 落位 blocked,依赖全 done 自动放行,
  daemon 启动 reconcileDeps 对账,手动绕过会弹回
- 新 API: PATCH projects/:id(autonomy/concurrency)、POST :id/sync、GET /api/agents、
  PATCH tasks/:id(title/priority/complexity 重置)
- daemon 定时同步(MAESTRO_SYNC_INTERVAL 默认 300s) + project.synced 事件
- priority 语义翻转: P0 最高/P1 默认/P2 最低,取值限 0..2,排序/映射/MCP/CLI 全跟进
- 静态服务发 no-cache 头(修浏览器吃旧 CSS/JS)
- schema 迁移: tasks.source_ref / projects.last_sync_at(ensureColumn 平滑升级旧库)

看板:
- 全屏预览模式(94vh 读完整方案+就地裁决,Esc/遮罩/裁决自动关闭)
- 产出 markdown 渲染为 HTML(零依赖渲染器,转义优先)
- 任务树筛选(复杂度/状态分组/关键字)+ 顶栏徽章组(待审批/可执行/执行中,hover 展开)
- 依赖可视化:详情 DEPS 区块 + 行内⛓等依赖 + 锚点跳转定位
- 按钮收敛:提交评审/编辑产出移除(CC 经 MCP 操作),界面只留用户动作
- Agent 执行面板 + 项目配置(并发/工作模式)+ 同步按钮

测试:21 个全过(新增 sync 幂等/迁移/patch/依赖落位/对账幂等)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:58:28 +08:00

167 lines
7.3 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:');
}
test('project + task creation, complexity routes initial status', () => {
const s = freshStore();
const p = s.createProject({ name: 'demo', repoPath: '/tmp/demo-' + Math.random() });
assert.equal(p.status, 'active');
const hard = s.createTask({ projectId: p.id, title: 'big', complexity: 'hard' });
const medium = s.createTask({ projectId: p.id, title: 'mid', complexity: 'medium' });
const easy = s.createTask({ projectId: p.id, title: 'small', complexity: 'easy' });
assert.equal(hard.status, 'analyzing'); // Hard 强制分析
assert.equal(medium.status, 'speccing'); // Medium 写方案
assert.equal(easy.status, 'ready'); // Easy 直接可执行
s.close();
});
test('Easy 路径:写操作 → ready → nextExecutable 领取', () => {
const s = freshStore();
const p = s.createProject({ name: 'e', repoPath: '/tmp/e-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'tweak', complexity: 'easy' });
s.setOperations(t.id, '改 README 一行');
const next = s.nextExecutable(p.id);
assert.equal(next?.id, t.id);
s.close();
});
test('Medium 闸:spec_review accept → readyreject 必带意见', () => {
const s = freshStore();
const p = s.createProject({ name: 'm', repoPath: '/tmp/m-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'medium' });
s.setSpec(t.id, '加一个接口,因为前端需要');
const inReview = s.transition(t.id, 'spec_review');
assert.equal(inReview.status, 'spec_review');
// reject 无意见 → 报错
assert.throws(() => s.decide(t.id, 'reject', 'user'), StoreError);
// reject 带意见 → 回到 speccing
const back = s.decide(t.id, 'reject', 'user', '方案漏了鉴权');
assert.equal(back.status, 'speccing');
assert.equal(back.approvals.at(-1)?.reason, '方案漏了鉴权');
// 重新评审 → accept → ready
s.transition(t.id, 'spec_review');
const ok = s.decide(t.id, 'accept', 'user');
assert.equal(ok.status, 'ready');
s.close();
});
test('Hard 闸:plan_review accept → decomposed,子任务汇总', () => {
const s = freshStore();
const p = s.createProject({ name: 'h', repoPath: '/tmp/h-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'epic', complexity: 'hard' });
s.setPlan(t.id, '分析…拆 2 个子任务');
s.transition(t.id, 'plan_review');
const decomposed = s.decide(t.id, 'accept', 'user');
assert.equal(decomposed.status, 'decomposed');
const c1 = s.createTask({ projectId: p.id, parentId: t.id, title: '子1', complexity: 'easy' });
assert.equal(c1.depth, 2);
// 拆解后容器不应被 nextExecutable 领取(它有子任务且非 ready)
s.setOperations(c1.id, '做子1');
const next = s.nextExecutable(p.id);
assert.equal(next?.id, c1.id); // 取到叶子,不是容器
s.close();
});
test('层级上限:medium 默认 3 层,第 4 层拒绝', () => {
const s = freshStore();
const p = s.createProject({ name: 'd', repoPath: '/tmp/d-' + Math.random() });
const l1 = s.createTask({ projectId: p.id, title: 'l1', complexity: 'medium' });
const l2 = s.createTask({ projectId: p.id, parentId: l1.id, title: 'l2', complexity: 'medium' });
const l3 = s.createTask({ projectId: p.id, parentId: l2.id, title: 'l3', complexity: 'medium' });
assert.equal(l3.depth, 3);
assert.throws(() => s.createTask({ projectId: p.id, parentId: l3.id, title: 'l4', complexity: 'medium' }), StoreError);
s.close();
});
test('非法状态流转被守卫拒绝', () => {
const s = freshStore();
const p = s.createProject({ name: 'g', repoPath: '/tmp/g-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); // ready
assert.throws(() => s.transition(t.id, 'done'), StoreError); // ready→done 非法
s.close();
});
test('exec_review 结果闸:accept → done', () => {
const s = freshStore();
const p = s.createProject({ name: 'x', repoPath: '/tmp/x-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'run', complexity: 'easy' });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.setResult(t.id, { branch: 'maestro/run', worktree: '/wt', diffSummary: '+1 -0', commits: ['abc'], prUrl: null });
s.transition(t.id, 'exec_review');
const done = s.decide(t.id, 'accept', 'user');
assert.equal(done.status, 'done');
s.close();
});
test('依赖未满足时不可领取', () => {
const s = freshStore();
const p = s.createProject({ name: 'dep', repoPath: '/tmp/dep-' + 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] });
s.setOperations(a.id, 'a'); s.setOperations(b.id, 'b');
// 优先级相同时 A 先建,但 B 依赖 A 未 done → 只能取到 A
const next = s.nextExecutable(p.id);
assert.equal(next?.id, a.id);
s.close();
});
test('事件订阅:状态变更广播', () => {
const s = freshStore();
const got: string[] = [];
s.subscribe((e) => got.push(e.type));
const p = s.createProject({ name: 'ev', repoPath: '/tmp/ev-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 't', complexity: 'easy' });
s.transition(t.id, 'queued');
assert.ok(got.includes('task.created'));
assert.ok(got.includes('status.changed'));
s.close();
});
test('依赖自动落位:建任务带未完成依赖 → blocked;依赖 done → 自动放行 ready', () => {
const s = freshStore();
const p = s.createProject({ name: 'ab', repoPath: '/tmp/ab-' + 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] });
assert.equal(b.status, 'blocked'); // 建即落位 blocked,而非假 ready
// 手动把 blocked 拉成 ready 也会被按依赖弹回(仍 blocked)
assert.equal(s.transition(b.id, 'ready').status, 'blocked');
// A 走完整闭环到 done → B 自动放行
const evts = [];
s.subscribe((e) => evts.push(e));
s.transition(a.id, 'queued');
s.transition(a.id, 'executing');
s.transition(a.id, 'exec_review');
s.decide(a.id, 'accept', 'user');
assert.equal(s.getTask(b.id).status, 'ready'); // 自动 blocked→ready
const auto = evts.find((e) => e.taskId === b.id && e.payload.auto === 'deps-met');
assert.ok(auto, '应有 deps-met 自动放行事件');
s.close();
});
test('reconcileDeps:存量 ready 但依赖未满足 → 纠正为 blocked(幂等)', () => {
const s = freshStore();
const p = s.createProject({ name: 'rc', repoPath: '/tmp/rc-' + 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 被直接写成 ready
s.db.prepare(`UPDATE tasks SET status = 'ready' WHERE id = ?`).run(b.id);
const r1 = s.reconcileDeps(p.id);
assert.equal(r1.blocked, 1);
assert.equal(s.getTask(b.id).status, 'blocked');
const r2 = s.reconcileDeps(p.id); // 幂等
assert.equal(r2.blocked + r2.released, 0);
s.close();
});