loop-controller.test.js 4.58 KB
/**
 * 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/);
    });
  });
});