| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622 |
- /**
- * 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('');
- });
- });
|