type-hierarchy.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. /**
  2. * The type hierarchy (CG-58) — the walk, the fan, and the tree the viewer draws.
  3. *
  4. * The walk half runs against a real indexed fixture rather than a stubbed
  5. * `CodeGraph`: the properties worth pinning are ones only a real index has —
  6. * that a Go struct satisfies an interface through a SYNTHESIZED `implements`
  7. * edge with no textual link between the two files, that a self-referential
  8. * `extends` in generated code does not loop, that the breadth-first order puts
  9. * every direct subtype ahead of any indirect one.
  10. *
  11. * The layout half is pure arithmetic over a payload, so it is asserted
  12. * directly. Everything the block does that could be WRONG rather than merely
  13. * ugly lives there: which row a connector attaches to, what folds, and which
  14. * noun the fold uses.
  15. */
  16. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  17. import * as fs from 'fs';
  18. import * as os from 'os';
  19. import * as path from 'path';
  20. import CodeGraph from '../src/index';
  21. import type { Node } from '../src/types';
  22. import {
  23. buildTypeHierarchy,
  24. canHaveHierarchy,
  25. countImplementers,
  26. DISPATCH_MIN_IMPLEMENTERS,
  27. MAX_DESCENDANTS,
  28. } from '../src/graph/type-hierarchy';
  29. import { buildHierarchy } from '../src/ui-server/api/hierarchy';
  30. import {
  31. buildHierarchyModel,
  32. connectorPath,
  33. visibleHierarchy,
  34. HIER_FOLD_AT,
  35. HIER_GLYPH_X,
  36. HIER_INDENT,
  37. HIER_PORT_X,
  38. HIER_ROW_H,
  39. } from '../ui/src/lib/hierarchy-model';
  40. import type {
  41. WireHierarchy,
  42. WireHierarchyNode,
  43. WireNodeDetail,
  44. } from '../ui/src/lib/wire';
  45. // =============================================================================
  46. // A real index
  47. // =============================================================================
  48. let tempDir: string;
  49. let projectRoot: string;
  50. let cg: CodeGraph;
  51. /** The one node with this name and kind, or a failure that says which was missing. */
  52. function nodeNamed(name: string, kind?: string): Node {
  53. const hits = cg
  54. .searchNodes(name, { limit: 40 })
  55. .map((r: any) => (r.node ?? r) as Node)
  56. .filter((n) => n.name === name && (!kind || n.kind === kind));
  57. expect(hits.length, `no ${kind ?? 'node'} named ${name}`).toBeGreaterThan(0);
  58. return hits[0]!;
  59. }
  60. beforeAll(async () => {
  61. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-hierarchy-'));
  62. projectRoot = path.join(tempDir, 'project');
  63. const src = path.join(projectRoot, 'src');
  64. fs.mkdirSync(src, { recursive: true });
  65. // A three-level TypeScript chain with a real override, plus an interface with
  66. // enough implementations to be a dispatch fan.
  67. fs.writeFileSync(
  68. path.join(src, 'shapes.ts'),
  69. `export interface Drawable {
  70. draw(): string;
  71. }
  72. export abstract class Shape implements Drawable {
  73. draw(): string {
  74. return 'shape';
  75. }
  76. area(): number {
  77. return 0;
  78. }
  79. }
  80. export class Square extends Shape {
  81. draw(): string {
  82. return 'square';
  83. }
  84. }
  85. export class Tile extends Square {
  86. label = 'tile';
  87. }
  88. `
  89. );
  90. // Nine implementations, so the fan clears DISPATCH_MIN_IMPLEMENTERS.
  91. const targets = [
  92. 'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel', 'India',
  93. ];
  94. fs.writeFileSync(
  95. path.join(src, 'plugins.ts'),
  96. `export interface Plugin {
  97. run(): void;
  98. }
  99. ${targets
  100. .map((name) => `export class ${name}Plugin implements Plugin {\n run(): void {}\n}`)
  101. .join('\n\n')}
  102. `
  103. );
  104. // Go: `System` satisfies `Clock` without either file naming the other. The
  105. // `implements` edge here is synthesized, which is the case the viewer draws
  106. // differently — the fixture mirrors `__tests__/fixtures/payroll-go`.
  107. fs.writeFileSync(path.join(projectRoot, 'go.mod'), 'module fixture\n\ngo 1.22\n');
  108. fs.writeFileSync(
  109. path.join(src, 'clock.go'),
  110. `package clock
  111. import "time"
  112. // Clock is the time seam.
  113. type Clock interface {
  114. Now() time.Time
  115. }
  116. // System is the production clock.
  117. type System struct{}
  118. func (System) Now() time.Time { return time.Now().UTC() }
  119. // Fixed is a frozen clock.
  120. type Fixed struct{ At time.Time }
  121. func (f Fixed) Now() time.Time { return f.At }
  122. `
  123. );
  124. cg = CodeGraph.initSync(projectRoot, {
  125. config: { include: ['src/**/*.ts', 'src/**/*.go'], exclude: [] },
  126. });
  127. await cg.indexAll();
  128. cg.resolveReferences();
  129. }, 120_000);
  130. afterAll(() => {
  131. cg?.close();
  132. if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  133. });
  134. describe('canHaveHierarchy', () => {
  135. it('is false for a function, so the walk never runs for one', () => {
  136. expect(canHaveHierarchy({ kind: 'function' } as Node)).toBe(false);
  137. expect(canHaveHierarchy({ kind: 'method' } as Node)).toBe(false);
  138. expect(canHaveHierarchy({ kind: 'class' } as Node)).toBe(true);
  139. expect(canHaveHierarchy({ kind: 'interface' } as Node)).toBe(true);
  140. expect(canHaveHierarchy({ kind: 'struct' } as Node)).toBe(true);
  141. expect(canHaveHierarchy({ kind: 'trait' } as Node)).toBe(true);
  142. });
  143. });
  144. describe('buildTypeHierarchy — upward', () => {
  145. it('walks past the direct parent to the whole chain', () => {
  146. const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'));
  147. expect(hierarchy).not.toBeNull();
  148. const byName = new Map(hierarchy!.ancestors.map((a) => [a.node.name, a]));
  149. expect(byName.get('Square')?.depth).toBe(1);
  150. expect(byName.get('Shape')?.depth).toBe(2);
  151. // `Shape implements Drawable`, so the interface is three steps up from Tile.
  152. expect(byName.get('Drawable')?.depth).toBe(3);
  153. expect(byName.get('Square')?.relation).toBe('extends');
  154. expect(byName.get('Drawable')?.relation).toBe('implements');
  155. });
  156. it('nearest ancestors come first', () => {
  157. const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'))!;
  158. const depths = hierarchy.ancestors.map((a) => a.depth);
  159. expect(depths).toEqual([...depths].sort((a, b) => a - b));
  160. });
  161. });
  162. describe('buildTypeHierarchy — the fan', () => {
  163. it('returns every direct subtype before any indirect one', () => {
  164. const hierarchy = buildTypeHierarchy(cg, nodeNamed('Shape', 'class'))!;
  165. const depths = hierarchy.descendants.map((d) => d.depth);
  166. expect(depths).toEqual([...depths].sort((a, b) => a - b));
  167. expect(hierarchy.descendants.map((d) => d.node.name)).toContain('Square');
  168. expect(hierarchy.descendants.map((d) => d.node.name)).toContain('Tile');
  169. expect(hierarchy.directSubtypes).toBe(1);
  170. });
  171. it('hangs an indirect subtype off its own parent, not off the focus', () => {
  172. const focus = nodeNamed('Shape', 'class');
  173. const hierarchy = buildTypeHierarchy(cg, focus)!;
  174. const square = hierarchy.descendants.find((d) => d.node.name === 'Square')!;
  175. const tile = hierarchy.descendants.find((d) => d.node.name === 'Tile')!;
  176. expect(square.parentId).toBe(focus.id);
  177. expect(tile.parentId).toBe(square.node.id);
  178. });
  179. it('calls a nine-implementation interface polymorphic', () => {
  180. const hierarchy = buildTypeHierarchy(cg, nodeNamed('Plugin', 'interface'))!;
  181. expect(hierarchy.directImplementers).toBeGreaterThanOrEqual(DISPATCH_MIN_IMPLEMENTERS);
  182. expect(hierarchy.polymorphic).toBe(true);
  183. expect(hierarchy.directSubtypes).toBe(hierarchy.descendants.filter((d) => d.depth === 1).length);
  184. });
  185. it('does not call a two-implementation interface polymorphic', () => {
  186. const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
  187. expect(hierarchy.directSubtypes).toBe(2);
  188. expect(hierarchy.polymorphic).toBe(false);
  189. });
  190. });
  191. describe('buildTypeHierarchy — Go implicit satisfaction', () => {
  192. it('finds the implementations of an interface no file names', () => {
  193. const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
  194. const names = hierarchy.descendants.map((d) => d.node.name).sort();
  195. expect(names).toEqual(['Fixed', 'System']);
  196. expect(hierarchy.descendants.every((d) => d.relation === 'implements')).toBe(true);
  197. });
  198. it('marks the synthesized edge, and keeps where it was wired', () => {
  199. const hierarchy = buildTypeHierarchy(cg, nodeNamed('Clock', 'interface'))!;
  200. const system = hierarchy.descendants.find((d) => d.node.name === 'System')!;
  201. expect(system.synthesized).toBe(true);
  202. const meta = (system.edge.metadata ?? {}) as Record<string, unknown>;
  203. expect(meta.synthesizedBy).toBe('go-implements');
  204. expect(String(meta.registeredAt)).toContain('clock.go');
  205. });
  206. });
  207. describe('buildTypeHierarchy — overrides', () => {
  208. it('marks a member that redeclares an ancestor s, and names the ancestor', () => {
  209. const hierarchy = buildTypeHierarchy(cg, nodeNamed('Square', 'class'))!;
  210. const matches = [...hierarchy.overrides.values()];
  211. const draw = matches.find((m) => m.baseTypeName === 'Shape');
  212. expect(draw, 'Square.draw should be matched against Shape.draw').toBeTruthy();
  213. expect(draw!.relation).toBe('extends');
  214. });
  215. it('leaves a member that declares something new unmarked', () => {
  216. const hierarchy = buildTypeHierarchy(cg, nodeNamed('Tile', 'class'))!;
  217. // `label` exists on nothing above Tile.
  218. const named = [...hierarchy.overrides.values()].map((m) => m.memberId);
  219. const label = cg
  220. .getOutgoingEdges(nodeNamed('Tile', 'class').id)
  221. .filter((e) => e.kind === 'contains')
  222. .map((e) => cg.getNode(e.target))
  223. .find((n) => n?.name === 'label');
  224. if (label) expect(named).not.toContain(label.id);
  225. });
  226. it('can be switched off without changing the tree', () => {
  227. const focus = nodeNamed('Square', 'class');
  228. const withOverrides = buildTypeHierarchy(cg, focus)!;
  229. const without = buildTypeHierarchy(cg, focus, { overrides: false })!;
  230. expect(without.overrides.size).toBe(0);
  231. expect(without.descendants.length).toBe(withOverrides.descendants.length);
  232. expect(without.ancestors.length).toBe(withOverrides.ancestors.length);
  233. });
  234. });
  235. describe('countImplementers', () => {
  236. it('counts distinct types, and agrees with the fan it sits beside', () => {
  237. const plugin = nodeNamed('Plugin', 'interface');
  238. const hierarchy = buildTypeHierarchy(cg, plugin)!;
  239. expect(countImplementers(cg, plugin.id)).toBe(hierarchy.directSubtypes);
  240. });
  241. it('is zero for a type nothing extends', () => {
  242. expect(countImplementers(cg, nodeNamed('Tile', 'class').id)).toBe(0);
  243. });
  244. });
  245. describe('the /api/node block', () => {
  246. it('is null for a function', () => {
  247. const fn = cg
  248. .searchNodes('run', { limit: 40 })
  249. .map((r: any) => (r.node ?? r) as Node)
  250. .find((n) => n.kind === 'method');
  251. if (fn) expect(buildHierarchy(cg, fn)).toBeNull();
  252. });
  253. it('is null for a type with no hierarchy at all', () => {
  254. const orphan = { id: 'x', kind: 'class', name: 'Nope' } as Node;
  255. expect(buildHierarchy(cg, orphan)).toBeNull();
  256. });
  257. it('carries a total that equals the list beneath it', () => {
  258. const built = buildHierarchy(cg, nodeNamed('Plugin', 'interface'))!;
  259. expect(built.wire.descendants.items.length).toBe(built.wire.descendants.shown);
  260. expect(built.wire.descendants.total).toBe(built.wire.descendants.items.length);
  261. expect(built.wire.descendants.truncated).toBe(false);
  262. expect(built.wire.direct).toBe(built.wire.descendants.total);
  263. });
  264. it('lifts the synthesized edge s wiring onto the row', () => {
  265. const built = buildHierarchy(cg, nodeNamed('Clock', 'interface'))!;
  266. const system = built.wire.descendants.items.find((d) => d.name === 'System')!;
  267. expect(system.synthesized).toBe(true);
  268. expect(system.via).toBe('go-implements');
  269. expect(system.registeredAt).toContain('clock.go');
  270. });
  271. it('hands the outline its override marks', () => {
  272. const built = buildHierarchy(cg, nodeNamed('Square', 'class'))!;
  273. expect([...built.overrides.values()].some((o) => o.baseTypeName === 'Shape')).toBe(true);
  274. });
  275. });
  276. // =============================================================================
  277. // The bounds, against a synthetic graph
  278. // =============================================================================
  279. /**
  280. * A `CodeGraph` stub holding only what the walk reads.
  281. *
  282. * A fan wide enough to hit {@link MAX_DESCENDANTS} would be thousands of files
  283. * to index for one assertion, and the property being pinned is arithmetic
  284. * rather than extraction: that the cap stops materialising rows, keeps counting
  285. * the direct ones, and says it was bounded.
  286. */
  287. function stubGraph(childCount: number): any {
  288. const type = (id: string, name: string): Node =>
  289. ({
  290. id,
  291. kind: 'class',
  292. name,
  293. qualifiedName: name,
  294. filePath: `src/${name}.ts`,
  295. startLine: 1,
  296. endLine: 2,
  297. startColumn: 0,
  298. endColumn: 0,
  299. language: 'typescript',
  300. }) as Node;
  301. const root = type('root', 'Root');
  302. const children = Array.from({ length: childCount }, (_, i) => type(`c${i}`, `Child${i}`));
  303. const all = new Map<string, Node>([[root.id, root], ...children.map((c) => [c.id, c] as const)]);
  304. return {
  305. getIncomingEdgesTo: (ids: string[]) =>
  306. ids.includes('root')
  307. ? children.map((c) => ({ source: c.id, target: 'root', kind: 'extends' }))
  308. : [],
  309. getOutgoingEdgesFrom: () => [],
  310. getNodesByIds: (ids: string[]) =>
  311. new Map(ids.map((id) => [id, all.get(id)!]).filter(([, n]) => !!n) as Array<[string, Node]>),
  312. root,
  313. };
  314. }
  315. describe('the descendant bound', () => {
  316. it('stays unbounded under the cap', () => {
  317. const cgStub = stubGraph(10);
  318. const hierarchy = buildTypeHierarchy(cgStub, cgStub.root)!;
  319. expect(hierarchy.descendants.length).toBe(10);
  320. expect(hierarchy.directSubtypes).toBe(10);
  321. expect(hierarchy.bounded).toBe(false);
  322. });
  323. it('stops materialising rows past the cap but keeps the direct count true', () => {
  324. const cgStub = stubGraph(MAX_DESCENDANTS + 37);
  325. const hierarchy = buildTypeHierarchy(cgStub, cgStub.root)!;
  326. expect(hierarchy.descendants.length).toBe(MAX_DESCENDANTS);
  327. // The number of subtypes is not the number of rows, and says so.
  328. expect(hierarchy.directSubtypes).toBe(MAX_DESCENDANTS + 37);
  329. expect(hierarchy.bounded).toBe(true);
  330. });
  331. it('reports the cap through the wire block as a truncated list', () => {
  332. const cgStub = stubGraph(MAX_DESCENDANTS + 37);
  333. const built = buildHierarchy(cgStub, cgStub.root)!;
  334. expect(built.wire.descendants.truncated).toBe(true);
  335. expect(built.wire.descendants.items.length).toBeLessThan(built.wire.descendants.total);
  336. expect(built.wire.bounded).toBe(true);
  337. expect(built.wire.direct).toBe(MAX_DESCENDANTS + 37);
  338. });
  339. });
  340. // =============================================================================
  341. // The tree the viewer draws
  342. // =============================================================================
  343. const FOCUS: WireNodeDetail = {
  344. id: 'focus',
  345. kind: 'interface',
  346. name: 'Clock',
  347. qualifiedName: 'Clock',
  348. file: 'src/clock.ts',
  349. line: 1,
  350. endLine: 3,
  351. language: 'typescript' as WireNodeDetail['language'],
  352. test: false,
  353. startColumn: 0,
  354. endColumn: 0,
  355. lines: 3,
  356. };
  357. function entry(
  358. name: string,
  359. depth: number,
  360. parentId: string,
  361. relation: 'extends' | 'implements' = 'implements'
  362. ): WireHierarchyNode {
  363. return {
  364. id: name,
  365. kind: 'class',
  366. name,
  367. qualifiedName: name,
  368. file: `src/${name}.ts`,
  369. line: 1,
  370. endLine: 2,
  371. language: 'typescript' as WireNodeDetail['language'],
  372. test: false,
  373. depth,
  374. parentId,
  375. relation,
  376. synthesized: false,
  377. hiddenSubtypes: 0,
  378. };
  379. }
  380. function hierarchyOf(
  381. ancestors: WireHierarchyNode[],
  382. descendants: WireHierarchyNode[],
  383. extra: Partial<WireHierarchy> = {}
  384. ): WireHierarchy {
  385. return {
  386. ancestors: {
  387. total: ancestors.length,
  388. shown: ancestors.length,
  389. truncated: false,
  390. items: ancestors,
  391. },
  392. descendants: {
  393. total: descendants.length,
  394. shown: descendants.length,
  395. truncated: false,
  396. items: descendants,
  397. },
  398. direct: descendants.filter((d) => d.depth === 1).length,
  399. implementers: descendants.filter((d) => d.depth === 1 && d.relation === 'implements').length,
  400. bounded: false,
  401. polymorphic: false,
  402. ...extra,
  403. };
  404. }
  405. describe('buildHierarchyModel', () => {
  406. it('puts the focus between the two halves, farthest ancestor at the top', () => {
  407. const model = buildHierarchyModel(
  408. hierarchyOf(
  409. [entry('Base', 2, 'Mid', 'extends'), entry('Mid', 1, 'focus', 'extends')],
  410. [entry('Sub', 1, 'focus', 'extends')]
  411. ),
  412. FOCUS
  413. );
  414. expect(model.rows.map((r) => r.node.name)).toEqual(['Base', 'Mid', 'Clock', 'Sub']);
  415. expect(model.focusIndex).toBe(2);
  416. expect(model.rows[2]!.side).toBe('focus');
  417. });
  418. it('indents each descendant level and leaves ancestors at zero', () => {
  419. const model = buildHierarchyModel(
  420. hierarchyOf([entry('Base', 1, 'focus', 'extends')], [
  421. entry('Sub', 1, 'focus', 'extends'),
  422. entry('SubSub', 2, 'Sub', 'extends'),
  423. ]),
  424. FOCUS
  425. );
  426. const indents = Object.fromEntries(model.rows.map((r) => [r.node.name, r.indent]));
  427. expect(indents.Base).toBe(0);
  428. expect(indents.Clock).toBe(0);
  429. expect(indents.Sub).toBe(HIER_INDENT);
  430. expect(indents.SubSub).toBe(HIER_INDENT * 2);
  431. });
  432. it('draws a descendant connector from its own parent row, not from the focus', () => {
  433. const model = buildHierarchyModel(
  434. hierarchyOf([], [entry('Sub', 1, 'focus', 'extends'), entry('SubSub', 2, 'Sub', 'extends')]),
  435. FOCUS
  436. );
  437. const rowOf = (name: string) => model.rows.findIndex((r) => r.node.name === name);
  438. const deep = model.connectors.find((c) => c.toIndex === rowOf('SubSub'))!;
  439. expect(deep.fromIndex).toBe(rowOf('Sub'));
  440. // Leaves the parent's glyph centre, meets the child's glyph.
  441. expect(deep.x).toBe(HIER_INDENT + HIER_PORT_X);
  442. expect(deep.toX).toBe(HIER_INDENT * 2 + HIER_GLYPH_X - 2);
  443. });
  444. it('never hangs a descendant off an ancestor row that shares its name', () => {
  445. // A cycle in generated code: `Loop` is both above and below the focus.
  446. const model = buildHierarchyModel(
  447. hierarchyOf([entry('Loop', 1, 'focus', 'extends')], [entry('Loop', 1, 'focus', 'extends')]),
  448. FOCUS
  449. );
  450. const descendantRow = model.rows.findIndex((r) => r.side === 'descendant');
  451. const connector = model.connectors.find((c) => c.toIndex === descendantRow)!;
  452. expect(connector.fromIndex).toBe(model.focusIndex);
  453. });
  454. it('carries the relation into the connector so implements can be dashed', () => {
  455. const model = buildHierarchyModel(
  456. hierarchyOf([], [entry('Impl', 1, 'focus', 'implements')]),
  457. FOCUS
  458. );
  459. expect(model.connectors[0]!.relation).toBe('implements');
  460. });
  461. it('claims a dispatch only when the payload says the type is polymorphic', () => {
  462. const plain = buildHierarchyModel(hierarchyOf([], [entry('A', 1, 'focus')]), FOCUS);
  463. expect(plain.headline).toBe('');
  464. const fan = buildHierarchyModel(
  465. hierarchyOf([], [entry('A', 1, 'focus')], { polymorphic: true, implementers: 9 }),
  466. FOCUS
  467. );
  468. expect(fan.headline).toContain('9 implementations');
  469. expect(fan.headline).toContain('Clock');
  470. });
  471. });
  472. describe('the fold', () => {
  473. const fan = (n: number, relation: 'extends' | 'implements' = 'implements') =>
  474. hierarchyOf(
  475. [],
  476. Array.from({ length: n }, (_, i) => entry(`Impl${i}`, 1, 'focus', relation))
  477. );
  478. it('does not fold a fan of exactly the threshold — a "+0 more" is not a fold', () => {
  479. const model = buildHierarchyModel(fan(HIER_FOLD_AT), FOCUS);
  480. expect(model.foldFrom).toBeNull();
  481. expect(model.foldCount).toBe(0);
  482. });
  483. it('folds the tail past the threshold and counts what it hid', () => {
  484. const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
  485. expect(model.foldCount).toBe(5);
  486. expect(model.foldNoun).toBe('implementations');
  487. const folded = visibleHierarchy(model, false);
  488. expect(folded.rows.length).toBe(model.focusIndex + 1 + HIER_FOLD_AT);
  489. expect(visibleHierarchy(model, true).rows.length).toBe(model.rows.length);
  490. });
  491. it('never leaves a connector running into the fold', () => {
  492. const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
  493. const folded = visibleHierarchy(model, false);
  494. for (const connector of folded.connectors) {
  495. expect(connector.toIndex).toBeLessThan(folded.rows.length);
  496. expect(connector.fromIndex).toBeLessThan(folded.rows.length);
  497. }
  498. });
  499. it('calls a family of subclasses subclasses, not implementations', () => {
  500. const model = buildHierarchyModel(fan(HIER_FOLD_AT + 2, 'extends'), FOCUS);
  501. expect(model.foldNoun).toBe('subclasses');
  502. });
  503. it('heights are the row count times the row height, with nothing measured', () => {
  504. const model = buildHierarchyModel(fan(HIER_FOLD_AT + 5), FOCUS);
  505. expect(visibleHierarchy(model, false).height).toBe(
  506. (model.focusIndex + 1 + HIER_FOLD_AT) * HIER_ROW_H
  507. );
  508. expect(visibleHierarchy(model, true).height).toBe(model.rows.length * HIER_ROW_H);
  509. });
  510. });
  511. describe('connectorPath', () => {
  512. it('is two straight runs and a corner, never a curve', () => {
  513. const path = connectorPath({
  514. fromIndex: 0,
  515. toIndex: 1,
  516. x: 26,
  517. toX: 38,
  518. relation: 'extends',
  519. synthesized: false,
  520. });
  521. 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}`);
  522. expect(path).not.toContain('C');
  523. });
  524. it('drops the horizontal run when the two rows share an indent', () => {
  525. const path = connectorPath({
  526. fromIndex: 0,
  527. toIndex: 1,
  528. x: 26,
  529. toX: 26,
  530. relation: 'implements',
  531. synthesized: false,
  532. });
  533. expect(path.match(/L/g)).toHaveLength(1);
  534. });
  535. });
  536. describe('the note under the tree', () => {
  537. it('says how much of the fan is on screen when it was capped', () => {
  538. const payload = hierarchyOf([], [entry('A', 1, 'focus')]);
  539. payload.descendants.total = 900;
  540. payload.descendants.truncated = true;
  541. const model = buildHierarchyModel(payload, FOCUS);
  542. expect(model.note).toContain('900');
  543. });
  544. it('says deeper subtypes exist when the walk stopped rather than the list', () => {
  545. const model = buildHierarchyModel(
  546. hierarchyOf([], [entry('A', 1, 'focus')], { bounded: true }),
  547. FOCUS
  548. );
  549. expect(model.note).toContain('Deeper subtypes');
  550. });
  551. it('is empty when the payload is the whole truth', () => {
  552. expect(buildHierarchyModel(hierarchyOf([], [entry('A', 1, 'focus')]), FOCUS).note).toBe('');
  553. });
  554. });