/**
* 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
`;
const VALID_SDD_HTML = `
Model
`;
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 = ``;
const newHtml = ``;
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);
});
});
});