unit-registry.test.js 5.45 KB
/**
 * 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([]);
    });
  });
});