doc-tools.test.js 6.49 KB
/**
 * doc-tools 单测 (PM + Architect)
 */

const fs = require('fs');
const path = require('path');
const os = require('os');

const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-dt-'));
process.env.HOME = TEST_HOME;

const pathResolver = require('../../../services/devloop/path-resolver');
const docStore = require('../../../services/devloop/doc-store');
const tools = require('../../../src/tools/devloop/doc-tools');

function setupProject() {
  pathResolver._resetForTesting();
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dt-proj-'));
  const pid = 'p-doctest';
  const reg = {
    [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' },
  };
  fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true });
  fs.writeFileSync(
    path.join(TEST_HOME, '.devloop', 'linked-projects.json'),
    JSON.stringify(reg)
  );
  pathResolver._resetForTesting();
  return { pid, root };
}

const VALID_PRD_HTML = `
<!doctype html>
<html><head><title>Scope</title></head><body>
<article data-doc="prd" data-rev="1">
  <section data-rev="1">
    <h3>S1</h3><p>desc</p>
    <table><tr><td>T</td></tr></table>
    <svg></svg>
    <pre>code block</pre>
    <div class="card">card</div>
  </section>
</article>
</body></html>`;

const VALID_SDD_HTML = `
<!doctype html>
<html><head><title>Model</title></head><body>
<article data-doc="sdd" data-rev="1">
  <section data-rev="1"><h3>M-1</h3><p>x</p></section>
</article>
</body></html>`;

describe('doc-tools', () => {
  let env;
  let ctx;
  beforeEach(() => {
    env = setupProject();
    ctx = { pid: env.pid, agent: 'pm' };
  });
  afterEach(() => {
    fs.rmSync(env.root, { recursive: true, force: true });
  });

  describe('bootstrap_prd_site / bootstrap_sdd_site', () => {
    it('creates prd site skeleton', async () => {
      const r = await tools.bootstrap_prd_site.execute({}, ctx);
      expect(r.ok).toBe(true);
      expect(fs.existsSync(path.join(env.root, 'prd', 'product'))).toBe(true);
      expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'shell.css'))).toBe(true);
      expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'search-index.json'))).toBe(true);
    });

    it('creates sdd site skeleton with mvcp/', async () => {
      const r = await tools.bootstrap_sdd_site.execute({}, ctx);
      expect(r.ok).toBe(true);
      expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp'))).toBe(true);
    });
  });

  describe('write_chapter', () => {
    it('writes valid PRD chapter', async () => {
      await tools.bootstrap_prd_site.execute({}, ctx);
      const r = await tools.write_chapter.execute({
        kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML,
      }, ctx);
      expect(r.ok).toBe(true);
      expect(fs.existsSync(path.join(env.root, 'prd', 'product', 'scope.html'))).toBe(true);
    });

    it('rejects invalid HTML', async () => {
      await tools.bootstrap_prd_site.execute({}, ctx);
      await expect(tools.write_chapter.execute({
        kind: 'prd', relPath: 'product/scope.html', html: '<p>no article tag</p>',
      }, ctx)).rejects.toThrow();
    });
  });

  describe('write_mvcp_page', () => {
    it('writes sdd/mvcp/model.html', async () => {
      await tools.bootstrap_sdd_site.execute({}, ctx);
      const r = await tools.write_mvcp_page.execute({
        which: 'model', html: VALID_SDD_HTML,
      }, ctx);
      expect(r.ok).toBe(true);
      expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp', 'model.html'))).toBe(true);
    });
  });

  describe('diff_html', () => {
    it('detects added/changed/removed sections', async () => {
      const oldHtml = `<article><section data-rev="1">A</section><section data-rev="2">B</section></article>`;
      const newHtml = `<article><section data-rev="1">A</section><section data-rev="2">B-changed</section><section data-rev="3">C</section></article>`;
      const r = await tools.diff_html.execute({ oldHtml, newHtml }, ctx);
      expect(r.added).toEqual(['3']);
      expect(r.changed).toEqual(['2']);
      expect(r.removed).toEqual([]);
    });
  });

  describe('diff_units', () => {
    it('detects added unit', async () => {
      const r = await tools.diff_units.execute({
        newUnits: [
          { id: 'u-a', contract_hash: 'h1' },
          { id: 'u-b', contract_hash: 'h2' },
        ],
        oldUnits: [{ id: 'u-a', contract_hash: 'h1' }],
      }, ctx);
      expect(r.added).toContain('u-b');
    });

    it('detects changed unit (contract_hash diff)', async () => {
      const r = await tools.diff_units.execute({
        newUnits: [{ id: 'u-a', contract_hash: 'h2' }],
        oldUnits: [{ id: 'u-a', contract_hash: 'h1' }],
      }, ctx);
      expect(r.changed).toContain('u-a');
    });
  });

  describe('echo_diff', () => {
    it('emits SSE prd.updated', async () => {
      const events = [];
      ctx.sseBroker = { emit: (pid, k, p) => events.push({ pid, k, p }) };
      const r = await tools.echo_diff.execute({
        kind: 'prd', rev: 2, summary: 'scope rev 2', sections_diff: { added: ['s2'] },
      }, ctx);
      expect(r.ok).toBe(true);
      expect(events).toHaveLength(1);
      expect(events[0].k).toBe('prd.updated');
    });
  });

  describe('update_search_index', () => {
    it('builds prd index after writes', async () => {
      await tools.bootstrap_prd_site.execute({}, ctx);
      await tools.write_chapter.execute({
        kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML,
      }, ctx);
      const r = await tools.update_search_index.execute({ target: 'prd' }, ctx);
      expect(r.ok).toBe(true);
      expect(r.built[0].pages).toBeGreaterThan(0);
    });
  });

  describe('hash_contract', () => {
    it('returns sha256-prefixed 16-hex hash', async () => {
      const r = await tools.hash_contract.execute({
        contract: { name: 'login', method: 'POST', path: '/api/login' },
      }, ctx);
      expect(r.hash).toMatch(/^sha256:[a-f0-9]{16}$/);
    });

    it('is deterministic for canonical-equivalent contracts', async () => {
      const a = await tools.hash_contract.execute({
        contract: { a: 1, b: 2 },
      }, ctx);
      const b = await tools.hash_contract.execute({
        contract: { b: 2, a: 1 },
      }, ctx);
      expect(a.hash).toBe(b.hash);
    });
  });

  describe('validate_prd_site', () => {
    it('flags missing required chapters', async () => {
      await tools.bootstrap_prd_site.execute({}, ctx);
      const r = await tools.validate_prd_site.execute({}, ctx);
      expect(r.ok).toBe(false);
      expect(r.issues.some(i => i.includes('missing chapter'))).toBe(true);
    });
  });
});