397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
248 lines
12 KiB
JavaScript
248 lines
12 KiB
JavaScript
// 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);
|
||
});
|
||
});
|