ui-map-model.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. /**
  2. * The Map's layout, without a browser (CG-49).
  3. *
  4. * The properties under test are the ones that make the picture mean something.
  5. * A map is only worth reading if the vertical position of a box is a claim
  6. * about the code — so the tests here are mostly about *why* a module ends up
  7. * where it does:
  8. *
  9. * - the layering rests on `declared` weight, not raw counts, because bare name
  10. * matching invents cross-module links out of shared method names;
  11. * - a two-cycle keeps its heavier direction and the lighter one is reported,
  12. * never quietly dropped;
  13. * - the same payload always produces the same picture, because a diagram you
  14. * cannot recognise between two visits is not a map of anything.
  15. *
  16. * The endpoint that feeds it is tested against a real index in
  17. * `ui-map-api.test.ts`.
  18. */
  19. import { describe, it, expect } from 'vitest';
  20. import {
  21. buildMapLayout,
  22. isEdgeVisible,
  23. linkId,
  24. moduleMetaLabel,
  25. nodeWidth,
  26. portPoint,
  27. strokeWidthFor,
  28. LAYER_GAP,
  29. MIN_WEIGHT,
  30. MIN_WEIGHT_WITH_TESTS,
  31. NODE_HEIGHT,
  32. type MapLayout,
  33. } from '../ui/src/lib/map-model';
  34. import type { WireMapLink, WireMapModule } from '../ui/src/lib/api';
  35. /* ------------------------------------------------------------- fixtures -- */
  36. function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
  37. return {
  38. id,
  39. label: id.slice(id.lastIndexOf('/') + 1) || id,
  40. files: over.files ?? 3,
  41. symbols: over.symbols ?? 30,
  42. languages: over.languages ?? [{ language: 'typescript', files: over.files ?? 3 }],
  43. test: over.test ?? false,
  44. facade: over.facade ?? false,
  45. fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
  46. };
  47. }
  48. function link(
  49. source: string,
  50. target: string,
  51. count: number,
  52. declared = count
  53. ): WireMapLink {
  54. return {
  55. source,
  56. target,
  57. count,
  58. declared,
  59. byKind: [{ kind: 'calls', count }],
  60. topPairs: [],
  61. };
  62. }
  63. function layerOf(layout: MapLayout, id: string): number {
  64. const node = layout.nodes.find((n) => n.id === id);
  65. expect(node, `no node ${id}`).toBeTruthy();
  66. return node!.layer;
  67. }
  68. const OPTS = { includeTests: false };
  69. /* ---------------------------------------------------------------- specs -- */
  70. describe('nodeWidth', () => {
  71. it('fits the wider of the two lines and never goes under the floor', () => {
  72. expect(nodeWidth('ui')).toBe(110);
  73. // A long id outgrows the floor; a long meta line outgrows a short id.
  74. expect(nodeWidth('src/resolution/(root files)')).toBeGreaterThan(200);
  75. expect(nodeWidth('src/db', '1218 symbols · 54 files')).toBeGreaterThan(nodeWidth('src/db'));
  76. });
  77. });
  78. describe('moduleMetaLabel', () => {
  79. it('says the counts in singular when there is one of them', () => {
  80. expect(moduleMetaLabel(mod('src/x', { symbols: 1, files: 1 }))).toBe('1 symbol · 1 file');
  81. expect(moduleMetaLabel(mod('src/x', { symbols: 9, files: 2 }))).toBe('9 symbols · 2 files');
  82. });
  83. });
  84. describe('strokeWidthFor', () => {
  85. it('grows with the logarithm of the count and stops at 6', () => {
  86. expect(strokeWidthFor(1)).toBe(1);
  87. expect(strokeWidthFor(700)).toBeLessThanOrEqual(6);
  88. expect(strokeWidthFor(1_000_000)).toBe(6);
  89. expect(strokeWidthFor(64)).toBeGreaterThan(strokeWidthFor(8));
  90. // A count of zero must not produce -Infinity.
  91. expect(Number.isFinite(strokeWidthFor(0))).toBe(true);
  92. });
  93. });
  94. describe('layering', () => {
  95. const modules = [mod('src/bin'), mod('src/core'), mod('src/db')];
  96. it('puts a module one layer above everything it depends on', () => {
  97. const layout = buildMapLayout(
  98. { modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
  99. OPTS
  100. );
  101. expect(layerOf(layout, 'src/db')).toBe(0);
  102. expect(layerOf(layout, 'src/core')).toBe(1);
  103. expect(layerOf(layout, 'src/bin')).toBe(2);
  104. // Layer 0 is the foundations, and it is drawn at the BOTTOM.
  105. const bin = layout.nodes.find((n) => n.id === 'src/bin')!;
  106. const db = layout.nodes.find((n) => n.id === 'src/db')!;
  107. expect(bin.y).toBeLessThan(db.y);
  108. expect(db.y - bin.y).toBe(2 * (NODE_HEIGHT + LAYER_GAP));
  109. });
  110. it('names only the top and bottom layers', () => {
  111. const layout = buildMapLayout(
  112. { modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
  113. OPTS
  114. );
  115. expect(layout.layers.map((l) => l.label)).toEqual([
  116. 'foundations — depend on nothing below',
  117. null,
  118. 'entry points',
  119. ]);
  120. });
  121. it('ignores a link with nothing declared behind it', () => {
  122. // `src/db -> src/bin` is 40 name-only matches (`run`, `push`, `finish`) and
  123. // would otherwise lift the storage layer above the CLI. It is still drawn —
  124. // as a back-edge — but it must not decide the vertical order.
  125. const layout = buildMapLayout(
  126. {
  127. modules,
  128. links: [
  129. link('src/bin', 'src/core', 10, 10),
  130. link('src/core', 'src/db', 10, 10),
  131. link('src/db', 'src/bin', 40, 0),
  132. ],
  133. },
  134. OPTS
  135. );
  136. expect(layout.basis.kind).toBe('declared');
  137. expect(layerOf(layout, 'src/db')).toBe(0);
  138. expect(layerOf(layout, 'src/bin')).toBe(2);
  139. const noisy = layout.edges.find((e) => e.source === 'src/db' && e.target === 'src/bin')!;
  140. expect(noisy).toBeTruthy();
  141. expect(noisy.back).toBe(true);
  142. });
  143. it('falls back to raw counts, and says so, when almost nothing is declared', () => {
  144. const layout = buildMapLayout(
  145. {
  146. modules,
  147. links: [
  148. link('src/bin', 'src/core', 10, 0),
  149. link('src/core', 'src/db', 10, 0),
  150. link('src/db', 'src/core', 2, 1),
  151. ],
  152. },
  153. OPTS
  154. );
  155. expect(layout.basis.kind).toBe('all');
  156. expect(layout.basis.declaredLinks).toBe(1);
  157. expect(layout.basis.totalLinks).toBe(3);
  158. expect(layout.basis.declaredLinks / layout.basis.totalLinks).toBeLessThan(0.4);
  159. // With raw counts the chain is still a chain, and the light back-reference
  160. // becomes the mutual one.
  161. expect(layerOf(layout, 'src/db')).toBe(0);
  162. expect(layerOf(layout, 'src/bin')).toBe(2);
  163. expect(layout.mutual.map((m) => m.back.source)).toEqual(['src/db']);
  164. });
  165. it('survives a three-module loop instead of recursing forever', () => {
  166. const layout = buildMapLayout(
  167. {
  168. modules,
  169. links: [
  170. link('src/bin', 'src/core', 5),
  171. link('src/core', 'src/db', 5),
  172. link('src/db', 'src/bin', 5),
  173. ],
  174. },
  175. OPTS
  176. );
  177. expect(layout.nodes).toHaveLength(3);
  178. expect(layout.moduleCycles).toEqual([['src/bin', 'src/core', 'src/db']]);
  179. // Every module still got a finite layer.
  180. expect(layout.nodes.every((n) => Number.isInteger(n.layer))).toBe(true);
  181. });
  182. });
  183. describe('two-cycles', () => {
  184. const modules = [mod('src/a'), mod('src/b')];
  185. it('keeps the heavier direction and reports the lighter as mutual', () => {
  186. const layout = buildMapLayout(
  187. { modules, links: [link('src/a', 'src/b', 20), link('src/b', 'src/a', 3)] },
  188. OPTS
  189. );
  190. expect(layerOf(layout, 'src/a')).toBe(1);
  191. expect(layerOf(layout, 'src/b')).toBe(0);
  192. expect(layout.mutual).toHaveLength(1);
  193. expect(layout.mutual[0]!.forward.source).toBe('src/a');
  194. expect(layout.mutual[0]!.back.source).toBe('src/b');
  195. // Both directions are still on the canvas; the lighter one points up.
  196. expect(layout.edges).toHaveLength(2);
  197. expect(layout.edges.find((e) => e.source === 'src/b')!.back).toBe(true);
  198. expect(layout.edges.find((e) => e.source === 'src/a')!.back).toBe(false);
  199. });
  200. it('breaks an exact tie the same way every time', () => {
  201. const one = buildMapLayout(
  202. { modules, links: [link('src/a', 'src/b', 7), link('src/b', 'src/a', 7)] },
  203. OPTS
  204. );
  205. const two = buildMapLayout(
  206. { modules, links: [link('src/b', 'src/a', 7), link('src/a', 'src/b', 7)] },
  207. OPTS
  208. );
  209. expect(one.mutual[0]!.back.source).toBe('src/b');
  210. expect(two.mutual[0]!.back.source).toBe('src/b');
  211. expect(layerOf(one, 'src/a')).toBe(layerOf(two, 'src/a'));
  212. });
  213. });
  214. describe('tests and thresholds', () => {
  215. const modules = [mod('src/core'), mod('__tests__', { test: true })];
  216. const links = [link('__tests__', 'src/core', 30), link('src/core', '__tests__', 2)];
  217. it('leaves test modules out until they are asked for, and their links with them', () => {
  218. const off = buildMapLayout({ modules, links }, { includeTests: false });
  219. expect(off.nodes.map((n) => n.id)).toEqual(['src/core']);
  220. expect(off.edges).toHaveLength(0);
  221. expect(off.minWeight).toBe(MIN_WEIGHT);
  222. const on = buildMapLayout({ modules, links }, { includeTests: true });
  223. expect(on.nodes).toHaveLength(2);
  224. expect(on.edges).toHaveLength(2);
  225. // A test module touches everything, so the bar for a visible link is higher.
  226. expect(on.minWeight).toBe(MIN_WEIGHT_WITH_TESTS);
  227. });
  228. it('marks a link under the threshold thin rather than deleting it', () => {
  229. const layout = buildMapLayout(
  230. {
  231. modules: [mod('src/a'), mod('src/b'), mod('src/c')],
  232. links: [link('src/a', 'src/b', 12), link('src/a', 'src/c', 2)],
  233. },
  234. OPTS
  235. );
  236. const thin = layout.edges.find((e) => e.target === 'src/c')!;
  237. expect(thin.thin).toBe(true);
  238. expect(isEdgeVisible(thin, null)).toBe(false);
  239. // Selecting either end brings it back — that is the whole point of hiding
  240. // it rather than dropping it.
  241. expect(isEdgeVisible(thin, 'src/a')).toBe(true);
  242. expect(isEdgeVisible(thin, 'src/c')).toBe(true);
  243. expect(isEdgeVisible(thin, 'src/b')).toBe(false);
  244. const fat = layout.edges.find((e) => e.target === 'src/b')!;
  245. expect(isEdgeVisible(fat, null)).toBe(true);
  246. expect(isEdgeVisible(fat, 'src/c')).toBe(false);
  247. });
  248. });
  249. describe('ports', () => {
  250. it('gives every link its own port, ordered by where the other end sits', () => {
  251. const layout = buildMapLayout(
  252. {
  253. modules: [mod('src/top'), mod('src/left'), mod('src/mid'), mod('src/right')],
  254. links: [
  255. link('src/top', 'src/left', 9),
  256. link('src/top', 'src/mid', 9),
  257. link('src/top', 'src/right', 9),
  258. ],
  259. },
  260. OPTS
  261. );
  262. const top = layout.nodes.find((n) => n.id === 'src/top')!;
  263. expect(top.sourceHandles).toHaveLength(3);
  264. expect(new Set(top.sourceHandles).size).toBe(3);
  265. // The handle order must follow the targets' left-to-right order, or the
  266. // three edges cross each other inside the gap for no reason.
  267. const xOf = (id: string) => {
  268. const n = layout.nodes.find((m) => m.id === id)!;
  269. return n.x + n.width / 2;
  270. };
  271. const targets = top.sourceHandles.map(
  272. (id) => layout.edges.find((e) => e.id === id)!.target
  273. );
  274. const xs = targets.map(xOf);
  275. expect(xs).toEqual([...xs].sort((a, b) => a - b));
  276. // Each target's single incoming link is its only target handle.
  277. for (const id of ['src/left', 'src/mid', 'src/right']) {
  278. expect(layout.nodes.find((n) => n.id === id)!.targetHandles).toHaveLength(1);
  279. }
  280. });
  281. it('names an edge by its endpoints, so two runs key the same', () => {
  282. expect(linkId({ source: 'a', target: 'b' })).toBe(linkId({ source: 'a', target: 'b' }));
  283. expect(linkId({ source: 'a', target: 'b' })).not.toBe(linkId({ source: 'b', target: 'a' }));
  284. });
  285. });
  286. describe('determinism', () => {
  287. const modules = [
  288. mod('src/alpha'),
  289. mod('src/beta'),
  290. mod('src/gamma'),
  291. mod('src/delta'),
  292. mod('src/epsilon'),
  293. ];
  294. const links = [
  295. link('src/alpha', 'src/beta', 12),
  296. link('src/alpha', 'src/gamma', 8),
  297. link('src/beta', 'src/delta', 15),
  298. link('src/gamma', 'src/delta', 6),
  299. link('src/delta', 'src/epsilon', 20),
  300. link('src/beta', 'src/epsilon', 5),
  301. ];
  302. it('produces an identical layout from an identical payload', () => {
  303. const a = buildMapLayout({ modules, links }, OPTS);
  304. const b = buildMapLayout({ modules, links }, OPTS);
  305. expect(JSON.stringify(b)).toBe(JSON.stringify(a));
  306. });
  307. it('does not depend on the order the payload happened to arrive in', () => {
  308. const a = buildMapLayout({ modules, links }, OPTS);
  309. const b = buildMapLayout(
  310. { modules: [...modules].reverse(), links: [...links].reverse() },
  311. OPTS
  312. );
  313. const positions = (l: MapLayout) =>
  314. l.nodes
  315. .map((n) => `${n.id}@${n.layer}:${Math.round(n.x)},${Math.round(n.y)}`)
  316. .sort()
  317. .join('|');
  318. expect(positions(b)).toBe(positions(a));
  319. });
  320. it('places an unconnected module without stretching the canvas around it', () => {
  321. const withIsland = buildMapLayout(
  322. { modules: [...modules, mod('src/island')], links },
  323. OPTS
  324. );
  325. const island = withIsland.nodes.find((n) => n.id === 'src/island')!;
  326. expect(island).toBeTruthy();
  327. expect(island.layer).toBe(0);
  328. // Parked at the right-hand end of its layer, not interleaved through the
  329. // modules that actually connect.
  330. const sameLayer = withIsland.nodes.filter((n) => n.layer === 0);
  331. expect(Math.max(...sameLayer.map((n) => n.x))).toBe(island.x);
  332. // And the canvas is no wider than the boxes standing shoulder to shoulder.
  333. const widest = Math.max(
  334. ...[0, 1, 2, 3].map((layer) =>
  335. withIsland.nodes
  336. .filter((n) => n.layer === layer)
  337. .reduce((sum, n) => sum + n.width, 0)
  338. )
  339. );
  340. expect(withIsland.width).toBeLessThan(widest + 6 * 34 + 200);
  341. });
  342. });
  343. describe('empty and degenerate inputs', () => {
  344. it('answers an empty payload without throwing', () => {
  345. const layout = buildMapLayout({ modules: [], links: [] }, OPTS);
  346. expect(layout.nodes).toHaveLength(0);
  347. expect(layout.edges).toHaveLength(0);
  348. expect(layout.basis.kind).toBe('all');
  349. expect(Number.isFinite(layout.width)).toBe(true);
  350. expect(Number.isFinite(layout.height)).toBe(true);
  351. });
  352. it('drops a link whose other end was filtered out', () => {
  353. const layout = buildMapLayout(
  354. {
  355. modules: [mod('src/a'), mod('__tests__', { test: true })],
  356. links: [link('src/a', '__tests__', 9), link('src/a', 'src/ghost', 9)],
  357. },
  358. OPTS
  359. );
  360. expect(layout.edges).toHaveLength(0);
  361. });
  362. it('leaves a single layer unlabelled', () => {
  363. const layout = buildMapLayout({ modules: [mod('src/only')], links: [] }, OPTS);
  364. expect(layout.layers).toHaveLength(1);
  365. expect(layout.layers[0]!.label).toBeNull();
  366. });
  367. });
  368. describe('directional ports and room', () => {
  369. const modules = [mod('src/a'), mod('src/b')];
  370. it('draws the Map exactly as before: bottoms leave, tops arrive, every link runs down', () => {
  371. const layout = buildMapLayout(
  372. { modules: [mod('src/bin'), mod('src/core'), mod('src/db')], links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
  373. OPTS
  374. );
  375. for (const node of layout.nodes) {
  376. expect(node.ports.bottom.map((p) => p.id)).toEqual(node.sourceHandles);
  377. expect(node.ports.top.map((p) => p.id)).toEqual(node.targetHandles);
  378. expect(node.ports.bottom.every((p) => p.type === 'source')).toBe(true);
  379. expect(node.ports.top.every((p) => p.type === 'target')).toBe(true);
  380. }
  381. expect(layout.edges.every((e) => e.route === 'down')).toBe(true);
  382. });
  383. it('keeps a back-edge bottom-to-top under layered ports, and top-to-bottom under directional ones', () => {
  384. const payload = { modules, links: [link('src/a', 'src/b', 10), link('src/b', 'src/a', 2)] };
  385. const layered = buildMapLayout(payload, OPTS);
  386. const directional = buildMapLayout(payload, { ...OPTS, ports: 'directional' });
  387. for (const layout of [layered, directional]) {
  388. const back = layout.edges.find((e) => e.source === 'src/b')!;
  389. expect(back.route).toBe('up');
  390. expect(back.back).toBe(true);
  391. }
  392. const a = (l: MapLayout) => l.nodes.find((n) => n.id === 'src/a')!;
  393. const b = (l: MapLayout) => l.nodes.find((n) => n.id === 'src/b')!;
  394. const id = linkId({ source: 'src/b', target: 'src/a' });
  395. // The Map: leaves b's bottom, arrives at a's top — through both boxes, as it always has.
  396. expect(portPoint(b(layered), id, 'source').y).toBe(b(layered).y + NODE_HEIGHT);
  397. expect(portPoint(a(layered), id, 'target').y).toBe(a(layered).y);
  398. // Directional: leaves b's top, arrives at a's bottom — around them.
  399. expect(portPoint(b(directional), id, 'source').y).toBe(b(directional).y);
  400. expect(portPoint(a(directional), id, 'target').y).toBe(a(directional).y + NODE_HEIGHT);
  401. });
  402. it('joins two modules on one layer over the top, under directional ports', () => {
  403. const layout = buildMapLayout(
  404. { modules, links: [link('src/a', 'src/b', 10)] },
  405. { ...OPTS, ports: 'directional', layering: (ids) => new Map(ids.map((id) => [id, 0])) }
  406. );
  407. const edge = layout.edges[0]!;
  408. expect(edge.route).toBe('level');
  409. const a = layout.nodes.find((n) => n.id === 'src/a')!;
  410. const b = layout.nodes.find((n) => n.id === 'src/b')!;
  411. expect(portPoint(a, edge.id, 'source').y).toBe(a.y);
  412. expect(portPoint(b, edge.id, 'target').y).toBe(b.y);
  413. });
  414. it('widens a box to keep its ports apart, and only then', () => {
  415. const leaves = Array.from({ length: 20 }, (_, i) => mod(`src/leaf${i}`));
  416. const payload = { modules: [mod('src/hub'), ...leaves], links: leaves.map((l) => link('src/hub', l.id, 5)) };
  417. const plain = buildMapLayout(payload, OPTS).nodes.find((n) => n.id === 'src/hub')!;
  418. const pitched = buildMapLayout(payload, { ...OPTS, portPitch: 12 }).nodes.find((n) => n.id === 'src/hub')!;
  419. // Nothing depends on the hub, so its second line is the island's.
  420. expect(plain.width).toBe(nodeWidth('src/hub', moduleMetaLabel(mod('src/hub'), true)));
  421. expect(pitched.width).toBe(Math.max(plain.width, 21 * 12));
  422. expect(pitched.width).toBeGreaterThan(plain.width);
  423. });
  424. it('spaces layers by the gap a view asks for', () => {
  425. const payload = { modules, links: [link('src/a', 'src/b', 10)] };
  426. const wide = buildMapLayout(payload, { ...OPTS, layerGap: 116 });
  427. const a = wide.nodes.find((n) => n.id === 'src/a')!;
  428. const b = wide.nodes.find((n) => n.id === 'src/b')!;
  429. expect(b.y - a.y).toBe(NODE_HEIGHT + 116);
  430. expect(wide.height).toBe(buildMapLayout(payload, OPTS).height + (116 - LAYER_GAP));
  431. // Layer 0 is the bottom, so it has the larger y.
  432. expect(wide.layers[0]!.y - wide.layers[1]!.y).toBe(NODE_HEIGHT + 116);
  433. });
  434. });