feat(ui): implement Map grouping, dependents, and weight bars; symbol tab address
Adds a new grouping system for the Map with a new grouping depth control, exposes per-module dependents (files and modules) to drive a weight bar, and renders it on each module. Introduces a MapKey to explain visuals, collapses lone root-file buckets for clearer labeling, and supports a nullable depth value to let the provider pick grouping. The Symbol tab now has its own address (#/s) when nothing is selected, and routing/top-bar logic is updated accordingly. Also updates export SVG rendering to include weight-based bars, and extends tests and docs to cover the new visuals and behavior.
This commit is contained in:
@@ -139,6 +139,7 @@ function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
|
||||
test: over.test ?? false,
|
||||
facade: over.facade ?? false,
|
||||
fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
|
||||
dependents: over.dependents ?? { files: 0, modules: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -449,6 +450,37 @@ describe('mapSvg', () => {
|
||||
expect(svg).not.toContain('>__tests__</text>');
|
||||
});
|
||||
|
||||
it('exports the weight bar the canvas draws, scaled the same way', () => {
|
||||
const weighted = buildMapLayout(
|
||||
{
|
||||
modules: [
|
||||
mod('src/types', { dependents: { files: 80, modules: 4 } }),
|
||||
mod('src/db', { dependents: { files: 20, modules: 2 } }),
|
||||
mod('src/bin'),
|
||||
],
|
||||
links: [link('src/db', 'src/types', 30), link('src/bin', 'src/db', 30)],
|
||||
},
|
||||
{ includeTests: false }
|
||||
);
|
||||
const svg = mapSvg(weighted);
|
||||
const nodeOf = (id: string) => weighted.nodes.find((n) => n.id === id)!;
|
||||
// Full bar for the heaviest, a quarter for the module a quarter as leaned
|
||||
// on, and NO rect at all for the one nothing depends on.
|
||||
// The export rounds to a tenth, as every coordinate in this file does.
|
||||
const tenth = (n: number) => Math.round(n * 10) / 10;
|
||||
const full = nodeOf('src/types');
|
||||
const quarter = nodeOf('src/db');
|
||||
expect(quarter.weight).toBeCloseTo(0.25, 5);
|
||||
expect(svg).toContain(`width="${tenth(full.width)}" height="4" fill="${EXPORT_COLORS.ink}"`);
|
||||
expect(svg).toContain(
|
||||
`width="${tenth(quarter.width * 0.25)}" height="4" fill="${EXPORT_COLORS.ink}"`
|
||||
);
|
||||
expect(nodeOf('src/bin').weight).toBe(0);
|
||||
expect(svg.match(/height="4" fill=/g)?.length).toBe(2);
|
||||
// …and the count rides in the meta line, as on screen.
|
||||
expect(svg).toContain('· 80 depend on it</text>');
|
||||
});
|
||||
|
||||
it('names the top and bottom bands', () => {
|
||||
const svg = mapSvg(layout);
|
||||
expect(svg).toContain('>entry points</text>');
|
||||
|
||||
@@ -24,7 +24,13 @@ 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';
|
||||
import {
|
||||
moduleIdFor,
|
||||
normalizeRoot,
|
||||
pickDefaultDepth,
|
||||
pickDefaultRoot,
|
||||
resetMapCache,
|
||||
} from '../src/ui-server/api/map';
|
||||
|
||||
let server: UiServerHandle;
|
||||
let api: GraphApi;
|
||||
@@ -293,6 +299,102 @@ describe('pickDefaultRoot', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickDefaultDepth', () => {
|
||||
/** `n` files under `dir`, each carrying `each` symbols. */
|
||||
function spread(dir: string, n: number, each: number, test = false) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
path: `${dir}/f${i}.ts`,
|
||||
symbols: each,
|
||||
test,
|
||||
}));
|
||||
}
|
||||
|
||||
it('goes deeper when one box holds the program', () => {
|
||||
// The shape this rule exists for: a React-Native-ish repo whose whole app
|
||||
// is under `src/`. At depth 1 the map is a box labelled `src` and nothing
|
||||
// else — 285 files and two thirds of the symbols, unopenable.
|
||||
const files = [
|
||||
...spread('src/components', 119, 12),
|
||||
...spread('src/app', 53, 21),
|
||||
...spread('src/api', 47, 7),
|
||||
...spread('src/utils', 24, 6),
|
||||
...spread('ios/CaptureView', 63, 28),
|
||||
...spread('ios/Camera', 3, 38),
|
||||
...spread('.github/workflows', 4, 0),
|
||||
];
|
||||
expect(pickDefaultDepth(files, '')).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps a repository whose directories ARE its modules at one level', () => {
|
||||
const files = [
|
||||
...spread('src/db', 8, 40),
|
||||
...spread('src/graph', 9, 40),
|
||||
...spread('src/mcp', 7, 40),
|
||||
...spread('src/search', 5, 40),
|
||||
...spread('src/sync', 4, 40),
|
||||
];
|
||||
expect(pickDefaultDepth(files, 'src')).toBe(1);
|
||||
});
|
||||
|
||||
it('does not open a dominant box that has nothing in it', () => {
|
||||
// `src/core` holds most of the symbols but only three files: this is a
|
||||
// small project honestly drawn, not a coarse grouping.
|
||||
const files = [
|
||||
...spread('src/core', 3, 90),
|
||||
...spread('src/db', 2, 10),
|
||||
...spread('src/api', 2, 10),
|
||||
{ path: 'src/index.ts', symbols: 5, test: false },
|
||||
];
|
||||
expect(pickDefaultDepth(files, 'src')).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps going while the picture is still one box', () => {
|
||||
// `frontend/` then `frontend/src/` — two levels of packaging before the
|
||||
// code. Neither is a map; the third level is.
|
||||
const files = [
|
||||
...spread('frontend/src/screens', 15, 10),
|
||||
...spread('frontend/src/components', 14, 10),
|
||||
...spread('frontend/src/hooks', 8, 10),
|
||||
...spread('frontend/src/api', 6, 10),
|
||||
...spread('backend/app', 5, 8),
|
||||
];
|
||||
expect(pickDefaultDepth(files, '')).toBe(3);
|
||||
});
|
||||
|
||||
it('stops before a deeper grouping becomes a crowd', () => {
|
||||
const files = [
|
||||
...spread('src/a', 30, 10),
|
||||
...Array.from({ length: 70 }, (_, i) => ({
|
||||
path: `src/b/m${i}/f.ts`,
|
||||
symbols: 1,
|
||||
test: false,
|
||||
})),
|
||||
];
|
||||
// Depth 2 is dominated by `src/a`, but depth 3 would draw 71 boxes.
|
||||
expect(pickDefaultDepth(files, '')).toBe(2);
|
||||
});
|
||||
|
||||
it('does not chase a tree that has no more levels to give', () => {
|
||||
const files = [
|
||||
...spread('src/a', 30, 10),
|
||||
...spread('src/b', 2, 1),
|
||||
];
|
||||
expect(pickDefaultDepth(files, 'src')).toBe(1);
|
||||
});
|
||||
|
||||
it('counts only the modules the map draws by default', () => {
|
||||
// Test files are hidden unless the reader asks for them, so a depth that
|
||||
// is only "enough boxes" once tests are counted is not enough boxes.
|
||||
const files = [
|
||||
...spread('src/app', 40, 10),
|
||||
...spread('src/__tests__/a', 12, 10, true),
|
||||
...spread('src/__tests__/b', 12, 10, true),
|
||||
...spread('src/__tests__/c', 12, 10, true),
|
||||
];
|
||||
expect(pickDefaultDepth(files, 'src')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/map', () => {
|
||||
it('is listed by the API index', async () => {
|
||||
const res = await request('/api');
|
||||
@@ -407,13 +509,40 @@ describe('GET /api/map', () => {
|
||||
// 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');
|
||||
// …but the bucket keeps its name only because `src/core/passes` sits beside
|
||||
// it. `src/api` has nothing below it, so its bucket IS `src/api` and saying
|
||||
// otherwise would name a directory the repository does not have.
|
||||
expect(ids).toContain('src/api');
|
||||
expect(ids).not.toContain('src/api/(root files)');
|
||||
|
||||
const slashed = await getMap('?root=src%2F&depth=2');
|
||||
expect(slashed.modules).toEqual(deep.modules);
|
||||
});
|
||||
|
||||
it('counts the files outside each module that reference into it', async () => {
|
||||
const map = await getMap('?root=src&depth=1');
|
||||
const by = new Map<string, any>(map.modules.map((m: any) => [m.id, m]));
|
||||
|
||||
// `src/types.ts` and `src/index.ts` are what the rest of the fixture
|
||||
// imports, so the bucket holding types is the most depended-on box.
|
||||
const types = by.get('src/(root files)');
|
||||
expect(types.dependents.files).toBeGreaterThan(0);
|
||||
expect(types.dependents.modules).toBeGreaterThan(1);
|
||||
|
||||
// Every count is FILES OUTSIDE the module: never more than the rest of the
|
||||
// repository, and a module's own internal imports never inflate it.
|
||||
const total = map.modules.reduce((sum: number, m: any) => sum + m.files, 0);
|
||||
for (const module of map.modules) {
|
||||
expect(module.dependents.files).toBeLessThanOrEqual(total - module.files);
|
||||
expect(module.dependents.modules).toBeLessThanOrEqual(map.modules.length - 1);
|
||||
// A module nothing arrives at is an island, and the two must agree —
|
||||
// they are computed from different queries and a reader sees both.
|
||||
const arrives = map.links.some((l: any) => l.target === module.id);
|
||||
if (!arrives) expect(module.dependents.files).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -46,6 +46,7 @@ function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
|
||||
test: over.test ?? false,
|
||||
facade: over.facade ?? false,
|
||||
fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
|
||||
dependents: over.dependents ?? { files: 0, modules: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -473,3 +474,49 @@ describe('directional ports and room', () => {
|
||||
expect(wide.layers[0]!.y - wide.layers[1]!.y).toBe(NODE_HEIGHT + 116);
|
||||
});
|
||||
});
|
||||
|
||||
describe('how much leans on a box', () => {
|
||||
it('scales the bar against the heaviest module DRAWN', () => {
|
||||
const modules = [
|
||||
mod('src/types', { dependents: { files: 90, modules: 5 } }),
|
||||
mod('src/db', { dependents: { files: 45, modules: 3 } }),
|
||||
mod('src/cli', { dependents: { files: 0, modules: 0 } }),
|
||||
];
|
||||
const links = [link('src/db', 'src/types', 20), link('src/cli', 'src/db', 20)];
|
||||
const layout = buildMapLayout({ modules, links }, OPTS);
|
||||
const weightOf = (id: string) => layout.nodes.find((n) => n.id === id)!.weight;
|
||||
|
||||
expect(weightOf('src/types')).toBe(1);
|
||||
expect(weightOf('src/db')).toBeCloseTo(0.5, 5);
|
||||
// Nothing depends on the CLI, so it draws no bar at all rather than a
|
||||
// sliver a reader would have to squint at to call empty.
|
||||
expect(weightOf('src/cli')).toBe(0);
|
||||
});
|
||||
|
||||
it('rescales when a heavier test module joins the picture', () => {
|
||||
const modules = [
|
||||
mod('src/types', { dependents: { files: 40, modules: 4 } }),
|
||||
mod('src/app', { dependents: { files: 10, modules: 1 } }),
|
||||
mod('__tests__', { test: true, dependents: { files: 80, modules: 6 } }),
|
||||
];
|
||||
const links = [link('src/app', 'src/types', 20), link('__tests__', 'src/app', 20)];
|
||||
const spec = { modules, links };
|
||||
// Tests off: the app's own busiest box is the full bar.
|
||||
const off = buildMapLayout(spec, { includeTests: false });
|
||||
expect(off.nodes.find((n) => n.id === 'src/types')!.weight).toBe(1);
|
||||
// Tests on: the scale moves, rather than leaving a bar running past a
|
||||
// maximum the reader cannot see.
|
||||
const on = buildMapLayout(spec, { includeTests: true });
|
||||
expect(on.nodes.find((n) => n.id === 'src/types')!.weight).toBeCloseTo(0.5, 5);
|
||||
expect(on.nodes.find((n) => n.id === '__tests__')!.weight).toBe(1);
|
||||
});
|
||||
|
||||
it('says the count on the box, and says nothing when nothing depends on it', () => {
|
||||
expect(moduleMetaLabel(mod('src/db', { dependents: { files: 45, modules: 3 } }))).toBe(
|
||||
'30 symbols · 3 files · 45 depend on it'
|
||||
);
|
||||
expect(moduleMetaLabel(mod('src/cli'))).toBe('30 symbols · 3 files');
|
||||
// An island's line is still the one sentence that matters about it.
|
||||
expect(moduleMetaLabel(mod('src/cli'), true)).toBe('nothing depends on this');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -674,6 +674,46 @@ describe('@colbymchenry/codegraph-ui — the seams', () => {
|
||||
expect(symbolHref('function:x')).toBe('#/s/function%3Ax');
|
||||
});
|
||||
|
||||
it('gives the Symbol tab an address of its own when no symbol is chosen', async () => {
|
||||
const { parseHash } = await import('../ui/src/lib/router.svelte');
|
||||
|
||||
// The regression this pins: the tab used to fall back to `#/`, and `#/` is
|
||||
// the landing page — which renders the SCREENS tab on any project that has
|
||||
// screens. Clicking Symbol landed you on somebody else's view.
|
||||
expect(symbolHref(null)).toBe('#/s');
|
||||
expect(parseHash('#/').route.view).toBe('home');
|
||||
|
||||
const empty = parseHash(symbolHref(null)).route;
|
||||
expect(empty.view).toBe('symbol');
|
||||
expect(empty).toMatchObject({ view: 'symbol', id: null });
|
||||
|
||||
// …and a chosen symbol still round-trips, id and all.
|
||||
const chosen = parseHash(symbolHref('function:x')).route;
|
||||
expect(chosen).toMatchObject({ view: 'symbol', id: 'function:x' });
|
||||
});
|
||||
|
||||
it('sends every nav tab to its own view', async () => {
|
||||
const { parseHash } = await import('../ui/src/lib/router.svelte');
|
||||
const { entryHref, screensHref, stepsHref, deadHref } = await import(
|
||||
'../ui/src/lib/navigation'
|
||||
);
|
||||
|
||||
// One href per tab in the top bar, each parsed back. A tab whose link
|
||||
// resolves to a different tab's view is the bug above, in general form.
|
||||
const tabs: Array<[string, string]> = [
|
||||
['screens', screensHref()],
|
||||
['steps', stepsHref()],
|
||||
['entry', entryHref()],
|
||||
['map', mapHref()],
|
||||
['symbol', symbolHref(null)],
|
||||
['flow', flowHref()],
|
||||
['dead', deadHref()],
|
||||
];
|
||||
for (const [view, href] of tabs) {
|
||||
expect(parseHash(href).route.view, `${href} should open the ${view} view`).toBe(view);
|
||||
}
|
||||
});
|
||||
|
||||
it('the default adapter is the loopback JSON API and asks for `api/...`', async () => {
|
||||
const asked: string[] = [];
|
||||
const adapter = createHttpAdapter({
|
||||
|
||||
Reference in New Issue
Block a user