/** * unitRegistry 单测 — 跑 FSM 转换 */ const fs = require('fs'); const path = require('path'); const os = require('os'); const pathResolver = require('../../services/devloop/path-resolver'); const docStore = require('../../services/devloop/doc-store'); const unitRegistry = require('../../services/devloop/unit-registry'); describe('unitRegistry', () => { let pid; beforeEach(() => { pathResolver._resetForTesting(); const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); pid = r.pid; // 准备 units.json const units = { project_id: pid, prd_rev: '1', sdd_rev: '1', l2_rounds: 0, l2_budget: 3, units: [ { id: 'auth-be', type: 'be', status: 'pending', depends_on: [], retries: 0, retry_budget: 5, contract_hash: 'sha256:aaaaaaaaaaaaaaaa', }, { id: 'auth-fe', type: 'fe', status: 'pending', depends_on: ['auth-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:bbbbbbbbbbbbbbbb', }, ], }; docStore.writeUnits(pid, units); }); afterEach(() => { const root = pathResolver.resolveProjectRoot(pid); fs.rmSync(root, { recursive: true, force: true }); }); describe('list / pending / passed', () => { it('lists all units', () => { expect(unitRegistry.list(pid)).toHaveLength(2); }); it('all start pending', () => { expect(unitRegistry.pending(pid)).toHaveLength(2); expect(unitRegistry.passed(pid)).toHaveLength(0); }); }); describe('canTransition', () => { it('pending → coding ok', () => { expect(unitRegistry.canTransition('pending', 'coding')).toBe(true); }); it('coding → testing ok', () => { expect(unitRegistry.canTransition('coding', 'testing')).toBe(true); }); it('pending → passed FORBIDDEN', () => { expect(unitRegistry.canTransition('pending', 'passed')).toBe(false); }); it('passed → pending ok (L2 reset)', () => { expect(unitRegistry.canTransition('passed', 'pending')).toBe(true); }); it('blocked → pending ok (人类干预)', () => { expect(unitRegistry.canTransition('blocked', 'pending')).toBe(true); }); }); describe('markCoding / markTesting / pass', () => { it('full L1 happy path', () => { unitRegistry.markCoding(pid, 'auth-be'); expect(unitRegistry.get(pid, 'auth-be').status).toBe('coding'); unitRegistry.markTesting(pid, 'auth-be'); expect(unitRegistry.get(pid, 'auth-be').status).toBe('testing'); unitRegistry.pass(pid, 'auth-be', 'sha256:newhash00000000'); const u = unitRegistry.get(pid, 'auth-be'); expect(u.status).toBe('passed'); expect(u.contract_hash).toBe('sha256:newhash00000000'); }); it('pending → testing throws', () => { expect(() => unitRegistry.markTesting(pid, 'auth-be')) .toThrow(unitRegistry.InvalidTransitionError); }); }); describe('fail + retries', () => { it('coding/testing → failed accumulates retries', () => { unitRegistry.markCoding(pid, 'auth-be'); unitRegistry.markTesting(pid, 'auth-be'); unitRegistry.fail(pid, 'auth-be', 'test red'); expect(unitRegistry.get(pid, 'auth-be').retries).toBe(1); unitRegistry.markCoding(pid, 'auth-be'); unitRegistry.markTesting(pid, 'auth-be'); unitRegistry.fail(pid, 'auth-be', 'test red 2'); expect(unitRegistry.get(pid, 'auth-be').retries).toBe(2); }); }); describe('block + reset', () => { it('failed → blocked then reset', () => { unitRegistry.markCoding(pid, 'auth-be'); unitRegistry.markTesting(pid, 'auth-be'); unitRegistry.fail(pid, 'auth-be', 'red'); unitRegistry.block(pid, 'auth-be', 'budget exceeded'); expect(unitRegistry.get(pid, 'auth-be').status).toBe('blocked'); const r = unitRegistry.reset(pid, ['auth-be']); expect(r.reset).toHaveLength(1); expect(unitRegistry.get(pid, 'auth-be').status).toBe('pending'); expect(unitRegistry.get(pid, 'auth-be').retries).toBe(0); }); it('reset skips coding/testing units with reason', () => { unitRegistry.markCoding(pid, 'auth-be'); const r = unitRegistry.reset(pid, ['auth-be', 'ghost']); expect(r.reset).toHaveLength(0); expect(r.skipped.find(s => s.id === 'auth-be').reason).toMatch(/coding/); expect(r.skipped.find(s => s.id === 'ghost').reason).toBe('not_found'); }); }); describe('l2GateOpen', () => { it('false when any not passed', () => { expect(unitRegistry.l2GateOpen(pid)).toBe(false); }); it('true when all passed', () => { for (const id of ['auth-be', 'auth-fe']) { unitRegistry.markCoding(pid, id); unitRegistry.markTesting(pid, id); unitRegistry.pass(pid, id, 'sha256:0000000000000000'); } expect(unitRegistry.l2GateOpen(pid)).toBe(true); }); }); describe('diffUnits', () => { it('detects added/changed/removed', () => { const oldList = [ { id: 'a-be', contract_hash: 'sha256:1' }, { id: 'b-be', contract_hash: 'sha256:2' }, ]; const newList = [ { id: 'a-be', contract_hash: 'sha256:1' }, // same { id: 'b-be', contract_hash: 'sha256:CHANGED' }, // changed { id: 'c-be', contract_hash: 'sha256:3' }, // added ]; const r = unitRegistry.diffUnits(oldList, newList); expect(r.added).toEqual(['c-be']); expect(r.changed).toEqual(['b-be']); expect(r.removed).toEqual([]); }); }); });