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('patchTask:title/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('patchTask:complexity 修改 → status 重置为新初始态 + status.changed 事件', () => { const s = freshStore(); const events: Array<{ type: string; payload: Record }> = []; 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(); }); // ---------- patchTask:deps 编辑 ---------- 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('patchTask:deps 引用不存在/跨项目任务被拒绝', () => { 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.changed(ready↔blocked 重算)', () => { const s = freshStore(); const events: Array<{ taskId: string | null; type: string; payload: Record }> = []; 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(); });