Files
factory/lib/wiki-graph/tests/graph.test.ts
vitya ca669d96e1 feat(lib): bundle MCP servers from .common/lib into factory/lib/
Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for:
- projects-meta-mcp v2.25.0 (TypeScript/Node)
- wiki-graph v0.3.1 (TypeScript/Node)
- interns-mcp v0.3.3 (Python/FastMCP)

.gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc)
bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:17:29 +03:00

103 lines
3.3 KiB
TypeScript

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: ['<todo-module>'] });
});
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']);
});
});