|
|
@@ -0,0 +1,622 @@
|
|
|
+/**
|
|
|
+ * The type hierarchy (CG-58) — the walk, the fan, and the tree the viewer draws.
|
|
|
+ *
|
|
|
+ * The walk half runs against a real indexed fixture rather than a stubbed
|
|
|
+ * `CodeGraph`: the properties worth pinning are ones only a real index has —
|
|
|
+ * that a Go struct satisfies an interface through a SYNTHESIZED `implements`
|
|
|
+ * edge with no textual link between the two files, that a self-referential
|
|
|
+ * `extends` in generated code does not loop, that the breadth-first order puts
|
|
|
+ * every direct subtype ahead of any indirect one.
|
|
|
+ *
|
|
|
+ * The layout half is pure arithmetic over a payload, so it is asserted
|
|
|
+ * directly. Everything the block does that could be WRONG rather than merely
|
|
|
+ * ugly lives there: which row a connector attaches to, what folds, and which
|
|
|
+ * noun the fold uses.
|
|
|
+ */
|
|
|
+
|
|
|
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
|
+import * as fs from 'fs';
|
|
|
+import * as os from 'os';
|
|
|
+import * as path from 'path';
|
|
|
+import CodeGraph from '../src/index';
|
|
|
+import type { Node } from '../src/types';
|
|
|
+import {
|
|
|
+ buildTypeHierarchy,
|
|
|
+ canHaveHierarchy,
|
|
|
+ countImplementers,
|
|
|
+ DISPATCH_MIN_IMPLEMENTERS,
|
|
|
+ MAX_DESCENDANTS,
|
|
|
+} from '../src/graph/type-hierarchy';
|
|
|
+import { buildHierarchy } from '../src/ui-server/api/hierarchy';
|
|
|
+import {
|
|
|
+ buildHierarchyModel,
|
|
|
+ connectorPath,
|
|
|
+ visibleHierarchy,
|
|
|
+ HIER_FOLD_AT,
|
|
|
+ HIER_GLYPH_X,
|
|
|
+ HIER_INDENT,
|
|
|
+ HIER_PORT_X,
|
|
|
+ HIER_ROW_H,
|
|
|
+} from '../ui/src/lib/hierarchy-model';
|
|
|
+import type {
|
|
|
+ WireHierarchy,
|
|
|
+ WireHierarchyNode,
|
|
|
+ WireNodeDetail,
|
|
|
+} from '../ui/src/lib/wire';
|
|
|
+
|
|
|
+// =============================================================================
|
|
|
+// A real index
|
|
|
+// =============================================================================
|
|
|
+
|
|
|
+let tempDir: string;
|
|
|
+let projectRoot: string;
|
|
|
+let cg: CodeGraph;
|
|
|
+
|
|
|
+/** The one node with this name and kind, or a failure that says which was missing. */
|
|
|
+function nodeNamed(name: string, kind?: string): Node {
|
|
|
+ const hits = cg
|
|
|
+ .searchNodes(name, { limit: 40 })
|
|
|
+ .map((r: any) => (r.node ?? r) as Node)
|
|
|
+ .filter((n) => n.name === name && (!kind || n.kind === kind));
|
|
|
+ expect(hits.length, `no ${kind ?? 'node'} named ${name}`).toBeGreaterThan(0);
|
|
|
+ return hits[0]!;
|
|
|
+}
|
|
|
+
|
|
|
+beforeAll(async () => {
|
|
|
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-hierarchy-'));
|
|
|
+ projectRoot = path.join(tempDir, 'project');
|
|
|
+ const src = path.join(projectRoot, 'src');
|
|
|
+ fs.mkdirSync(src, { recursive: true });
|
|
|
+
|
|
|
+ // A three-level TypeScript chain with a real override, plus an interface with
|
|
|
+ // enough implementations to be a dispatch fan.
|
|
|
+ fs.writeFileSync(
|
|
|
+ path.join(src, 'shapes.ts'),
|
|
|
+ `export interface Drawable {
|
|
|
+ draw(): string;
|
|
|
+}
|
|
|
+
|
|
|
+export abstract class Shape implements Drawable {
|
|
|
+ draw(): string {
|
|
|
+ return 'shape';
|
|
|
+ }
|
|
|
+ area(): number {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export class Square extends Shape {
|
|
|
+ draw(): string {
|
|
|
+ return 'square';
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export class Tile extends Square {
|
|
|
+ label = 'tile';
|
|
|
+}
|
|
|
+`
|
|
|
+ );
|
|
|
+
|
|
|
+ // Nine implementations, so the fan clears DISPATCH_MIN_IMPLEMENTERS.
|
|
|
+ const targets = [
|
|
|
+ 'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel', 'India',
|
|
|
+ ];
|
|
|
+ fs.writeFileSync(
|
|
|
+ path.join(src, 'plugins.ts'),
|
|
|
+ `export interface Plugin {
|
|
|
+ run(): void;
|
|
|
+}
|
|
|
+
|
|
|
+${targets
|
|
|
+ .map((name) => `export class ${name}Plugin implements Plugin {\n run(): void {}\n}`)
|
|
|
+ .join('\n\n')}
|
|
|
+`
|
|
|
+ );
|
|
|
+
|
|
|
+ // Go: `System` satisfies `Clock` without either file naming the other. The
|
|
|
+ // `implements` edge here is synthesized, which is the case the viewer draws
|
|
|
+ // differently — the fixture mirrors `__tests__/fixtures/payroll-go`.
|
|
|
+ fs.writeFileSync(path.join(projectRoot, 'go.mod'), 'module fixture\n\ngo 1.22\n');
|
|
|
+ fs.writeFileSync(
|
|
|
+ path.join(src, 'clock.go'),
|
|
|
+ `package clock
|
|
|
+
|
|
|
+import "time"
|
|
|
+
|
|
|
+// Clock is the time seam.
|
|
|
+type Clock interface {
|
|
|
+ Now() time.Time
|
|
|
+}
|
|
|
+
|
|
|
+// System is the production clock.
|
|
|
+type System struct{}
|
|
|
+
|
|
|
+func (System) Now() time.Time { return time.Now().UTC() }
|
|
|
+
|
|
|
+// Fixed is a frozen clock.
|
|
|
+type Fixed struct{ At time.Time }
|
|
|
+
|
|
|
+func (f Fixed) Now() time.Time { return f.At }
|
|
|
+`
|
|
|
+ );
|
|
|
+
|
|
|
+ cg = CodeGraph.initSync(projectRoot, {
|
|
|
+ config: { include: ['src/**/*.ts', 'src/**/*.go'], exclude: [] },
|
|
|
+ });
|
|
|
+ await cg.indexAll();
|
|
|
+ cg.resolveReferences();
|
|
|
+}, 120_000);
|
|
|
+
|
|
|
+afterAll(() => {
|
|
|
+ cg?.close();
|
|
|
+ if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
|
|
|
+});
|
|
|
+
|
|
|
+describe('canHaveHierarchy', () => {
|
|
|
+ it('is false for a function, so the walk never runs for one', () => {
|
|
|
+ expect(canHaveHierarchy({ kind: 'function' } as Node)).toBe(false);
|
|
|
+ expect(canHaveHierarchy({ kind: 'method' } as Node)).toBe(false);
|
|
|
+ expect(canHaveHierarchy({ kind: 'class' } as Node)).toBe(true);
|
|
|
+ expect(canHaveHierarchy({ kind: 'interface' } as Node)).toBe(true);
|
|
|
+ expect(canHaveHierarchy({ kind: 'struct' } as Node)).toBe(true);
|
|
|
+ expect(canHaveHierarchy({ kind: 'trait' } as Node)).toBe(true);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('buildTypeHierarchy — upward', () => {
|
|
|
+ it('walks past the direct parent to the whole chain', () => {
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'));
|
|
|
+ expect(hierarchy).not.toBeNull();
|
|
|
+ const byName = new Map(hierarchy!.ancestors.map((a) => [a.node.name, a]));
|
|
|
+ expect(byName.get('Square')?.depth).toBe(1);
|
|
|
+ expect(byName.get('Shape')?.depth).toBe(2);
|
|
|
+ // `Shape implements Drawable`, so the interface is three steps up from Tile.
|
|
|
+ expect(byName.get('Drawable')?.depth).toBe(3);
|
|
|
+ expect(byName.get('Square')?.relation).toBe('extends');
|
|
|
+ expect(byName.get('Drawable')?.relation).toBe('implements');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('nearest ancestors come first', () => {
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'))!;
|
|
|
+ const depths = hierarchy.ancestors.map((a) => a.depth);
|
|
|
+ expect(depths).toEqual([...depths].sort((a, b) => a - b));
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('buildTypeHierarchy — the fan', () => {
|
|
|
+ it('returns every direct subtype before any indirect one', () => {
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, nodeNamed('Shape', 'class'))!;
|
|
|
+ const depths = hierarchy.descendants.map((d) => d.depth);
|
|
|
+ expect(depths).toEqual([...depths].sort((a, b) => a - b));
|
|
|
+ expect(hierarchy.descendants.map((d) => d.node.name)).toContain('Square');
|
|
|
+ expect(hierarchy.descendants.map((d) => d.node.name)).toContain('Tile');
|
|
|
+ expect(hierarchy.directSubtypes).toBe(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('hangs an indirect subtype off its own parent, not off the focus', () => {
|
|
|
+ const focus = nodeNamed('Shape', 'class');
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, focus)!;
|
|
|
+ const square = hierarchy.descendants.find((d) => d.node.name === 'Square')!;
|
|
|
+ const tile = hierarchy.descendants.find((d) => d.node.name === 'Tile')!;
|
|
|
+ expect(square.parentId).toBe(focus.id);
|
|
|
+ expect(tile.parentId).toBe(square.node.id);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('calls a nine-implementation interface polymorphic', () => {
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, nodeNamed('Plugin', 'interface'))!;
|
|
|
+ expect(hierarchy.directImplementers).toBeGreaterThanOrEqual(DISPATCH_MIN_IMPLEMENTERS);
|
|
|
+ expect(hierarchy.polymorphic).toBe(true);
|
|
|
+ expect(hierarchy.directSubtypes).toBe(hierarchy.descendants.filter((d) => d.depth === 1).length);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('does not call a two-implementation interface polymorphic', () => {
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
|
|
|
+ expect(hierarchy.directSubtypes).toBe(2);
|
|
|
+ expect(hierarchy.polymorphic).toBe(false);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('buildTypeHierarchy — Go implicit satisfaction', () => {
|
|
|
+ it('finds the implementations of an interface no file names', () => {
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
|
|
|
+ const names = hierarchy.descendants.map((d) => d.node.name).sort();
|
|
|
+ expect(names).toEqual(['Fixed', 'System']);
|
|
|
+ expect(hierarchy.descendants.every((d) => d.relation === 'implements')).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('marks the synthesized edge, and keeps where it was wired', () => {
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
|
|
|
+ const system = hierarchy.descendants.find((d) => d.node.name === 'System')!;
|
|
|
+ expect(system.synthesized).toBe(true);
|
|
|
+ const meta = (system.edge.metadata ?? {}) as Record<string, unknown>;
|
|
|
+ expect(meta.synthesizedBy).toBe('go-implements');
|
|
|
+ expect(String(meta.registeredAt)).toContain('clock.go');
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('buildTypeHierarchy — overrides', () => {
|
|
|
+ it('marks a member that redeclares an ancestor s, and names the ancestor', () => {
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, nodeNamed('Square', 'class'))!;
|
|
|
+ const matches = [...hierarchy.overrides.values()];
|
|
|
+ const draw = matches.find((m) => m.baseTypeName === 'Shape');
|
|
|
+ expect(draw, 'Square.draw should be matched against Shape.draw').toBeTruthy();
|
|
|
+ expect(draw!.relation).toBe('extends');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('leaves a member that declares something new unmarked', () => {
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'))!;
|
|
|
+ // `label` exists on nothing above Tile.
|
|
|
+ const named = [...hierarchy.overrides.values()].map((m) => m.memberId);
|
|
|
+ const label = cg
|
|
|
+ .getOutgoingEdges(nodeNamed('Tile', 'class').id)
|
|
|
+ .filter((e) => e.kind === 'contains')
|
|
|
+ .map((e) => cg.getNode(e.target))
|
|
|
+ .find((n) => n?.name === 'label');
|
|
|
+ if (label) expect(named).not.toContain(label.id);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('can be switched off without changing the tree', () => {
|
|
|
+ const focus = nodeNamed('Square', 'class');
|
|
|
+ const withOverrides = buildTypeHierarchy(cg, focus)!;
|
|
|
+ const without = buildTypeHierarchy(cg, focus, { overrides: false })!;
|
|
|
+ expect(without.overrides.size).toBe(0);
|
|
|
+ expect(without.descendants.length).toBe(withOverrides.descendants.length);
|
|
|
+ expect(without.ancestors.length).toBe(withOverrides.ancestors.length);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('countImplementers', () => {
|
|
|
+ it('counts distinct types, and agrees with the fan it sits beside', () => {
|
|
|
+ const plugin = nodeNamed('Plugin', 'interface');
|
|
|
+ const hierarchy = buildTypeHierarchy(cg, plugin)!;
|
|
|
+ expect(countImplementers(cg, plugin.id)).toBe(hierarchy.directSubtypes);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('is zero for a type nothing extends', () => {
|
|
|
+ expect(countImplementers(cg, nodeNamed('Tile', 'class').id)).toBe(0);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('the /api/node block', () => {
|
|
|
+ it('is null for a function', () => {
|
|
|
+ const fn = cg
|
|
|
+ .searchNodes('run', { limit: 40 })
|
|
|
+ .map((r: any) => (r.node ?? r) as Node)
|
|
|
+ .find((n) => n.kind === 'method');
|
|
|
+ if (fn) expect(buildHierarchy(cg, fn)).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('is null for a type with no hierarchy at all', () => {
|
|
|
+ const orphan = { id: 'x', kind: 'class', name: 'Nope' } as Node;
|
|
|
+ expect(buildHierarchy(cg, orphan)).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('carries a total that equals the list beneath it', () => {
|
|
|
+ const built = buildHierarchy(cg, nodeNamed('Plugin', 'interface'))!;
|
|
|
+ expect(built.wire.descendants.items.length).toBe(built.wire.descendants.shown);
|
|
|
+ expect(built.wire.descendants.total).toBe(built.wire.descendants.items.length);
|
|
|
+ expect(built.wire.descendants.truncated).toBe(false);
|
|
|
+ expect(built.wire.direct).toBe(built.wire.descendants.total);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('lifts the synthesized edge s wiring onto the row', () => {
|
|
|
+ const built = buildHierarchy(cg, nodeNamed('Clock', 'interface'))!;
|
|
|
+ const system = built.wire.descendants.items.find((d) => d.name === 'System')!;
|
|
|
+ expect(system.synthesized).toBe(true);
|
|
|
+ expect(system.via).toBe('go-implements');
|
|
|
+ expect(system.registeredAt).toContain('clock.go');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('hands the outline its override marks', () => {
|
|
|
+ const built = buildHierarchy(cg, nodeNamed('Square', 'class'))!;
|
|
|
+ expect([...built.overrides.values()].some((o) => o.baseTypeName === 'Shape')).toBe(true);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// =============================================================================
|
|
|
+// The bounds, against a synthetic graph
|
|
|
+// =============================================================================
|
|
|
+
|
|
|
+/**
|
|
|
+ * A `CodeGraph` stub holding only what the walk reads.
|
|
|
+ *
|
|
|
+ * A fan wide enough to hit {@link MAX_DESCENDANTS} would be thousands of files
|
|
|
+ * to index for one assertion, and the property being pinned is arithmetic
|
|
|
+ * rather than extraction: that the cap stops materialising rows, keeps counting
|
|
|
+ * the direct ones, and says it was bounded.
|
|
|
+ */
|
|
|
+function stubGraph(childCount: number): any {
|
|
|
+ const type = (id: string, name: string): Node =>
|
|
|
+ ({
|
|
|
+ id,
|
|
|
+ kind: 'class',
|
|
|
+ name,
|
|
|
+ qualifiedName: name,
|
|
|
+ filePath: `src/${name}.ts`,
|
|
|
+ startLine: 1,
|
|
|
+ endLine: 2,
|
|
|
+ startColumn: 0,
|
|
|
+ endColumn: 0,
|
|
|
+ language: 'typescript',
|
|
|
+ }) as Node;
|
|
|
+
|
|
|
+ const root = type('root', 'Root');
|
|
|
+ const children = Array.from({ length: childCount }, (_, i) => type(`c${i}`, `Child${i}`));
|
|
|
+ const all = new Map<string, Node>([[root.id, root], ...children.map((c) => [c.id, c] as const)]);
|
|
|
+
|
|
|
+ return {
|
|
|
+ getIncomingEdgesTo: (ids: string[]) =>
|
|
|
+ ids.includes('root')
|
|
|
+ ? children.map((c) => ({ source: c.id, target: 'root', kind: 'extends' }))
|
|
|
+ : [],
|
|
|
+ getOutgoingEdgesFrom: () => [],
|
|
|
+ getNodesByIds: (ids: string[]) =>
|
|
|
+ new Map(ids.map((id) => [id, all.get(id)!]).filter(([, n]) => !!n) as Array<[string, Node]>),
|
|
|
+ root,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+describe('the descendant bound', () => {
|
|
|
+ it('stays unbounded under the cap', () => {
|
|
|
+ const cgStub = stubGraph(10);
|
|
|
+ const hierarchy = buildTypeHierarchy(cgStub, cgStub.root)!;
|
|
|
+ expect(hierarchy.descendants.length).toBe(10);
|
|
|
+ expect(hierarchy.directSubtypes).toBe(10);
|
|
|
+ expect(hierarchy.bounded).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('stops materialising rows past the cap but keeps the direct count true', () => {
|
|
|
+ const cgStub = stubGraph(MAX_DESCENDANTS + 37);
|
|
|
+ const hierarchy = buildTypeHierarchy(cgStub, cgStub.root)!;
|
|
|
+ expect(hierarchy.descendants.length).toBe(MAX_DESCENDANTS);
|
|
|
+ // The number of subtypes is not the number of rows, and says so.
|
|
|
+ expect(hierarchy.directSubtypes).toBe(MAX_DESCENDANTS + 37);
|
|
|
+ expect(hierarchy.bounded).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('reports the cap through the wire block as a truncated list', () => {
|
|
|
+ const cgStub = stubGraph(MAX_DESCENDANTS + 37);
|
|
|
+ const built = buildHierarchy(cgStub, cgStub.root)!;
|
|
|
+ expect(built.wire.descendants.truncated).toBe(true);
|
|
|
+ expect(built.wire.descendants.items.length).toBeLessThan(built.wire.descendants.total);
|
|
|
+ expect(built.wire.bounded).toBe(true);
|
|
|
+ expect(built.wire.direct).toBe(MAX_DESCENDANTS + 37);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// =============================================================================
|
|
|
+// The tree the viewer draws
|
|
|
+// =============================================================================
|
|
|
+
|
|
|
+const FOCUS: WireNodeDetail = {
|
|
|
+ id: 'focus',
|
|
|
+ kind: 'interface',
|
|
|
+ name: 'Clock',
|
|
|
+ qualifiedName: 'Clock',
|
|
|
+ file: 'src/clock.ts',
|
|
|
+ line: 1,
|
|
|
+ endLine: 3,
|
|
|
+ language: 'typescript' as WireNodeDetail['language'],
|
|
|
+ test: false,
|
|
|
+ startColumn: 0,
|
|
|
+ endColumn: 0,
|
|
|
+ lines: 3,
|
|
|
+};
|
|
|
+
|
|
|
+function entry(
|
|
|
+ name: string,
|
|
|
+ depth: number,
|
|
|
+ parentId: string,
|
|
|
+ relation: 'extends' | 'implements' = 'implements'
|
|
|
+): WireHierarchyNode {
|
|
|
+ return {
|
|
|
+ id: name,
|
|
|
+ kind: 'class',
|
|
|
+ name,
|
|
|
+ qualifiedName: name,
|
|
|
+ file: `src/${name}.ts`,
|
|
|
+ line: 1,
|
|
|
+ endLine: 2,
|
|
|
+ language: 'typescript' as WireNodeDetail['language'],
|
|
|
+ test: false,
|
|
|
+ depth,
|
|
|
+ parentId,
|
|
|
+ relation,
|
|
|
+ synthesized: false,
|
|
|
+ hiddenSubtypes: 0,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function hierarchyOf(
|
|
|
+ ancestors: WireHierarchyNode[],
|
|
|
+ descendants: WireHierarchyNode[],
|
|
|
+ extra: Partial<WireHierarchy> = {}
|
|
|
+): WireHierarchy {
|
|
|
+ return {
|
|
|
+ ancestors: {
|
|
|
+ total: ancestors.length,
|
|
|
+ shown: ancestors.length,
|
|
|
+ truncated: false,
|
|
|
+ items: ancestors,
|
|
|
+ },
|
|
|
+ descendants: {
|
|
|
+ total: descendants.length,
|
|
|
+ shown: descendants.length,
|
|
|
+ truncated: false,
|
|
|
+ items: descendants,
|
|
|
+ },
|
|
|
+ direct: descendants.filter((d) => d.depth === 1).length,
|
|
|
+ implementers: descendants.filter((d) => d.depth === 1 && d.relation === 'implements').length,
|
|
|
+ bounded: false,
|
|
|
+ polymorphic: false,
|
|
|
+ ...extra,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+describe('buildHierarchyModel', () => {
|
|
|
+ it('puts the focus between the two halves, farthest ancestor at the top', () => {
|
|
|
+ const model = buildHierarchyModel(
|
|
|
+ hierarchyOf(
|
|
|
+ [entry('Base', 2, 'Mid', 'extends'), entry('Mid', 1, 'focus', 'extends')],
|
|
|
+ [entry('Sub', 1, 'focus', 'extends')]
|
|
|
+ ),
|
|
|
+ FOCUS
|
|
|
+ );
|
|
|
+ expect(model.rows.map((r) => r.node.name)).toEqual(['Base', 'Mid', 'Clock', 'Sub']);
|
|
|
+ expect(model.focusIndex).toBe(2);
|
|
|
+ expect(model.rows[2]!.side).toBe('focus');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('indents each descendant level and leaves ancestors at zero', () => {
|
|
|
+ const model = buildHierarchyModel(
|
|
|
+ hierarchyOf([entry('Base', 1, 'focus', 'extends')], [
|
|
|
+ entry('Sub', 1, 'focus', 'extends'),
|
|
|
+ entry('SubSub', 2, 'Sub', 'extends'),
|
|
|
+ ]),
|
|
|
+ FOCUS
|
|
|
+ );
|
|
|
+ const indents = Object.fromEntries(model.rows.map((r) => [r.node.name, r.indent]));
|
|
|
+ expect(indents.Base).toBe(0);
|
|
|
+ expect(indents.Clock).toBe(0);
|
|
|
+ expect(indents.Sub).toBe(HIER_INDENT);
|
|
|
+ expect(indents.SubSub).toBe(HIER_INDENT * 2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('draws a descendant connector from its own parent row, not from the focus', () => {
|
|
|
+ const model = buildHierarchyModel(
|
|
|
+ hierarchyOf([], [entry('Sub', 1, 'focus', 'extends'), entry('SubSub', 2, 'Sub', 'extends')]),
|
|
|
+ FOCUS
|
|
|
+ );
|
|
|
+ const rowOf = (name: string) => model.rows.findIndex((r) => r.node.name === name);
|
|
|
+ const deep = model.connectors.find((c) => c.toIndex === rowOf('SubSub'))!;
|
|
|
+ expect(deep.fromIndex).toBe(rowOf('Sub'));
|
|
|
+ // Leaves the parent's glyph centre, meets the child's glyph.
|
|
|
+ expect(deep.x).toBe(HIER_INDENT + HIER_PORT_X);
|
|
|
+ expect(deep.toX).toBe(HIER_INDENT * 2 + HIER_GLYPH_X - 2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('never hangs a descendant off an ancestor row that shares its name', () => {
|
|
|
+ // A cycle in generated code: `Loop` is both above and below the focus.
|
|
|
+ const model = buildHierarchyModel(
|
|
|
+ hierarchyOf([entry('Loop', 1, 'focus', 'extends')], [entry('Loop', 1, 'focus', 'extends')]),
|
|
|
+ FOCUS
|
|
|
+ );
|
|
|
+ const descendantRow = model.rows.findIndex((r) => r.side === 'descendant');
|
|
|
+ const connector = model.connectors.find((c) => c.toIndex === descendantRow)!;
|
|
|
+ expect(connector.fromIndex).toBe(model.focusIndex);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('carries the relation into the connector so implements can be dashed', () => {
|
|
|
+ const model = buildHierarchyModel(
|
|
|
+ hierarchyOf([], [entry('Impl', 1, 'focus', 'implements')]),
|
|
|
+ FOCUS
|
|
|
+ );
|
|
|
+ expect(model.connectors[0]!.relation).toBe('implements');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('claims a dispatch only when the payload says the type is polymorphic', () => {
|
|
|
+ const plain = buildHierarchyModel(hierarchyOf([], [entry('A', 1, 'focus')]), FOCUS);
|
|
|
+ expect(plain.headline).toBe('');
|
|
|
+
|
|
|
+ const fan = buildHierarchyModel(
|
|
|
+ hierarchyOf([], [entry('A', 1, 'focus')], { polymorphic: true, implementers: 9 }),
|
|
|
+ FOCUS
|
|
|
+ );
|
|
|
+ expect(fan.headline).toContain('9 implementations');
|
|
|
+ expect(fan.headline).toContain('Clock');
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('the fold', () => {
|
|
|
+ const fan = (n: number, relation: 'extends' | 'implements' = 'implements') =>
|
|
|
+ hierarchyOf(
|
|
|
+ [],
|
|
|
+ Array.from({ length: n }, (_, i) => entry(`Impl${i}`, 1, 'focus', relation))
|
|
|
+ );
|
|
|
+
|
|
|
+ it('does not fold a fan of exactly the threshold — a "+0 more" is not a fold', () => {
|
|
|
+ const model = buildHierarchyModel(fan(HIER_FOLD_AT), FOCUS);
|
|
|
+ expect(model.foldFrom).toBeNull();
|
|
|
+ expect(model.foldCount).toBe(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('folds the tail past the threshold and counts what it hid', () => {
|
|
|
+ const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
|
|
|
+ expect(model.foldCount).toBe(5);
|
|
|
+ expect(model.foldNoun).toBe('implementations');
|
|
|
+ const folded = visibleHierarchy(model, false);
|
|
|
+ expect(folded.rows.length).toBe(model.focusIndex + 1 + HIER_FOLD_AT);
|
|
|
+ expect(visibleHierarchy(model, true).rows.length).toBe(model.rows.length);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('never leaves a connector running into the fold', () => {
|
|
|
+ const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
|
|
|
+ const folded = visibleHierarchy(model, false);
|
|
|
+ for (const connector of folded.connectors) {
|
|
|
+ expect(connector.toIndex).toBeLessThan(folded.rows.length);
|
|
|
+ expect(connector.fromIndex).toBeLessThan(folded.rows.length);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('calls a family of subclasses subclasses, not implementations', () => {
|
|
|
+ const model = buildHierarchyModel(fan(HIER_FOLD_AT + 2, 'extends'), FOCUS);
|
|
|
+ expect(model.foldNoun).toBe('subclasses');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('heights are the row count times the row height, with nothing measured', () => {
|
|
|
+ const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
|
|
|
+ expect(visibleHierarchy(model, false).height).toBe(
|
|
|
+ (model.focusIndex + 1 + HIER_FOLD_AT) * HIER_ROW_H
|
|
|
+ );
|
|
|
+ expect(visibleHierarchy(model, true).height).toBe(model.rows.length * HIER_ROW_H);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('connectorPath', () => {
|
|
|
+ it('is two straight runs and a corner, never a curve', () => {
|
|
|
+ const path = connectorPath({
|
|
|
+ fromIndex: 0,
|
|
|
+ toIndex: 1,
|
|
|
+ x: 26,
|
|
|
+ toX: 38,
|
|
|
+ relation: 'extends',
|
|
|
+ synthesized: false,
|
|
|
+ });
|
|
|
+ expect(path).toBe(`M 26 ${HIER_ROW_H / 2} L 26 ${HIER_ROW_H + HIER_ROW_H / 2} L 38 ${HIER_ROW_H + HIER_ROW_H / 2}`);
|
|
|
+ expect(path).not.toContain('C');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('drops the horizontal run when the two rows share an indent', () => {
|
|
|
+ const path = connectorPath({
|
|
|
+ fromIndex: 0,
|
|
|
+ toIndex: 1,
|
|
|
+ x: 26,
|
|
|
+ toX: 26,
|
|
|
+ relation: 'implements',
|
|
|
+ synthesized: false,
|
|
|
+ });
|
|
|
+ expect(path.match(/L/g)).toHaveLength(1);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('the note under the tree', () => {
|
|
|
+ it('says how much of the fan is on screen when it was capped', () => {
|
|
|
+ const payload = hierarchyOf([], [entry('A', 1, 'focus')]);
|
|
|
+ payload.descendants.total = 900;
|
|
|
+ payload.descendants.truncated = true;
|
|
|
+ const model = buildHierarchyModel(payload, FOCUS);
|
|
|
+ expect(model.note).toContain('900');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('says deeper subtypes exist when the walk stopped rather than the list', () => {
|
|
|
+ const model = buildHierarchyModel(
|
|
|
+ hierarchyOf([], [entry('A', 1, 'focus')], { bounded: true }),
|
|
|
+ FOCUS
|
|
|
+ );
|
|
|
+ expect(model.note).toContain('Deeper subtypes');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('is empty when the payload is the whole truth', () => {
|
|
|
+ expect(buildHierarchyModel(hierarchyOf([], [entry('A', 1, 'focus')]), FOCUS).note).toBe('');
|
|
|
+ });
|
|
|
+});
|