loop-controller.test.js
4.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
/**
* 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 l2_rounds/l2_budget', () => {
const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
expect(u.units).toHaveLength(3);
for (const x of u.units) {
expect(typeof x.l2_rounds).toBe('number');
expect(typeof x.l2_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/);
});
});
});