ui-flow-model.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. /**
  2. * The Flow strip's geometry (CG-50) — `ui/src/lib/flow-model.ts`.
  3. *
  4. * Pure functions, no browser: this is where the strip's two load-bearing claims
  5. * are checked. That a card's height is ARITHMETIC (the CSS pins the same
  6. * number, so an arrow lands where the layout said it would), and that a column
  7. * is a card's LONGEST distance from a start (so two routes that rejoin do so in
  8. * the same column, and nothing is ever drawn left of something that calls it).
  9. *
  10. * The endpoint that feeds it is tested against a real index in
  11. * `ui-flow-api.test.ts`.
  12. */
  13. import { describe, it, expect } from 'vitest';
  14. import {
  15. buildFlowLayout,
  16. cardHeight,
  17. dashFor,
  18. labelLinesFor,
  19. lineLabelFor,
  20. CARD_WIDTH,
  21. CODE_LINE_HEIGHT,
  22. CODE_PADDING,
  23. COLUMN_PITCH,
  24. HEADER_HEIGHT,
  25. LABEL_MAX_CHARS,
  26. LINK_WIDTH,
  27. NO_SOURCE_HEIGHT,
  28. PADDING,
  29. ROW_GAP,
  30. } from '../ui/src/lib/flow-model';
  31. import type { WireFlow, WireFlowEdge, WireFlowHop } from '../ui/src/lib/api';
  32. /* ------------------------------------------------------------- builders -- */
  33. function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
  34. return {
  35. kind: 'calls',
  36. label: 'calls',
  37. upward: false,
  38. uncertain: false,
  39. synthesized: false,
  40. ...over,
  41. };
  42. }
  43. function hop(name: string, opts: { lines?: number; edge?: WireFlowEdge | null } = {}): WireFlowHop {
  44. const lines = opts.lines ?? 7;
  45. return {
  46. node: {
  47. id: `method:${name}`,
  48. kind: 'method',
  49. name,
  50. qualifiedName: name,
  51. file: `src/${name}.ts`,
  52. line: 10,
  53. endLine: 40,
  54. language: 'typescript',
  55. test: false,
  56. },
  57. edge: opts.edge === undefined ? edge() : opts.edge,
  58. callRef: null,
  59. source:
  60. lines === 0
  61. ? null
  62. : {
  63. file: `src/${name}.ts`,
  64. language: 'typescript',
  65. from: 7,
  66. to: 6 + lines,
  67. lines: Array.from({ length: lines }, (_, i) => `line ${i}`),
  68. drift: false,
  69. },
  70. };
  71. }
  72. function flow(id: string, names: string[]): WireFlow {
  73. return {
  74. id,
  75. label: `${names[0]} → ${names[names.length - 1]}`,
  76. hops: names.map((name, i) => hop(name, { edge: i === 0 ? null : edge() })),
  77. };
  78. }
  79. /* ---------------------------------------------------------------- tests -- */
  80. describe('cardHeight', () => {
  81. it('is the header plus one row per source line', () => {
  82. expect(cardHeight(hop('a', { lines: 7 }))).toBe(HEADER_HEIGHT + 7 * CODE_LINE_HEIGHT + CODE_PADDING);
  83. expect(cardHeight(hop('a', { lines: 1 }))).toBe(HEADER_HEIGHT + CODE_LINE_HEIGHT + CODE_PADDING);
  84. });
  85. it('gives a card with no source the height of the sentence that replaces it', () => {
  86. expect(cardHeight(hop('a', { lines: 0 }))).toBe(HEADER_HEIGHT + NO_SOURCE_HEIGHT);
  87. });
  88. });
  89. describe('dashFor', () => {
  90. it('marks a synthesized hop `5 3` and an uncertain one `2 3`', () => {
  91. expect(dashFor(edge({ synthesized: true }))).toBe('5 3');
  92. expect(dashFor(edge({ uncertain: true }))).toBe('2 3');
  93. expect(dashFor(edge())).toBeNull();
  94. });
  95. it('lets the synthesized pattern win, because it is the stronger claim', () => {
  96. // A dynamic-dispatch bridge that also scored low confidence is still first
  97. // and foremost a bridge: "we inferred this hop" is what a reader has to see.
  98. expect(dashFor(edge({ synthesized: true, uncertain: true }))).toBe('5 3');
  99. });
  100. });
  101. describe('labelLinesFor', () => {
  102. it('leaves an ordinary call as one word', () => {
  103. expect(labelLinesFor(edge())).toEqual(['calls']);
  104. });
  105. it('stacks a synthesized label and shortens the wiring site to a basename', () => {
  106. expect(
  107. labelLinesFor(
  108. edge({ synthesized: true, label: 'via callback · registered at src/deep/nested/wire.ts:88' })
  109. )
  110. ).toEqual(['via callback', 'registered at wire.ts:88']);
  111. });
  112. it('cuts anything still too wide for an 86px connector', () => {
  113. const lines = labelLinesFor(edge({ label: 'via an extraordinarily long mechanism name' }));
  114. expect(lines).toHaveLength(1);
  115. expect(lines[0]!.length).toBe(LABEL_MAX_CHARS);
  116. expect(lines[0]!.endsWith('…')).toBe(true);
  117. });
  118. });
  119. describe('lineLabelFor', () => {
  120. it('prints the recorded line, and nothing when there is none', () => {
  121. expect(lineLabelFor(edge({ line: 2029 }))).toBe('line 2029');
  122. expect(lineLabelFor(edge())).toBeNull();
  123. expect(lineLabelFor(edge({ line: 0 }))).toBeNull();
  124. });
  125. });
  126. describe('buildFlowLayout — one path', () => {
  127. const single = flow('f1', ['a', 'b', 'c']);
  128. it('puts one card per column, left to right, at the spec pitch', () => {
  129. const layout = buildFlowLayout([single], 'f1');
  130. expect(layout.cards.map((c) => c.hop.node.name)).toEqual(['a', 'b', 'c']);
  131. expect(layout.cards.map((c) => c.column)).toEqual([0, 1, 2]);
  132. expect(layout.cards.map((c) => c.x)).toEqual([PADDING, PADDING + COLUMN_PITCH, PADDING + 2 * COLUMN_PITCH]);
  133. expect(COLUMN_PITCH).toBe(CARD_WIDTH + LINK_WIDTH);
  134. });
  135. it('places every card on one row and numbers its step on the active flow', () => {
  136. const layout = buildFlowLayout([single], 'f1');
  137. expect(new Set(layout.cards.map((c) => c.y)).size).toBe(1);
  138. expect(layout.cards.map((c) => c.step)).toEqual([0, 1, 2]);
  139. });
  140. it('links consecutive cards and nothing else', () => {
  141. const layout = buildFlowLayout([single], 'f1');
  142. expect(layout.links.map((l) => [l.source, l.target])).toEqual([
  143. ['method:a', 'method:b'],
  144. ['method:b', 'method:c'],
  145. ]);
  146. });
  147. it('sizes the canvas to the cards it drew', () => {
  148. const layout = buildFlowLayout([single], 'f1');
  149. expect(layout.columns).toBe(3);
  150. expect(layout.gaps).toEqual([LINK_WIDTH, LINK_WIDTH]);
  151. expect(layout.width).toBe(PADDING * 2 + 3 * CARD_WIDTH + 2 * LINK_WIDTH);
  152. expect(layout.height).toBe(PADDING * 2 + cardHeight(single.hops[0] as WireFlowHop));
  153. });
  154. it('widens the gap a long synthesized label has to fit into', () => {
  155. // 86px holds `calls`; it does not hold `registered at App.tsx:3764`, which
  156. // at a fixed pitch ran under the source of the card it was explaining.
  157. const wired: WireFlow = {
  158. id: 'f1',
  159. label: 'a → b',
  160. hops: [
  161. hop('a', { edge: null }),
  162. hop('b', {
  163. edge: edge({
  164. synthesized: true,
  165. line: 5337,
  166. label: 'via callback · onUpdate · registered at src/app/App.tsx:3764',
  167. }),
  168. }),
  169. ],
  170. };
  171. const layout = buildFlowLayout([wired], 'f1');
  172. expect(layout.gaps[0]).toBeGreaterThan(LINK_WIDTH);
  173. // Wide enough for the widest line it has to hold.
  174. const widest = Math.max(...(layout.links[0]?.labelLines ?? []).map((l) => l.length));
  175. expect(layout.gaps[0]).toBeGreaterThanOrEqual(widest * 6.65);
  176. // …and the second card starts past it, so nothing is drawn over the label.
  177. expect(layout.cards[1]?.x).toBe(PADDING + CARD_WIDTH + (layout.gaps[0] as number));
  178. });
  179. it('answers an empty picture for no flows at all', () => {
  180. expect(buildFlowLayout([], null)).toEqual({
  181. cards: [],
  182. links: [],
  183. width: 0,
  184. height: 0,
  185. columns: 0,
  186. gaps: [],
  187. });
  188. });
  189. });
  190. describe('buildFlowLayout — two paths that merge', () => {
  191. // a → b → d and a → c → d: the same start, the same end, different middles.
  192. const left = flow('f1', ['a', 'b', 'd']);
  193. const right = flow('f2', ['a', 'c', 'd']);
  194. it('draws one DAG, not two strips', () => {
  195. const layout = buildFlowLayout([left, right], 'f1');
  196. expect(layout.cards).toHaveLength(4);
  197. expect(layout.links).toHaveLength(4);
  198. expect(layout.columns).toBe(3);
  199. });
  200. it('rejoins the shared cards in one column and stacks the branch', () => {
  201. const layout = buildFlowLayout([left, right], 'f1');
  202. const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
  203. expect(at('a').column).toBe(0);
  204. expect(at('d').column).toBe(2);
  205. expect(at('b').column).toBe(1);
  206. expect(at('c').column).toBe(1);
  207. // Same column, different rows, exactly one gap apart.
  208. expect(at('c').y - at('b').y).toBe(at('b').height + ROW_GAP);
  209. });
  210. it('records which paths a shared card and a branch link belong to', () => {
  211. const layout = buildFlowLayout([left, right], 'f1');
  212. const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
  213. expect(at('a').flows).toEqual(['f1', 'f2']);
  214. expect(at('b').flows).toEqual(['f1']);
  215. expect(at('c').flows).toEqual(['f2']);
  216. expect(layout.links.find((l) => l.target === 'method:c')!.flows).toEqual(['f2']);
  217. });
  218. it('marks the picked path, and only the picked path, with a step', () => {
  219. const picked = buildFlowLayout([left, right], 'f2');
  220. const at = (name: string) => picked.cards.find((c) => c.hop.node.name === name)!;
  221. expect(at('c').step).toBe(1);
  222. expect(at('b').step).toBe(-1);
  223. // …and the picked path is the one drawn along the top of its columns.
  224. expect(at('c').y).toBeLessThan(at('b').y);
  225. });
  226. });
  227. describe('buildFlowLayout — awkward shapes', () => {
  228. it('never draws a card left of something that calls it, on a long merge', () => {
  229. // a → b → c → d and a → d: `d`'s column must come from the LONGEST route,
  230. // or the short path would drag it back on top of `b`.
  231. const long = flow('f1', ['a', 'b', 'c', 'd']);
  232. const short = flow('f2', ['a', 'd']);
  233. const layout = buildFlowLayout([long, short], 'f1');
  234. const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
  235. expect(at('d').column).toBe(3);
  236. for (const link of layout.links) {
  237. const from = layout.cards.find((c) => c.id === link.source)!;
  238. const to = layout.cards.find((c) => c.id === link.target)!;
  239. expect(to.column).toBeGreaterThan(from.column);
  240. }
  241. });
  242. it('still draws every card when a flow calls back into itself', () => {
  243. // a → b → a: a real shape (recursion through a helper) and one with no
  244. // topological order. Nothing may vanish.
  245. const cyclic: WireFlow = {
  246. id: 'f1',
  247. label: 'a → a',
  248. hops: [hop('a', { edge: null }), hop('b'), { ...hop('a'), edge: edge() }],
  249. };
  250. const layout = buildFlowLayout([cyclic], 'f1');
  251. expect(layout.cards.map((c) => c.hop.node.name).sort()).toEqual(['a', 'b']);
  252. expect(layout.links).toHaveLength(2);
  253. expect(layout.cards.every((c) => Number.isFinite(c.x) && Number.isFinite(c.y))).toBe(true);
  254. });
  255. it('centres a short column against a tall one', () => {
  256. const tall = flow('f1', ['a', 'b', 'd']);
  257. const alt = flow('f2', ['a', 'c', 'd']);
  258. const layout = buildFlowLayout([tall, alt], 'f1');
  259. const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
  260. const columnMiddle = (name: string) => at(name).y + at(name).height / 2;
  261. // `a` is alone in its column; `b`/`c` share the next one. Their midpoints line up.
  262. expect(columnMiddle('a')).toBeCloseTo((at('b').y + at('c').y + at('c').height) / 2, 5);
  263. });
  264. });