ui-map-model.test.ts 14 KB

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