/** * 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 = ` Scope

S1

desc

T
code block
card
`; const VALID_SDD_HTML = ` Model

M-1

x

`; 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: '

no article tag

', }, 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 = `
A
B
`; const newHtml = `
A
B-changed
C
`; 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); }); }); });