feat(ui): the Map — the repository at module granularity, layered from the graph (CG-49)
`GET /api/map` rolls the whole edge table up to module granularity in one `GROUP BY`, and the Map tab draws it: one box per directory, dependencies pointing down, nothing placed by hand. Two decisions carry the screen. The vertical order rests on each link's `declared` weight — the edges resolved through an import, a qualified name, an inheritance clause or a typed receiver — not on its raw count. Bare name matching resolves `run`, `push` and `finish` across unrelated directories, and layering on raw counts put `src/db` directly under `src/bin` on this repository's own index. On declared edges the same data reproduces the pipeline CLAUDE.md describes, with a third of the mutual pairs. When too few links carry a declared edge to describe a project, the layout falls back to raw counts and the side panel says so. And the aggregation is a single scan. Grouping by the symbol names as well as the modules costs nothing extra — the join is what is expensive — so one query yields both the link weights and the tooltip's symbol pairs. Measured against this index inflated to 800k edges: 1.28s for one scan against 1.89s for two, which is the difference between meeting and missing the cold budget on a ten-thousand-file repository. Cached answers come back in ~3ms. Nothing is dropped silently: thin links are hidden until a module they touch is selected and counted in the panel, uncertain references are excluded from every number on screen and the total is printed, and mutual dependencies, module loops and file-level circular imports are listed rather than straightened away. An edge that still points up after layering is drawn dashed on selection instead of being reversed or removed. The layout — cycle-breaking, longest-path layering, barycenter ordering, ports — is a pure function of the payload in `ui/src/lib/map-model.ts`, so the tests toggle and the selection cost no round-trip and the same project always draws the same picture. Svelte Flow supplies pan, zoom and fit; never a layout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a1dfa72cac
commit
6d0f60f32c
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* `GET /api/map` — the module aggregation behind the Map (CG-49).
|
||||
*
|
||||
* Against a real indexed fixture over a real loopback server, like the rest of
|
||||
* the viewer's API suite. The fixture is shaped to produce exactly the things
|
||||
* the endpoint has to get right and that a synthetic payload cannot prove:
|
||||
*
|
||||
* - a façade (`src/index.ts`) that must stay its own box rather than being
|
||||
* folded in with the loose type declarations beside it,
|
||||
* - real `imports` edges, so the `declared` subset is not always equal to the
|
||||
* raw count and the layering has something trustworthy to rest on,
|
||||
* - a two-file import cycle, so the file-level cycle report has a component to
|
||||
* find,
|
||||
* - a test directory, so the `test` flag and the root default can be checked.
|
||||
*
|
||||
* The pure layout — layering, cycle-breaking, ports — is tested without a
|
||||
* server in `ui-map-model.test.ts`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import CodeGraph from '../src/index';
|
||||
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
|
||||
import { moduleIdFor, normalizeRoot, pickDefaultRoot, resetMapCache } from '../src/ui-server/api/map';
|
||||
|
||||
let server: UiServerHandle;
|
||||
let api: GraphApi;
|
||||
let tempDir: string;
|
||||
let projectRoot: string;
|
||||
|
||||
function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
path: requestPath,
|
||||
method: 'GET',
|
||||
headers: { Host: `127.0.0.1:${server.port}` },
|
||||
setHost: false,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body: Buffer.concat(chunks).toString('utf-8'),
|
||||
type: res.headers['content-type'],
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function getMap(query = ''): Promise<any> {
|
||||
const res = await request(`/api/map${query}`);
|
||||
expect(res.type).toBe('application/json; charset=utf-8');
|
||||
expect(res.status).toBe(200);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
function write(root: string, rel: string, body: string): void {
|
||||
const full = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, body);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-map-'));
|
||||
projectRoot = path.join(tempDir, 'project');
|
||||
|
||||
write(projectRoot, 'src/types.ts', `export interface Row {\n id: string;\n}\n`);
|
||||
|
||||
write(
|
||||
projectRoot,
|
||||
'src/db/schema.ts',
|
||||
`export const TABLES = ['rows'];\n`
|
||||
);
|
||||
// db -> core, the LIGHT direction of the mutual pair below.
|
||||
write(
|
||||
projectRoot,
|
||||
'src/db/store.ts',
|
||||
`import { Row } from '../types';
|
||||
import { normalise } from '../core/util';
|
||||
|
||||
export class Store {
|
||||
rows: Row[] = [];
|
||||
put(row: Row): void {
|
||||
this.rows.push(normalise(row));
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
// util <-> store is a deliberate two-file import cycle: it gives the file
|
||||
// cycle report a component to find and the module graph a mutual pair.
|
||||
write(
|
||||
projectRoot,
|
||||
'src/core/util.ts',
|
||||
`import { Row } from '../types';
|
||||
import { Store } from '../db/store';
|
||||
|
||||
export function normalise(row: Row): Row {
|
||||
return { id: row.id.trim() };
|
||||
}
|
||||
|
||||
export function count(store: Store): number {
|
||||
return store.rows.length;
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
// Two directory levels under `src`, so depth=2 has something real to split.
|
||||
write(
|
||||
projectRoot,
|
||||
'src/core/passes/trim.ts',
|
||||
`import { Row } from '../../types';
|
||||
|
||||
export function trim(row: Row): Row {
|
||||
return { id: row.id.slice(0, 8) };
|
||||
}
|
||||
`
|
||||
);
|
||||
// core -> db, several times over: the HEAVY direction.
|
||||
write(
|
||||
projectRoot,
|
||||
'src/core/engine.ts',
|
||||
`import { Store } from '../db/store';
|
||||
import { TABLES } from '../db/schema';
|
||||
import { trim } from './passes/trim';
|
||||
import { Row } from '../types';
|
||||
|
||||
export class Engine {
|
||||
store = new Store();
|
||||
boot(): string[] {
|
||||
return TABLES;
|
||||
}
|
||||
add(row: Row): void {
|
||||
this.store.put(trim(row));
|
||||
this.store.put(row);
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
write(
|
||||
projectRoot,
|
||||
'src/api/handler.ts',
|
||||
`import { Engine } from '../core/engine';
|
||||
import { Row } from '../types';
|
||||
|
||||
export function handle(engine: Engine, row: Row): void {
|
||||
engine.add(row);
|
||||
}
|
||||
`
|
||||
);
|
||||
write(
|
||||
projectRoot,
|
||||
'src/api/routes.ts',
|
||||
`import { Engine } from '../core/engine';
|
||||
import { handle } from './handler';
|
||||
|
||||
export function route(engine: Engine): void {
|
||||
handle(engine, { id: 'x' });
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
write(
|
||||
projectRoot,
|
||||
'src/index.ts',
|
||||
`import { Engine } from './core/engine';
|
||||
import { route } from './api/routes';
|
||||
|
||||
export function start(): void {
|
||||
route(new Engine());
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
write(
|
||||
projectRoot,
|
||||
'__tests__/engine.test.ts',
|
||||
`import { Engine } from '../src/core/engine';
|
||||
|
||||
export function testBoot(): string[] {
|
||||
return new Engine().boot();
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(projectRoot, {
|
||||
config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
|
||||
});
|
||||
await cg.indexAll();
|
||||
cg.resolveReferences();
|
||||
cg.close();
|
||||
|
||||
const viewerDir = path.join(tempDir, 'viewer');
|
||||
fs.mkdirSync(viewerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
|
||||
|
||||
resetMapCache();
|
||||
api = createGraphApi({ projectRoot });
|
||||
server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
api?.close();
|
||||
await server?.close();
|
||||
resetMapCache();
|
||||
if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('moduleIdFor', () => {
|
||||
it('names a module after the first `depth` segments under the root', () => {
|
||||
expect(moduleIdFor('src/core/engine.ts', 'src', 1)).toEqual({ id: 'src/core', facade: false });
|
||||
expect(moduleIdFor('src/a/b/c.ts', 'src', 2)).toEqual({ id: 'src/a/b', facade: false });
|
||||
expect(moduleIdFor('a/b/c.ts', '', 1)).toEqual({ id: 'a', facade: false });
|
||||
});
|
||||
|
||||
it('keeps a façade as its own box and buckets the other loose files', () => {
|
||||
expect(moduleIdFor('src/index.ts', 'src', 1)).toEqual({ id: 'src/index.ts', facade: true });
|
||||
expect(moduleIdFor('src/lib.rs', 'src', 1)?.facade).toBe(true);
|
||||
expect(moduleIdFor('pkg/__init__.py', 'pkg', 1)?.facade).toBe(true);
|
||||
expect(moduleIdFor('src/types.ts', 'src', 1)).toEqual({
|
||||
id: 'src/(root files)',
|
||||
facade: false,
|
||||
});
|
||||
expect(moduleIdFor('types.ts', '', 1)).toEqual({ id: '(root files)', facade: false });
|
||||
});
|
||||
|
||||
it('buckets a loose file into the directory it is actually in, not the top one', () => {
|
||||
// Two segments at depth 2 is a loose file inside `src/a`, so it belongs to
|
||||
// that directory's bucket. Folding it into `src/(root files)` would claim a
|
||||
// file lives somewhere it does not.
|
||||
expect(moduleIdFor('src/a/loose.ts', 'src', 2)).toEqual({
|
||||
id: 'src/a/(root files)',
|
||||
facade: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a file outside the root', () => {
|
||||
expect(moduleIdFor('__tests__/x.test.ts', 'src', 1)).toBeNull();
|
||||
// A sibling whose name merely starts with the root is not under it.
|
||||
expect(moduleIdFor('srcx/y.ts', 'src', 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRoot', () => {
|
||||
it('treats `src`, `src/` and `./src` as one root', () => {
|
||||
expect(normalizeRoot('src')).toBe('src');
|
||||
expect(normalizeRoot('src/')).toBe('src');
|
||||
expect(normalizeRoot('./src')).toBe('src');
|
||||
expect(normalizeRoot('src\\')).toBe('src');
|
||||
});
|
||||
|
||||
it('treats the repository root as the empty string however it is written', () => {
|
||||
expect(normalizeRoot('')).toBe('');
|
||||
expect(normalizeRoot('.')).toBe('');
|
||||
expect(normalizeRoot('/')).toBe('');
|
||||
expect(normalizeRoot(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickDefaultRoot', () => {
|
||||
it('picks the directory holding a clear majority of the non-test symbols', () => {
|
||||
expect(
|
||||
pickDefaultRoot([
|
||||
{ path: 'src/a.ts', symbols: 80, test: false },
|
||||
{ path: 'scripts/b.ts', symbols: 5, test: false },
|
||||
{ path: '__tests__/c.ts', symbols: 900, test: true },
|
||||
])
|
||||
).toBe('src');
|
||||
});
|
||||
|
||||
it('falls back to the repository root when no directory dominates', () => {
|
||||
expect(
|
||||
pickDefaultRoot([
|
||||
{ path: 'a/one.ts', symbols: 10, test: false },
|
||||
{ path: 'b/two.ts', symbols: 10, test: false },
|
||||
{ path: 'c/three.ts', symbols: 10, test: false },
|
||||
])
|
||||
).toBe('');
|
||||
expect(pickDefaultRoot([{ path: 'flat.ts', symbols: 4, test: false }])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/map', () => {
|
||||
it('is listed by the API index', async () => {
|
||||
const res = await request('/api');
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.endpoints.map((e: any) => e.path)).toContain('/api/map');
|
||||
});
|
||||
|
||||
it('opens on the source directory and keeps the façade its own box', async () => {
|
||||
const map = await getMap();
|
||||
expect(map.root).toBe('src');
|
||||
expect(map.depth).toBe(1);
|
||||
|
||||
const ids = map.modules.map((m: any) => m.id);
|
||||
expect(ids).toEqual(['src/(root files)', 'src/api', 'src/core', 'src/db', 'src/index.ts']);
|
||||
expect(map.modules.find((m: any) => m.id === 'src/core').files).toBe(3);
|
||||
|
||||
const facade = map.modules.find((m: any) => m.id === 'src/index.ts');
|
||||
expect(facade.facade).toBe(true);
|
||||
expect(facade.files).toBe(1);
|
||||
expect(facade.symbols).toBeGreaterThan(0);
|
||||
// Nothing under `src` is a test, so the default root already excludes them.
|
||||
expect(map.modules.every((m: any) => m.test === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('offers every top-level directory as a root, plus the repository itself', async () => {
|
||||
const map = await getMap();
|
||||
expect(map.roots[0]).toEqual({ root: '', label: 'whole repository', files: map.index.files });
|
||||
expect(map.roots.map((r: any) => r.root)).toEqual(
|
||||
expect.arrayContaining(['', 'src', '__tests__'])
|
||||
);
|
||||
});
|
||||
|
||||
it('counts cross-module edges only, with a declared subset and named pairs', async () => {
|
||||
const map = await getMap();
|
||||
const link = map.links.find((l: any) => l.source === 'src/api' && l.target === 'src/core');
|
||||
expect(link).toBeTruthy();
|
||||
expect(link.count).toBeGreaterThan(0);
|
||||
// Every kind's count has to add up to the link's own count, or the tooltip
|
||||
// and the stroke width are describing two different things.
|
||||
expect(link.byKind.reduce((sum: number, k: any) => sum + k.count, 0)).toBe(link.count);
|
||||
// `import { Engine }` is a declared dependency; it must survive as one.
|
||||
expect(link.declared).toBeGreaterThan(0);
|
||||
expect(link.declared).toBeLessThanOrEqual(link.count);
|
||||
expect(link.topPairs.length).toBeGreaterThan(0);
|
||||
expect(link.topPairs.length).toBeLessThanOrEqual(4);
|
||||
expect(link.topPairs.every((p: any) => p.declared <= p.count)).toBe(true);
|
||||
|
||||
// No module ever links to itself: same-module edges are not dependencies.
|
||||
expect(map.links.every((l: any) => l.source !== l.target)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the heavier direction of a mutual pair heavier', async () => {
|
||||
const map = await getMap();
|
||||
const coreToDb = map.links.find((l: any) => l.source === 'src/core' && l.target === 'src/db');
|
||||
const dbToCore = map.links.find((l: any) => l.source === 'src/db' && l.target === 'src/core');
|
||||
expect(coreToDb).toBeTruthy();
|
||||
expect(dbToCore).toBeTruthy();
|
||||
expect(coreToDb.count).toBeGreaterThan(dbToCore.count);
|
||||
});
|
||||
|
||||
it('reports the file-level cycle the fixture contains', async () => {
|
||||
const map = await getMap();
|
||||
expect(map.cycles.total).toBeGreaterThanOrEqual(1);
|
||||
const knot = map.cycles.items.find((c: any) =>
|
||||
c.files.includes('src/core/util.ts') && c.files.includes('src/db/store.ts')
|
||||
);
|
||||
expect(knot, JSON.stringify(map.cycles)).toBeTruthy();
|
||||
expect(knot.size).toBe(knot.files.length);
|
||||
expect(knot.modules).toEqual(expect.arrayContaining(['src/core', 'src/db']));
|
||||
expect(map.cycles.shown).toBe(map.cycles.items.length);
|
||||
});
|
||||
|
||||
it('lists each module\'s files, capped, with the true total beside them', async () => {
|
||||
const map = await getMap();
|
||||
for (const module of map.modules) {
|
||||
expect(module.fileList.total).toBe(module.files);
|
||||
expect(module.fileList.shown).toBe(module.fileList.items.length);
|
||||
expect(module.fileList.truncated).toBe(module.fileList.shown < module.fileList.total);
|
||||
expect(module.fileList.items).toEqual([...module.fileList.items].sort());
|
||||
}
|
||||
// A module's files are everything BELOW it, not just the files directly in
|
||||
// it: `src/core` at depth 1 owns `src/core/passes/trim.ts` too, and the
|
||||
// panel's list has to match the count on the box.
|
||||
const core = map.modules.find((m: any) => m.id === 'src/core');
|
||||
expect(core.fileList.items).toEqual([
|
||||
'src/core/engine.ts',
|
||||
'src/core/passes/trim.ts',
|
||||
'src/core/util.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('says how many references the confidence floor excluded', async () => {
|
||||
const map = await getMap();
|
||||
expect(map.excluded.confidenceBelow).toBe(0.6);
|
||||
expect(map.excluded.uncertainEdges).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('answers the whole repository, where the tests are a test module', async () => {
|
||||
const map = await getMap('?root=&depth=1');
|
||||
expect(map.root).toBe('');
|
||||
const ids = map.modules.map((m: any) => m.id);
|
||||
expect(ids).toEqual(expect.arrayContaining(['src', '__tests__']));
|
||||
expect(map.modules.find((m: any) => m.id === '__tests__').test).toBe(true);
|
||||
expect(map.modules.find((m: any) => m.id === 'src').test).toBe(false);
|
||||
expect(map.links.some((l: any) => l.source === '__tests__' && l.target === 'src')).toBe(true);
|
||||
});
|
||||
|
||||
it('splits deeper when asked, and `src/` is the same root as `src`', async () => {
|
||||
const deep = await getMap('?root=src&depth=2');
|
||||
const ids = deep.modules.map((m: any) => m.id);
|
||||
// A directory two levels down becomes its own box; a file loose one level
|
||||
// down joins that level's bucket rather than being promoted to a module.
|
||||
expect(ids).toContain('src/core/passes');
|
||||
expect(ids).toContain('src/core/(root files)');
|
||||
expect(ids).toContain('src/api/(root files)');
|
||||
expect(ids).not.toContain('src/core');
|
||||
|
||||
const slashed = await getMap('?root=src%2F&depth=2');
|
||||
expect(slashed.modules).toEqual(deep.modules);
|
||||
});
|
||||
|
||||
it('rejects an out-of-range depth as JSON, not as a crash', async () => {
|
||||
const res = await request('/api/map?depth=9');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.type).toBe('application/json; charset=utf-8');
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.code).toBe('bad-request');
|
||||
expect(body.error).toContain('depth');
|
||||
});
|
||||
|
||||
it('serves the second identical request from the cache, byte for byte', async () => {
|
||||
// Other cases in this file have already warmed `src` at depth 1; the point
|
||||
// here is the first-then-second transition, so start from a cold cache.
|
||||
resetMapCache();
|
||||
const first = await getMap('?root=src&depth=1');
|
||||
const second = await getMap('?root=src&depth=1');
|
||||
expect(first.timing.cached).toBe(false);
|
||||
expect(second.timing.cached).toBe(true);
|
||||
// Everything except the timing stamp must be identical — a map that is not
|
||||
// reproducible between two reloads is not a map of anything.
|
||||
const strip = (m: any) => JSON.stringify({ ...m, timing: undefined });
|
||||
expect(strip(second)).toBe(strip(first));
|
||||
});
|
||||
|
||||
it('does not let one root\'s answer be served for another', async () => {
|
||||
const src = await getMap('?root=src&depth=1');
|
||||
const all = await getMap('?root=&depth=1');
|
||||
expect(all.root).toBe('');
|
||||
expect(all.modules.map((m: any) => m.id)).not.toEqual(src.modules.map((m: any) => m.id));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* The Map's layout, without a browser (CG-49).
|
||||
*
|
||||
* The properties under test are the ones that make the picture mean something.
|
||||
* A map is only worth reading if the vertical position of a box is a claim
|
||||
* about the code — so the tests here are mostly about *why* a module ends up
|
||||
* where it does:
|
||||
*
|
||||
* - the layering rests on `declared` weight, not raw counts, because bare name
|
||||
* matching invents cross-module links out of shared method names;
|
||||
* - a two-cycle keeps its heavier direction and the lighter one is reported,
|
||||
* never quietly dropped;
|
||||
* - the same payload always produces the same picture, because a diagram you
|
||||
* cannot recognise between two visits is not a map of anything.
|
||||
*
|
||||
* The endpoint that feeds it is tested against a real index in
|
||||
* `ui-map-api.test.ts`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildMapLayout,
|
||||
isEdgeVisible,
|
||||
linkId,
|
||||
moduleMetaLabel,
|
||||
nodeWidth,
|
||||
strokeWidthFor,
|
||||
LAYER_GAP,
|
||||
MIN_WEIGHT,
|
||||
MIN_WEIGHT_WITH_TESTS,
|
||||
NODE_HEIGHT,
|
||||
type MapLayout,
|
||||
} from '../ui/src/lib/map-model';
|
||||
import type { WireMapLink, WireMapModule } from '../ui/src/lib/api';
|
||||
|
||||
/* ------------------------------------------------------------- fixtures -- */
|
||||
|
||||
function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
|
||||
return {
|
||||
id,
|
||||
label: id.slice(id.lastIndexOf('/') + 1) || id,
|
||||
files: over.files ?? 3,
|
||||
symbols: over.symbols ?? 30,
|
||||
languages: over.languages ?? [{ language: 'typescript', files: over.files ?? 3 }],
|
||||
test: over.test ?? false,
|
||||
facade: over.facade ?? false,
|
||||
fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function link(
|
||||
source: string,
|
||||
target: string,
|
||||
count: number,
|
||||
declared = count
|
||||
): WireMapLink {
|
||||
return {
|
||||
source,
|
||||
target,
|
||||
count,
|
||||
declared,
|
||||
byKind: [{ kind: 'calls', count }],
|
||||
topPairs: [],
|
||||
};
|
||||
}
|
||||
|
||||
function layerOf(layout: MapLayout, id: string): number {
|
||||
const node = layout.nodes.find((n) => n.id === id);
|
||||
expect(node, `no node ${id}`).toBeTruthy();
|
||||
return node!.layer;
|
||||
}
|
||||
|
||||
const OPTS = { includeTests: false };
|
||||
|
||||
/* ---------------------------------------------------------------- specs -- */
|
||||
|
||||
describe('nodeWidth', () => {
|
||||
it('fits the wider of the two lines and never goes under the floor', () => {
|
||||
expect(nodeWidth('ui')).toBe(110);
|
||||
// A long id outgrows the floor; a long meta line outgrows a short id.
|
||||
expect(nodeWidth('src/resolution/(root files)')).toBeGreaterThan(200);
|
||||
expect(nodeWidth('src/db', '1218 symbols · 54 files')).toBeGreaterThan(nodeWidth('src/db'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('moduleMetaLabel', () => {
|
||||
it('says the counts in singular when there is one of them', () => {
|
||||
expect(moduleMetaLabel(mod('src/x', { symbols: 1, files: 1 }))).toBe('1 symbol · 1 file');
|
||||
expect(moduleMetaLabel(mod('src/x', { symbols: 9, files: 2 }))).toBe('9 symbols · 2 files');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strokeWidthFor', () => {
|
||||
it('grows with the logarithm of the count and stops at 6', () => {
|
||||
expect(strokeWidthFor(1)).toBe(1);
|
||||
expect(strokeWidthFor(700)).toBeLessThanOrEqual(6);
|
||||
expect(strokeWidthFor(1_000_000)).toBe(6);
|
||||
expect(strokeWidthFor(64)).toBeGreaterThan(strokeWidthFor(8));
|
||||
// A count of zero must not produce -Infinity.
|
||||
expect(Number.isFinite(strokeWidthFor(0))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layering', () => {
|
||||
const modules = [mod('src/bin'), mod('src/core'), mod('src/db')];
|
||||
|
||||
it('puts a module one layer above everything it depends on', () => {
|
||||
const layout = buildMapLayout(
|
||||
{ modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
|
||||
OPTS
|
||||
);
|
||||
expect(layerOf(layout, 'src/db')).toBe(0);
|
||||
expect(layerOf(layout, 'src/core')).toBe(1);
|
||||
expect(layerOf(layout, 'src/bin')).toBe(2);
|
||||
// Layer 0 is the foundations, and it is drawn at the BOTTOM.
|
||||
const bin = layout.nodes.find((n) => n.id === 'src/bin')!;
|
||||
const db = layout.nodes.find((n) => n.id === 'src/db')!;
|
||||
expect(bin.y).toBeLessThan(db.y);
|
||||
expect(db.y - bin.y).toBe(2 * (NODE_HEIGHT + LAYER_GAP));
|
||||
});
|
||||
|
||||
it('names only the top and bottom layers', () => {
|
||||
const layout = buildMapLayout(
|
||||
{ modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
|
||||
OPTS
|
||||
);
|
||||
expect(layout.layers.map((l) => l.label)).toEqual([
|
||||
'foundations — depend on nothing below',
|
||||
null,
|
||||
'entry points',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a link with nothing declared behind it', () => {
|
||||
// `src/db -> src/bin` is 40 name-only matches (`run`, `push`, `finish`) and
|
||||
// would otherwise lift the storage layer above the CLI. It is still drawn —
|
||||
// as a back-edge — but it must not decide the vertical order.
|
||||
const layout = buildMapLayout(
|
||||
{
|
||||
modules,
|
||||
links: [
|
||||
link('src/bin', 'src/core', 10, 10),
|
||||
link('src/core', 'src/db', 10, 10),
|
||||
link('src/db', 'src/bin', 40, 0),
|
||||
],
|
||||
},
|
||||
OPTS
|
||||
);
|
||||
expect(layout.basis.kind).toBe('declared');
|
||||
expect(layerOf(layout, 'src/db')).toBe(0);
|
||||
expect(layerOf(layout, 'src/bin')).toBe(2);
|
||||
const noisy = layout.edges.find((e) => e.source === 'src/db' && e.target === 'src/bin')!;
|
||||
expect(noisy).toBeTruthy();
|
||||
expect(noisy.back).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to raw counts, and says so, when almost nothing is declared', () => {
|
||||
const layout = buildMapLayout(
|
||||
{
|
||||
modules,
|
||||
links: [
|
||||
link('src/bin', 'src/core', 10, 0),
|
||||
link('src/core', 'src/db', 10, 0),
|
||||
link('src/db', 'src/core', 2, 1),
|
||||
],
|
||||
},
|
||||
OPTS
|
||||
);
|
||||
expect(layout.basis.kind).toBe('all');
|
||||
expect(layout.basis.declaredLinks).toBe(1);
|
||||
expect(layout.basis.totalLinks).toBe(3);
|
||||
expect(layout.basis.declaredLinks / layout.basis.totalLinks).toBeLessThan(0.4);
|
||||
// With raw counts the chain is still a chain, and the light back-reference
|
||||
// becomes the mutual one.
|
||||
expect(layerOf(layout, 'src/db')).toBe(0);
|
||||
expect(layerOf(layout, 'src/bin')).toBe(2);
|
||||
expect(layout.mutual.map((m) => m.back.source)).toEqual(['src/db']);
|
||||
});
|
||||
|
||||
it('survives a three-module loop instead of recursing forever', () => {
|
||||
const layout = buildMapLayout(
|
||||
{
|
||||
modules,
|
||||
links: [
|
||||
link('src/bin', 'src/core', 5),
|
||||
link('src/core', 'src/db', 5),
|
||||
link('src/db', 'src/bin', 5),
|
||||
],
|
||||
},
|
||||
OPTS
|
||||
);
|
||||
expect(layout.nodes).toHaveLength(3);
|
||||
expect(layout.moduleCycles).toEqual([['src/bin', 'src/core', 'src/db']]);
|
||||
// Every module still got a finite layer.
|
||||
expect(layout.nodes.every((n) => Number.isInteger(n.layer))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('two-cycles', () => {
|
||||
const modules = [mod('src/a'), mod('src/b')];
|
||||
|
||||
it('keeps the heavier direction and reports the lighter as mutual', () => {
|
||||
const layout = buildMapLayout(
|
||||
{ modules, links: [link('src/a', 'src/b', 20), link('src/b', 'src/a', 3)] },
|
||||
OPTS
|
||||
);
|
||||
expect(layerOf(layout, 'src/a')).toBe(1);
|
||||
expect(layerOf(layout, 'src/b')).toBe(0);
|
||||
expect(layout.mutual).toHaveLength(1);
|
||||
expect(layout.mutual[0]!.forward.source).toBe('src/a');
|
||||
expect(layout.mutual[0]!.back.source).toBe('src/b');
|
||||
// Both directions are still on the canvas; the lighter one points up.
|
||||
expect(layout.edges).toHaveLength(2);
|
||||
expect(layout.edges.find((e) => e.source === 'src/b')!.back).toBe(true);
|
||||
expect(layout.edges.find((e) => e.source === 'src/a')!.back).toBe(false);
|
||||
});
|
||||
|
||||
it('breaks an exact tie the same way every time', () => {
|
||||
const one = buildMapLayout(
|
||||
{ modules, links: [link('src/a', 'src/b', 7), link('src/b', 'src/a', 7)] },
|
||||
OPTS
|
||||
);
|
||||
const two = buildMapLayout(
|
||||
{ modules, links: [link('src/b', 'src/a', 7), link('src/a', 'src/b', 7)] },
|
||||
OPTS
|
||||
);
|
||||
expect(one.mutual[0]!.back.source).toBe('src/b');
|
||||
expect(two.mutual[0]!.back.source).toBe('src/b');
|
||||
expect(layerOf(one, 'src/a')).toBe(layerOf(two, 'src/a'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('tests and thresholds', () => {
|
||||
const modules = [mod('src/core'), mod('__tests__', { test: true })];
|
||||
const links = [link('__tests__', 'src/core', 30), link('src/core', '__tests__', 2)];
|
||||
|
||||
it('leaves test modules out until they are asked for, and their links with them', () => {
|
||||
const off = buildMapLayout({ modules, links }, { includeTests: false });
|
||||
expect(off.nodes.map((n) => n.id)).toEqual(['src/core']);
|
||||
expect(off.edges).toHaveLength(0);
|
||||
expect(off.minWeight).toBe(MIN_WEIGHT);
|
||||
|
||||
const on = buildMapLayout({ modules, links }, { includeTests: true });
|
||||
expect(on.nodes).toHaveLength(2);
|
||||
expect(on.edges).toHaveLength(2);
|
||||
// A test module touches everything, so the bar for a visible link is higher.
|
||||
expect(on.minWeight).toBe(MIN_WEIGHT_WITH_TESTS);
|
||||
});
|
||||
|
||||
it('marks a link under the threshold thin rather than deleting it', () => {
|
||||
const layout = buildMapLayout(
|
||||
{
|
||||
modules: [mod('src/a'), mod('src/b'), mod('src/c')],
|
||||
links: [link('src/a', 'src/b', 12), link('src/a', 'src/c', 2)],
|
||||
},
|
||||
OPTS
|
||||
);
|
||||
const thin = layout.edges.find((e) => e.target === 'src/c')!;
|
||||
expect(thin.thin).toBe(true);
|
||||
expect(isEdgeVisible(thin, null)).toBe(false);
|
||||
// Selecting either end brings it back — that is the whole point of hiding
|
||||
// it rather than dropping it.
|
||||
expect(isEdgeVisible(thin, 'src/a')).toBe(true);
|
||||
expect(isEdgeVisible(thin, 'src/c')).toBe(true);
|
||||
expect(isEdgeVisible(thin, 'src/b')).toBe(false);
|
||||
|
||||
const fat = layout.edges.find((e) => e.target === 'src/b')!;
|
||||
expect(isEdgeVisible(fat, null)).toBe(true);
|
||||
expect(isEdgeVisible(fat, 'src/c')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ports', () => {
|
||||
it('gives every link its own port, ordered by where the other end sits', () => {
|
||||
const layout = buildMapLayout(
|
||||
{
|
||||
modules: [mod('src/top'), mod('src/left'), mod('src/mid'), mod('src/right')],
|
||||
links: [
|
||||
link('src/top', 'src/left', 9),
|
||||
link('src/top', 'src/mid', 9),
|
||||
link('src/top', 'src/right', 9),
|
||||
],
|
||||
},
|
||||
OPTS
|
||||
);
|
||||
const top = layout.nodes.find((n) => n.id === 'src/top')!;
|
||||
expect(top.sourceHandles).toHaveLength(3);
|
||||
expect(new Set(top.sourceHandles).size).toBe(3);
|
||||
|
||||
// The handle order must follow the targets' left-to-right order, or the
|
||||
// three edges cross each other inside the gap for no reason.
|
||||
const xOf = (id: string) => {
|
||||
const n = layout.nodes.find((m) => m.id === id)!;
|
||||
return n.x + n.width / 2;
|
||||
};
|
||||
const targets = top.sourceHandles.map(
|
||||
(id) => layout.edges.find((e) => e.id === id)!.target
|
||||
);
|
||||
const xs = targets.map(xOf);
|
||||
expect(xs).toEqual([...xs].sort((a, b) => a - b));
|
||||
|
||||
// Each target's single incoming link is its only target handle.
|
||||
for (const id of ['src/left', 'src/mid', 'src/right']) {
|
||||
expect(layout.nodes.find((n) => n.id === id)!.targetHandles).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('names an edge by its endpoints, so two runs key the same', () => {
|
||||
expect(linkId({ source: 'a', target: 'b' })).toBe(linkId({ source: 'a', target: 'b' }));
|
||||
expect(linkId({ source: 'a', target: 'b' })).not.toBe(linkId({ source: 'b', target: 'a' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('determinism', () => {
|
||||
const modules = [
|
||||
mod('src/alpha'),
|
||||
mod('src/beta'),
|
||||
mod('src/gamma'),
|
||||
mod('src/delta'),
|
||||
mod('src/epsilon'),
|
||||
];
|
||||
const links = [
|
||||
link('src/alpha', 'src/beta', 12),
|
||||
link('src/alpha', 'src/gamma', 8),
|
||||
link('src/beta', 'src/delta', 15),
|
||||
link('src/gamma', 'src/delta', 6),
|
||||
link('src/delta', 'src/epsilon', 20),
|
||||
link('src/beta', 'src/epsilon', 5),
|
||||
];
|
||||
|
||||
it('produces an identical layout from an identical payload', () => {
|
||||
const a = buildMapLayout({ modules, links }, OPTS);
|
||||
const b = buildMapLayout({ modules, links }, OPTS);
|
||||
expect(JSON.stringify(b)).toBe(JSON.stringify(a));
|
||||
});
|
||||
|
||||
it('does not depend on the order the payload happened to arrive in', () => {
|
||||
const a = buildMapLayout({ modules, links }, OPTS);
|
||||
const b = buildMapLayout(
|
||||
{ modules: [...modules].reverse(), links: [...links].reverse() },
|
||||
OPTS
|
||||
);
|
||||
const positions = (l: MapLayout) =>
|
||||
l.nodes
|
||||
.map((n) => `${n.id}@${n.layer}:${Math.round(n.x)},${Math.round(n.y)}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
expect(positions(b)).toBe(positions(a));
|
||||
});
|
||||
|
||||
it('places an unconnected module without stretching the canvas around it', () => {
|
||||
const withIsland = buildMapLayout(
|
||||
{ modules: [...modules, mod('src/island')], links },
|
||||
OPTS
|
||||
);
|
||||
const island = withIsland.nodes.find((n) => n.id === 'src/island')!;
|
||||
expect(island).toBeTruthy();
|
||||
expect(island.layer).toBe(0);
|
||||
// Parked at the right-hand end of its layer, not interleaved through the
|
||||
// modules that actually connect.
|
||||
const sameLayer = withIsland.nodes.filter((n) => n.layer === 0);
|
||||
expect(Math.max(...sameLayer.map((n) => n.x))).toBe(island.x);
|
||||
// And the canvas is no wider than the boxes standing shoulder to shoulder.
|
||||
const widest = Math.max(
|
||||
...[0, 1, 2, 3].map((layer) =>
|
||||
withIsland.nodes
|
||||
.filter((n) => n.layer === layer)
|
||||
.reduce((sum, n) => sum + n.width, 0)
|
||||
)
|
||||
);
|
||||
expect(withIsland.width).toBeLessThan(widest + 6 * 34 + 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty and degenerate inputs', () => {
|
||||
it('answers an empty payload without throwing', () => {
|
||||
const layout = buildMapLayout({ modules: [], links: [] }, OPTS);
|
||||
expect(layout.nodes).toHaveLength(0);
|
||||
expect(layout.edges).toHaveLength(0);
|
||||
expect(layout.basis.kind).toBe('all');
|
||||
expect(Number.isFinite(layout.width)).toBe(true);
|
||||
expect(Number.isFinite(layout.height)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a link whose other end was filtered out', () => {
|
||||
const layout = buildMapLayout(
|
||||
{
|
||||
modules: [mod('src/a'), mod('__tests__', { test: true })],
|
||||
links: [link('src/a', '__tests__', 9), link('src/a', 'src/ghost', 9)],
|
||||
},
|
||||
OPTS
|
||||
);
|
||||
expect(layout.edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves a single layer unlabelled', () => {
|
||||
const layout = buildMapLayout({ modules: [mod('src/only')], links: [] }, OPTS);
|
||||
expect(layout.layers).toHaveLength(1);
|
||||
expect(layout.layers[0]!.label).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user