/** * loop-controller (P1) 单测 * - callAgent (mock 模式) 能从 fixture 产出文件 + git commit * - runDocsPhase 串行跑 PM + Architect,产物齐全 * - getProjectState 返回 prd_rev/sdd_rev/units */ process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; const fs = require('fs'); const path = require('path'); const os = require('os'); const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-lc-')); process.env.HOME = TEST_HOME; const pathResolver = require('../../services/devloop/path-resolver'); const lc = require('../../src/core/devloop/loop-controller'); describe('loop-controller (P1)', () => { let pid, cid; const created = []; afterAll(() => { // 清理 sandbox 项目目录 for (const c of created) { try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); } catch { /* ignore */ } } }); async function fresh() { pathResolver._resetForTesting(); cid = 'c-test-' + Math.random().toString(36).slice(2, 7); created.push(cid); const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'unit-test', }); pid = proj.pid; return proj; } describe('callAgent (mock devloop-pm)', () => { beforeAll(async () => { await fresh(); }); it('produces files from fixture + commits', async () => { const r = await lc.callAgent(pid, 'devloop-pm', 'requirement: hello'); expect(r.ok).toBe(true); expect(r.mock).toBe(true); expect(r.files_written.length).toBeGreaterThanOrEqual(10); expect(r.commit).not.toBeNull(); expect(r.commit.sha).toBeTruthy(); const root = pathResolver.resolveProjectRoot(pid); expect(fs.existsSync(path.join(root, 'prd', 'index.html'))).toBe(true); expect(fs.existsSync(path.join(root, 'agent-state', 'pm.json'))).toBe(true); }); it('subsequent call still commits (timeline.ndjson always appended)', async () => { const r = await lc.callAgent(pid, 'devloop-pm', 'second time same fixture'); // mock 产物相同但 timeline.ndjson 会追加一行 → 总有 diff,有 commit expect(r.commit).not.toBeNull(); expect(r.commit.sha).toBeTruthy(); }); }); describe('runDocsPhase (PM → Architect)', () => { beforeAll(async () => { await fresh(); }); let phase; it('completes both agents', async () => { phase = await lc.runDocsPhase(pid, '做一个 TODO 列表'); expect(phase.ok).toBe(true); expect(phase.pm.commit?.sha).toBeTruthy(); expect(phase.architect.commit?.sha).toBeTruthy(); }); it('produces full P1 acceptance file tree', () => { const root = pathResolver.resolveProjectRoot(pid); const required = [ 'project.json', 'prd/index.html', 'prd/reading-guide.html', 'prd/product/background.html', 'prd/product/personas.html', 'prd/product/scope.html', 'prd/feature/feature-tree.html', 'prd/flow/user-flows.html', 'sdd/index.html', 'sdd/mvcp/model.html', 'sdd/mvcp/view.html', 'sdd/mvcp/control.html', 'sdd/mvcp/process.html', 'units.json', 'integration/spec.json', 'agent-state/pm.json', 'agent-state/pm-todo.json', 'agent-state/architect.json', 'agent-state/architect-todo.json', 'history/prd-rev-001.html', 'history/sdd-rev-001.html', ]; const missing = required.filter(f => !fs.existsSync(path.join(root, f))); expect(missing).toEqual([]); }); it('units.json has 3 units with project-level l2_rounds/l2_budget', () => { const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); expect(u.units).toHaveLength(3); expect(typeof u.l2_rounds).toBe('number'); expect(typeof u.l2_budget).toBe('number'); for (const x of u.units) { expect(x.status).toBe('pending'); expect(typeof x.retry_budget).toBe('number'); } }); it('getProjectState reflects produced docs', () => { const s = lc.getProjectState(pid); expect(s.prd_site).toBe(true); expect(s.sdd_site).toBe(true); expect(s.prd_rev).toBe(1); expect(s.sdd_rev).toBe(1); expect(s.units).toHaveLength(3); expect(Array.isArray(s.timeline_tail)).toBe(true); expect(s.timeline_tail.length).toBeGreaterThan(0); }); }); describe('runDocsPhase rejects bad input', () => { beforeAll(async () => { await fresh(); }); it('throws when requirement missing', async () => { await expect(lc.runDocsPhase(pid, '')).rejects.toThrow(/requirement/); }); }); });