// tools/project-graph.test.mjs import { describe, it, expect } from 'vitest'; import { parseGraph, nodeDistrict, DISTRICT_RULES } from './project-graph.mjs'; describe('parseGraph', () => { it('парсит node-link JSON в {nodes, links}', () => { const text = JSON.stringify({ directed: false, multigraph: false, graph: {}, nodes: [{ id: 'a', label: 'A', file_type: 'code', source_file: 'tools/x.mjs', community: 3 }], links: [{ source: 'a', target: 'b', relation: 'references' }], }); const g = parseGraph(text); expect(g.nodes).toHaveLength(1); expect(g.links).toHaveLength(1); expect(g.nodes[0].id).toBe('a'); }); it('битый JSON → {nodes:[], links:[]} (не крашит)', () => { expect(parseGraph('{не json')).toEqual({ nodes: [], links: [] }); }); it('отсутствующие массивы → пустые', () => { expect(parseGraph('{}')).toEqual({ nodes: [], links: [] }); }); it('не-строка → {nodes:[], links:[]}', () => { expect(parseGraph(null)).toEqual({ nodes: [], links: [] }); }); }); describe('nodeDistrict', () => { it('docs/superpowers → docs-superpowers', () => { expect(nodeDistrict('docs/superpowers/specs/x.md')).toBe('docs-superpowers'); }); it('docs/observer → docs-observer', () => { expect(nodeDistrict('docs/observer/STATUS.md')).toBe('docs-observer'); }); it('app/resources/js → app-frontend', () => { expect(nodeDistrict('app/resources/js/views/DealsView.vue')).toBe('app-frontend'); }); it('app/app (php backend) → app-backend', () => { expect(nodeDistrict('app/app/Services/LeadRouter.php')).toBe('app-backend'); }); it('tools/ → tools', () => { expect(nodeDistrict('tools/router-engine.mjs')).toBe('tools'); }); it('неизвестный/пустой источник → other', () => { expect(nodeDistrict('some/random/thing.txt')).toBe('other'); expect(nodeDistrict('')).toBe('other'); expect(nodeDistrict(null)).toBe('other'); }); it('DISTRICT_RULES заморожен и упорядочен (specific перед generic)', () => { expect(Object.isFrozen(DISTRICT_RULES)).toBe(true); const docsSuperIdx = DISTRICT_RULES.findIndex((r) => r.prefix === 'docs/superpowers/'); const docsIdx = DISTRICT_RULES.findIndex((r) => r.prefix === 'docs/'); expect(docsSuperIdx).toBeGreaterThanOrEqual(0); expect(docsSuperIdx).toBeLessThan(docsIdx); // specific раньше generic }); }); import { pruneJunk, PRUNE_PREFIXES } from './project-graph.mjs'; describe('pruneJunk', () => { const nodes = [ { id: '1', source_file: 'docs/x.md' }, { id: '2', source_file: 'app/public/build/assets/app-x.css' }, { id: '3', source_file: 'tools/router-engine.mjs' }, { id: '4', source_file: null }, ]; it('узлы с junk-префиксом отсечены, остальные сохранены', () => { const r = pruneJunk(nodes, ['app/public/build/']); expect(r.kept.map((n) => n.id)).toEqual(['1', '3', '4']); expect(r.prunedCount).toBe(1); }); it('PRUNE_PREFIXES заморожен и включает подтверждённый build', () => { expect(Object.isFrozen(PRUNE_PREFIXES)).toBe(true); expect(PRUNE_PREFIXES).toContain('app/public/build/'); }); it('не-массив → {kept:[], prunedCount:0}', () => { expect(pruneJunk(null, PRUNE_PREFIXES)).toEqual({ kept: [], prunedCount: 0 }); }); }); import { nodeDegrees, buildDistrictMap } from './project-graph.mjs'; describe('nodeDegrees', () => { it('считает степень по вхождениям id в source/target', () => { const links = [ { source: 'a', target: 'b' }, { source: 'a', target: 'c' }, { source: 'b', target: 'c' }, ]; const deg = nodeDegrees(links); expect(deg.get('a')).toBe(2); expect(deg.get('b')).toBe(2); expect(deg.get('c')).toBe(2); }); it('пустые/битые links → пустая карта', () => { expect(nodeDegrees(null).size).toBe(0); }); }); describe('buildDistrictMap (layer-0)', () => { const nodes = [ { id: 'a', label: 'A', source_file: 'tools/router-engine.mjs', community: 5 }, { id: 'b', label: 'B', source_file: 'tools/judge-engine.mjs', community: 5 }, { id: 'c', label: 'C', source_file: 'tools/escape-grant.mjs', community: 7 }, { id: 'd', label: 'D', source_file: 'docs/observer/STATUS.md', community: null }, { id: 'j', label: 'J', source_file: 'app/public/build/assets/x.css', community: 1 }, ]; const links = [ { source: 'a', target: 'b' }, { source: 'a', target: 'c' }, { source: 'b', target: 'c' }, ]; it('район tools имеет 3 узла, топ по степени, communities вторично', () => { const map = buildDistrictMap(nodes, links, { topNodesPerDistrict: 2 }); const tools = map.find((d) => d.district === 'tools'); expect(tools.nodeCount).toBe(3); expect(tools.topNodes[0].id).toBe('a'); // степень 2 (наибольшая, lexically первый при равенстве) expect(tools.topNodes).toHaveLength(2); // cap expect(tools.communities).toEqual([5, 7]); // вторичный сигнал, non-null, отсортирован }); it('junk-узлы (build) НЕ попадают в карту', () => { const map = buildDistrictMap(nodes, links, {}); expect(map.find((d) => d.district === 'app-frontend' || d.district === 'app-backend')).toBeUndefined(); expect(map.every((d) => d.district !== 'other' || d.nodeCount > 0)).toBe(true); }); it('район docs-observer: 1 узел, communities пуст (все null)', () => { const map = buildDistrictMap(nodes, links, {}); const obs = map.find((d) => d.district === 'docs-observer'); expect(obs.nodeCount).toBe(1); expect(obs.communities).toEqual([]); }); }); import { districtDetail } from './project-graph.mjs'; describe('districtDetail (layer-1)', () => { const nodes = [ { id: 'a', label: 'A', file_type: 'code', source_file: 'tools/router-engine.mjs', community: 5 }, { id: 'b', label: 'B', file_type: 'code', source_file: 'tools/judge-engine.mjs', community: 5 }, { id: 'c', label: 'C', file_type: 'code', source_file: 'tools/escape-grant.mjs', community: 7 }, { id: 'd', label: 'D', file_type: 'document', source_file: 'docs/observer/STATUS.md', community: null }, ]; it('возвращает узлы района + группировку по community', () => { const r = districtDetail(nodes, 'tools', {}); expect(r.district).toBe('tools'); expect(r.nodes.map((n) => n.id).sort()).toEqual(['a', 'b', 'c']); expect(r.byCommunity['5'].sort()).toEqual(['a', 'b']); expect(r.byCommunity['7']).toEqual(['c']); }); it('пустой район → пустые', () => { const r = districtDetail(nodes, 'nope', {}); expect(r.nodes).toEqual([]); expect(r.byCommunity).toEqual({}); }); it('узлы с community:null группируются под "none"', () => { const r = districtDetail(nodes, 'docs-observer', {}); expect(r.byCommunity.none).toEqual(['d']); }); }); import { readStaleness, gatherStalenessInputs } from './project-graph.mjs'; describe('readStaleness (✅O16 pure)', () => { it('needs_update присутствует → stale true', () => { const r = readStaleness({ needsUpdatePresent: true, graphCommit: 'aaa', headCommit: 'aaa', commitsBehind: 0, uncommittedCount: 0 }); expect(r).toEqual({ stale: true, commits_behind: 0, uncommitted: 0 }); }); it('commit рассинхрон → stale true даже без needs_update', () => { const r = readStaleness({ needsUpdatePresent: false, graphCommit: 'aaa', headCommit: 'bbb', commitsBehind: 12, uncommittedCount: 3 }); expect(r).toEqual({ stale: true, commits_behind: 12, uncommitted: 3 }); }); it('всё чисто → stale false', () => { const r = readStaleness({ needsUpdatePresent: false, graphCommit: 'aaa', headCommit: 'aaa', commitsBehind: 0, uncommittedCount: 0 }); expect(r).toEqual({ stale: false, commits_behind: 0, uncommitted: 0 }); }); it('кривые числа → 0 (не NaN)', () => { const r = readStaleness({ needsUpdatePresent: true, commitsBehind: 'x', uncommittedCount: undefined }); expect(r.commits_behind).toBe(0); expect(r.uncommitted).toBe(0); }); it('commits_behind=null сохраняется (SE11: устарел, величина неизвестна)', () => { const r = readStaleness({ needsUpdatePresent: true, commitsBehind: null, uncommittedCount: 2 }); expect(r.stale).toBe(true); expect(r.commits_behind).toBe(null); expect(r.uncommitted).toBe(2); }); }); describe('gatherStalenessInputs (обёртка через моки)', () => { it('собирает входы из инъектированных fs/git', () => { const readFileImpl = (p) => p.endsWith('needs_update') ? 'Run /graphify --update (4 changed)' : 'Built from commit: `81cbd8c1`'; const existsImpl = () => true; const gitImpl = (args) => args.includes('rev-list') ? '7' : ' M a.mjs\n?? b.mjs'; const inp = gatherStalenessInputs({ readFileImpl, existsImpl, gitImpl, headCommit: 'deadbeef' }); expect(inp.needsUpdatePresent).toBe(true); expect(inp.graphCommit).toBe('81cbd8c1'); expect(inp.commitsBehind).toBe(7); expect(inp.uncommittedCount).toBe(2); }); it('git rev-list бросил (граф-commit вне истории) → commitsBehind null (SE11)', () => { const readFileImpl = () => 'Built from commit: `81cbd8c1`'; const existsImpl = () => false; const gitImpl = (args) => { if (args.includes('rev-list')) throw new Error('bad revision'); return ''; }; const inp = gatherStalenessInputs({ readFileImpl, existsImpl, gitImpl, headCommit: 'deadbeef' }); expect(inp.commitsBehind).toBe(null); }); }); import { buildGraphSection } from './project-graph.mjs'; describe('buildGraphSection (CD-R6-G)', () => { const districtMap = [ { district: 'tools', nodeCount: 3, topNodes: [{ id: 'a', label: 'A', degree: 2 }], communities: [5, 7] }, { district: 'docs-observer', nodeCount: 1, topNodes: [], communities: [] }, ]; const staleness = { stale: true, commits_behind: 12, uncommitted: 3 }; it('секция содержит kind, районы (layer-0) и inline staleness', () => { const s = buildGraphSection({ districtMap, staleness }); expect(s.kind).toBe('project-graph'); expect(s.layer0).toBe(districtMap); expect(s.staleness).toEqual(staleness); expect(s.districtCount).toBe(2); }); it('пустая карта → districtCount 0, staleness сохранён', () => { const s = buildGraphSection({ districtMap: [], staleness }); expect(s.districtCount).toBe(0); expect(s.staleness.stale).toBe(true); }); it('отсутствующий staleness → безопасный дефолт stale:true (fail-safe сигнал)', () => { const s = buildGraphSection({ districtMap: [] }); expect(s.staleness.stale).toBe(true); // нет сигнала → считаем сталым (§5.4 сигнал ВСЕГДА) }); it('commits_behind=null сохраняется в секции (SE-B1: SE11 не схлопывается в 0)', () => { const s = buildGraphSection({ districtMap: [], staleness: { stale: true, commits_behind: null, uncommitted: 2 } }); expect(s.staleness.commits_behind).toBe(null); expect(s.staleness.uncommitted).toBe(2); }); }); describe('readStaleness — SE-B3 (нечитаемый граф → консервативно stale)', () => { it('graphCommit пуст (не прочитан) при наличии headCommit → stale:true', () => { const r = readStaleness({ needsUpdatePresent: false, graphCommit: '', headCommit: 'deadbeef', commitsBehind: null, uncommittedCount: 0 }); expect(r.stale).toBe(true); }); it('headCommit пуст → stale:true (свежесть не подтверждена)', () => { const r = readStaleness({ needsUpdatePresent: false, graphCommit: 'aaa', headCommit: '', commitsBehind: 0, uncommittedCount: 0 }); expect(r.stale).toBe(true); }); });