import { describe, test, expect } from 'vitest'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { buildGraphFromDir, buildGraph } from '../src/graph.js'; const here = dirname(fileURLToPath(import.meta.url)); const FIXTURE = join(here, 'fixtures', 'wiki'); describe('WikiGraph over a fixture corpus', () => { const g = buildGraphFromDir(FIXTURE); test('path: direct edge', () => { expect(g.path('a', 'b')).toEqual(['a', 'b']); }); test('path: two hops', () => { expect(g.path('a', 'c')).toEqual(['a', 'b', 'c']); }); test('path: undirected — walks edges against their direction', () => { expect(g.path('c', 'a')).toEqual(['c', 'b', 'a']); }); test('path: no path returns empty', () => { expect(g.path('a', 'e')).toEqual([]); }); test('neighbors: directed, depth 1', () => { expect(g.neighbors('a', 1)).toEqual(['b']); }); test('neighbors: directed, depth 2 accumulates', () => { expect(g.neighbors('a', 2)).toEqual(['b', 'c']); }); test('backlinks: incoming edges, sorted', () => { expect(g.backlinks('c')).toEqual(['b', 'd']); }); test('orphans: unlinked pages and dangling targets', () => { expect(g.orphans()).toEqual({ pages: ['e'], dangling: [''] }); }); test('stats: counts', () => { expect(g.stats()).toEqual({ nodes: 5, edges: 3, components: 2, dangling: 1, collisions: 0, }); }); }); describe('provenance-dir exclusion', () => { test('by default raw/ sources/ assets/ are excluded — canonical graph stays clean', () => { const g = buildGraphFromDir(FIXTURE); const s = g.stats(); expect(s.nodes).toBe(5); // a,b,c,d,e — ghost (sources) and raw/a ignored expect(s.collisions).toBe(0); // raw/a would collide with concepts/a if indexed expect(g.path('a', 'e')).toEqual([]); // raw/a's a→e edge must not leak in expect(g.neighbors('a', 2)).not.toContain('ghost'); }); test('exclude:[] indexes everything (provenance leaks in)', () => { const g = buildGraphFromDir(FIXTURE, { exclude: [] }); const s = g.stats(); expect(s.nodes).toBe(6); // + ghost expect(s.collisions).toBe(1); // a appears in concepts/ and raw/ expect(g.path('a', 'e')).toEqual(['a', 'e']); // raw/a injected the direct edge }); }); describe('dangling case-normalization', () => { test('case-variant unresolved targets dedupe to one dangling', () => { const g = buildGraph([ { slug: 'p1', dir: 'concepts', content: 'link [[Foo]]' }, { slug: 'p2', dir: 'concepts', content: 'link [[foo]]' }, ]); // resolution is case-insensitive, so dangling must be too — both are the same missing page expect(g.stats().dangling).toBe(1); expect(g.orphans().dangling).toEqual(['foo']); }); }); describe('basename collision', () => { const g = buildGraph([ { slug: 'clock', dir: 'concepts', content: '# Clock concept' }, { slug: 'clock', dir: 'entities', content: '# Clock entity' }, { slug: 'uses', dir: 'concepts', content: 'see [[clock]]' }, ]); test('two files sharing a basename collapse to one node and count as a collision', () => { const s = g.stats(); expect(s.nodes).toBe(2); expect(s.collisions).toBe(1); }); test('a link to a colliding slug still resolves (edge created)', () => { expect(g.backlinks('clock')).toEqual(['uses']); }); });