1
0

ui-flow-model.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  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. capId,
  31. endCapHeight,
  32. endCapText,
  33. END_CAP_DASH,
  34. END_CAP_WIDTH,
  35. } from '../ui/src/lib/flow-model';
  36. import type {
  37. WireFlow,
  38. WireFlowBoundary,
  39. WireFlowEdge,
  40. WireFlowHop,
  41. WireNodeRef,
  42. } from '../ui/src/lib/api';
  43. /* ------------------------------------------------------------- builders -- */
  44. function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
  45. return {
  46. kind: 'calls',
  47. label: 'calls',
  48. upward: false,
  49. uncertain: false,
  50. synthesized: false,
  51. ...over,
  52. };
  53. }
  54. function hop(name: string, opts: { lines?: number; edge?: WireFlowEdge | null } = {}): WireFlowHop {
  55. const lines = opts.lines ?? 7;
  56. return {
  57. node: {
  58. id: `method:${name}`,
  59. kind: 'method',
  60. name,
  61. qualifiedName: name,
  62. file: `src/${name}.ts`,
  63. line: 10,
  64. endLine: 40,
  65. language: 'typescript',
  66. test: false,
  67. },
  68. edge: opts.edge === undefined ? edge() : opts.edge,
  69. callRef: null,
  70. source:
  71. lines === 0
  72. ? null
  73. : {
  74. file: `src/${name}.ts`,
  75. language: 'typescript',
  76. from: 7,
  77. to: 6 + lines,
  78. lines: Array.from({ length: lines }, (_, i) => `line ${i}`),
  79. drift: false,
  80. },
  81. };
  82. }
  83. function flow(
  84. id: string,
  85. names: string[],
  86. extra: { boundary?: WireFlowBoundary | null; partial?: boolean } = {}
  87. ): WireFlow {
  88. return {
  89. id,
  90. label: `${names[0]} → ${names[names.length - 1]}`,
  91. hops: names.map((name, i) => hop(name, { edge: i === 0 ? null : edge() })),
  92. boundary: extra.boundary ?? null,
  93. partial: extra.partial === true,
  94. };
  95. }
  96. function ref(name: string): WireNodeRef {
  97. return {
  98. id: `method:${name}`,
  99. kind: 'method',
  100. name,
  101. qualifiedName: name,
  102. file: `src/${name}.ts`,
  103. line: 10,
  104. endLine: 40,
  105. language: 'typescript',
  106. test: false,
  107. };
  108. }
  109. function boundary(over: Partial<WireFlowBoundary> = {}): WireFlowBoundary {
  110. return {
  111. node: ref('routeAny'),
  112. sites: [
  113. {
  114. form: 'computed-call',
  115. label: 'computed member call',
  116. snippet: "return table[name](payload);",
  117. line: 61,
  118. key: 'save',
  119. keyIsType: false,
  120. moreSites: 0,
  121. candidates: [{ node: ref('onSave'), display: 'onSave', named: true }],
  122. candidateNote: null,
  123. },
  124. ],
  125. uncertain: { total: 0, shown: 0, truncated: false, items: [] },
  126. further: { total: 0, shown: 0, truncated: false, items: [] },
  127. missed: [ref('onSave')],
  128. ...over,
  129. };
  130. }
  131. /* ---------------------------------------------------------------- tests -- */
  132. describe('cardHeight', () => {
  133. it('is the header plus one row per source line', () => {
  134. expect(cardHeight(hop('a', { lines: 7 }))).toBe(HEADER_HEIGHT + 7 * CODE_LINE_HEIGHT + CODE_PADDING);
  135. expect(cardHeight(hop('a', { lines: 1 }))).toBe(HEADER_HEIGHT + CODE_LINE_HEIGHT + CODE_PADDING);
  136. });
  137. it('gives a card with no source the height of the sentence that replaces it', () => {
  138. expect(cardHeight(hop('a', { lines: 0 }))).toBe(HEADER_HEIGHT + NO_SOURCE_HEIGHT);
  139. });
  140. });
  141. describe('dashFor', () => {
  142. it('marks a synthesized hop `5 3` and an uncertain one `2 3`', () => {
  143. expect(dashFor(edge({ synthesized: true }))).toBe('5 3');
  144. expect(dashFor(edge({ uncertain: true }))).toBe('2 3');
  145. expect(dashFor(edge())).toBeNull();
  146. });
  147. it('lets the synthesized pattern win, because it is the stronger claim', () => {
  148. // A dynamic-dispatch bridge that also scored low confidence is still first
  149. // and foremost a bridge: "we inferred this hop" is what a reader has to see.
  150. expect(dashFor(edge({ synthesized: true, uncertain: true }))).toBe('5 3');
  151. });
  152. });
  153. describe('labelLinesFor', () => {
  154. it('leaves an ordinary call as one word', () => {
  155. expect(labelLinesFor(edge())).toEqual(['calls']);
  156. });
  157. it('stacks a synthesized label and shortens the wiring site to a basename', () => {
  158. expect(
  159. labelLinesFor(
  160. edge({ synthesized: true, label: 'via callback · registered at src/deep/nested/wire.ts:88' })
  161. )
  162. ).toEqual(['via callback', 'registered at wire.ts:88']);
  163. });
  164. it('cuts anything still too wide for an 86px connector', () => {
  165. const lines = labelLinesFor(edge({ label: 'via an extraordinarily long mechanism name' }));
  166. expect(lines).toHaveLength(1);
  167. expect(lines[0]!.length).toBe(LABEL_MAX_CHARS);
  168. expect(lines[0]!.endsWith('…')).toBe(true);
  169. });
  170. });
  171. describe('lineLabelFor', () => {
  172. it('prints the recorded line, and nothing when there is none', () => {
  173. expect(lineLabelFor(edge({ line: 2029 }))).toBe('line 2029');
  174. expect(lineLabelFor(edge())).toBeNull();
  175. expect(lineLabelFor(edge({ line: 0 }))).toBeNull();
  176. });
  177. });
  178. describe('buildFlowLayout — one path', () => {
  179. const single = flow('f1', ['a', 'b', 'c']);
  180. it('puts one card per column, left to right, at the spec pitch', () => {
  181. const layout = buildFlowLayout([single], 'f1');
  182. expect(layout.cards.map((c) => c.hop.node.name)).toEqual(['a', 'b', 'c']);
  183. expect(layout.cards.map((c) => c.column)).toEqual([0, 1, 2]);
  184. expect(layout.cards.map((c) => c.x)).toEqual([PADDING, PADDING + COLUMN_PITCH, PADDING + 2 * COLUMN_PITCH]);
  185. expect(COLUMN_PITCH).toBe(CARD_WIDTH + LINK_WIDTH);
  186. });
  187. it('places every card on one row and numbers its step on the active flow', () => {
  188. const layout = buildFlowLayout([single], 'f1');
  189. expect(new Set(layout.cards.map((c) => c.y)).size).toBe(1);
  190. expect(layout.cards.map((c) => c.step)).toEqual([0, 1, 2]);
  191. });
  192. it('links consecutive cards and nothing else', () => {
  193. const layout = buildFlowLayout([single], 'f1');
  194. expect(layout.links.map((l) => [l.source, l.target])).toEqual([
  195. ['method:a', 'method:b'],
  196. ['method:b', 'method:c'],
  197. ]);
  198. });
  199. it('sizes the canvas to the cards it drew', () => {
  200. const layout = buildFlowLayout([single], 'f1');
  201. expect(layout.columns).toBe(3);
  202. expect(layout.gaps).toEqual([LINK_WIDTH, LINK_WIDTH]);
  203. expect(layout.width).toBe(PADDING * 2 + 3 * CARD_WIDTH + 2 * LINK_WIDTH);
  204. expect(layout.height).toBe(PADDING * 2 + cardHeight(single.hops[0] as WireFlowHop));
  205. });
  206. it('widens the gap a long synthesized label has to fit into', () => {
  207. // 86px holds `calls`; it does not hold `registered at App.tsx:3764`, which
  208. // at a fixed pitch ran under the source of the card it was explaining.
  209. const wired: WireFlow = {
  210. id: 'f1',
  211. label: 'a → b',
  212. hops: [
  213. hop('a', { edge: null }),
  214. hop('b', {
  215. edge: edge({
  216. synthesized: true,
  217. line: 5337,
  218. label: 'via callback · onUpdate · registered at src/app/App.tsx:3764',
  219. }),
  220. }),
  221. ],
  222. };
  223. const layout = buildFlowLayout([wired], 'f1');
  224. expect(layout.gaps[0]).toBeGreaterThan(LINK_WIDTH);
  225. // Wide enough for the widest line it has to hold.
  226. const widest = Math.max(...(layout.links[0]?.labelLines ?? []).map((l) => l.length));
  227. expect(layout.gaps[0]).toBeGreaterThanOrEqual(widest * 6.65);
  228. // …and the second card starts past it, so nothing is drawn over the label.
  229. expect(layout.cards[1]?.x).toBe(PADDING + CARD_WIDTH + (layout.gaps[0] as number));
  230. });
  231. it('answers an empty picture for no flows at all', () => {
  232. expect(buildFlowLayout([], null)).toEqual({
  233. cards: [],
  234. endCaps: [],
  235. links: [],
  236. width: 0,
  237. height: 0,
  238. columns: 0,
  239. gaps: [],
  240. });
  241. });
  242. });
  243. describe('buildFlowLayout — two paths that merge', () => {
  244. // a → b → d and a → c → d: the same start, the same end, different middles.
  245. const left = flow('f1', ['a', 'b', 'd']);
  246. const right = flow('f2', ['a', 'c', 'd']);
  247. it('draws one DAG, not two strips', () => {
  248. const layout = buildFlowLayout([left, right], 'f1');
  249. expect(layout.cards).toHaveLength(4);
  250. expect(layout.links).toHaveLength(4);
  251. expect(layout.columns).toBe(3);
  252. });
  253. it('rejoins the shared cards in one column and stacks the branch', () => {
  254. const layout = buildFlowLayout([left, right], 'f1');
  255. const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
  256. expect(at('a').column).toBe(0);
  257. expect(at('d').column).toBe(2);
  258. expect(at('b').column).toBe(1);
  259. expect(at('c').column).toBe(1);
  260. // Same column, different rows, exactly one gap apart.
  261. expect(at('c').y - at('b').y).toBe(at('b').height + ROW_GAP);
  262. });
  263. it('records which paths a shared card and a branch link belong to', () => {
  264. const layout = buildFlowLayout([left, right], 'f1');
  265. const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
  266. expect(at('a').flows).toEqual(['f1', 'f2']);
  267. expect(at('b').flows).toEqual(['f1']);
  268. expect(at('c').flows).toEqual(['f2']);
  269. expect(layout.links.find((l) => l.target === 'method:c')!.flows).toEqual(['f2']);
  270. });
  271. it('marks the picked path, and only the picked path, with a step', () => {
  272. const picked = buildFlowLayout([left, right], 'f2');
  273. const at = (name: string) => picked.cards.find((c) => c.hop.node.name === name)!;
  274. expect(at('c').step).toBe(1);
  275. expect(at('b').step).toBe(-1);
  276. // …and the picked path is the one drawn along the top of its columns.
  277. expect(at('c').y).toBeLessThan(at('b').y);
  278. });
  279. });
  280. describe('endCapText', () => {
  281. it('names the form, keeps the key and counts the candidates', () => {
  282. const text = endCapText(boundary());
  283. expect(text.intro).toContain('routeAny');
  284. expect(text.sites[0].headline).toBe('computed member call at line 61');
  285. expect(text.sites[0].key).toBe('save');
  286. expect(text.sites[0].candidateHeading).toBe('1 candidate target \u203a');
  287. expect(text.quiet).toBeNull();
  288. expect(text.missed).toContain('onSave');
  289. });
  290. it('says the key is a runtime value rather than leaving the line blank', () => {
  291. const b = boundary();
  292. b.sites[0]!.key = null;
  293. b.sites[0]!.candidates = [];
  294. const text = endCapText(b);
  295. expect(text.sites[0].key).toBeNull();
  296. expect(text.sites[0].notes).toContain('the key is a runtime value');
  297. expect(text.sites[0].candidateHeading).toBeNull();
  298. });
  299. it('admits when the detector found nothing rather than implying a cause', () => {
  300. const text = endCapText(boundary({ sites: [] }));
  301. expect(text.quiet).toMatch(/No dynamic-dispatch site/);
  302. expect(text.sites).toEqual([]);
  303. });
  304. it('leads with the unfollowed name-only matches and their confidence', () => {
  305. const text = endCapText(
  306. boundary({
  307. uncertain: {
  308. total: 3,
  309. shown: 2,
  310. truncated: true,
  311. items: [
  312. { node: ref('save'), line: 61, confidence: 0.4 },
  313. { node: ref('store'), line: 62, confidence: 0.35 },
  314. ],
  315. },
  316. })
  317. );
  318. // The count is the TRUE total, not the length of the visible list.
  319. expect(text.uncertainHeading).toBe('3 name-only matches not followed (confidence < 0.6)');
  320. expect(text.uncertain).toHaveLength(2);
  321. });
  322. it('counts further resolved calls in the plural the number actually needs', () => {
  323. const one = endCapText(
  324. boundary({ further: { total: 1, shown: 1, truncated: false, items: [] } })
  325. );
  326. expect(one.further).toContain('1 further resolved call ');
  327. const many = endCapText(
  328. boundary({ further: { total: 4, shown: 0, truncated: true, items: [] } })
  329. );
  330. expect(many.further).toContain('4 further resolved calls ');
  331. });
  332. });
  333. describe('endCapHeight', () => {
  334. it('grows with what the cap has to say', () => {
  335. const bare = endCapHeight(boundary({ sites: [], missed: [] }));
  336. const full = endCapHeight(
  337. boundary({
  338. uncertain: {
  339. total: 2,
  340. shown: 2,
  341. truncated: false,
  342. items: [
  343. { node: ref('save'), line: 61, confidence: 0.4 },
  344. { node: ref('store'), line: 62, confidence: 0.3 },
  345. ],
  346. },
  347. further: { total: 5, shown: 0, truncated: true, items: [] },
  348. })
  349. );
  350. expect(full).toBeGreaterThan(bare);
  351. });
  352. it('is a whole number, because it is a pixel', () => {
  353. expect(Number.isInteger(endCapHeight(boundary()))).toBe(true);
  354. });
  355. });
  356. describe('buildFlowLayout — the end cap', () => {
  357. it('places the cap one column past the symbol the path stopped at', () => {
  358. const f = flow('f1', ['alpha', 'routeAny'], { boundary: boundary() });
  359. const layout = buildFlowLayout([f], 'f1');
  360. expect(layout.endCaps).toHaveLength(1);
  361. const cap = layout.endCaps[0]!;
  362. expect(cap.id).toBe(capId('method:routeAny'));
  363. expect(cap.anchorId).toBe('method:routeAny');
  364. expect(cap.column).toBe(1 + 1);
  365. expect(cap.width).toBe(END_CAP_WIDTH);
  366. expect(layout.columns).toBe(3);
  367. // The card the cap hangs off is tinted at the dispatch line.
  368. expect(layout.cards.find((c) => c.id === 'method:routeAny')!.stopLine).toBe(61);
  369. expect(layout.cards.find((c) => c.id === 'method:alpha')!.stopLine).toBeNull();
  370. });
  371. it('joins it with a dotted link that carries no arrow and no edge', () => {
  372. const layout = buildFlowLayout([flow('f1', ['alpha', 'routeAny'], { boundary: boundary() })], 'f1');
  373. const link = layout.links.find((l) => l.cap);
  374. expect(link).toBeDefined();
  375. expect(link!.edge).toBeNull();
  376. expect(link!.dash).toBe(END_CAP_DASH);
  377. expect(link!.label).toBe('end of static path');
  378. expect(link!.labelLines.join(' ')).toBe('end of static path');
  379. expect(link!.lineLabel).toBeNull();
  380. });
  381. it('draws no cap for a flow that reached what it was asked for', () => {
  382. const layout = buildFlowLayout([flow('f1', ['alpha', 'beta'])], 'f1');
  383. expect(layout.endCaps).toEqual([]);
  384. expect(layout.links.every((l) => !l.cap)).toBe(true);
  385. });
  386. it('draws ONE cap when two paths run out at the same symbol', () => {
  387. const a = flow('a', ['alpha', 'routeAny'], { boundary: boundary() });
  388. const b = flow('b', ['gamma', 'routeAny'], { boundary: boundary() });
  389. const layout = buildFlowLayout([a, b], 'a');
  390. expect(layout.endCaps).toHaveLength(1);
  391. expect(layout.endCaps[0]!.flows.sort()).toEqual(['a', 'b']);
  392. });
  393. it('leaves room for a cap wider or narrower than a card', () => {
  394. const layout = buildFlowLayout([flow('f1', ['alpha', 'routeAny'], { boundary: boundary() })], 'f1');
  395. const cap = layout.endCaps[0]!;
  396. // The canvas is wide enough to hold the cap, not just the cards.
  397. expect(layout.width).toBe(cap.x + cap.width + PADDING);
  398. // And the cap starts one gap past the card it hangs off.
  399. const anchor = layout.cards.find((c) => c.id === 'method:routeAny')!;
  400. expect(cap.x).toBe(anchor.x + CARD_WIDTH + LINK_WIDTH);
  401. });
  402. it('ignores a boundary whose symbol is not on screen', () => {
  403. const orphan = boundary({ node: ref('nowhere') });
  404. const layout = buildFlowLayout([flow('f1', ['alpha', 'beta'], { boundary: orphan })], 'f1');
  405. expect(layout.endCaps).toEqual([]);
  406. });
  407. });
  408. describe('buildFlowLayout — awkward shapes', () => {
  409. it('never draws a card left of something that calls it, on a long merge', () => {
  410. // a → b → c → d and a → d: `d`'s column must come from the LONGEST route,
  411. // or the short path would drag it back on top of `b`.
  412. const long = flow('f1', ['a', 'b', 'c', 'd']);
  413. const short = flow('f2', ['a', 'd']);
  414. const layout = buildFlowLayout([long, short], 'f1');
  415. const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
  416. expect(at('d').column).toBe(3);
  417. for (const link of layout.links) {
  418. const from = layout.cards.find((c) => c.id === link.source)!;
  419. const to = layout.cards.find((c) => c.id === link.target)!;
  420. expect(to.column).toBeGreaterThan(from.column);
  421. }
  422. });
  423. it('still draws every card when a flow calls back into itself', () => {
  424. // a → b → a: a real shape (recursion through a helper) and one with no
  425. // topological order. Nothing may vanish.
  426. const cyclic: WireFlow = {
  427. id: 'f1',
  428. label: 'a → a',
  429. hops: [hop('a', { edge: null }), hop('b'), { ...hop('a'), edge: edge() }],
  430. };
  431. const layout = buildFlowLayout([cyclic], 'f1');
  432. expect(layout.cards.map((c) => c.hop.node.name).sort()).toEqual(['a', 'b']);
  433. expect(layout.links).toHaveLength(2);
  434. expect(layout.cards.every((c) => Number.isFinite(c.x) && Number.isFinite(c.y))).toBe(true);
  435. });
  436. it('centres a short column against a tall one', () => {
  437. const tall = flow('f1', ['a', 'b', 'd']);
  438. const alt = flow('f2', ['a', 'c', 'd']);
  439. const layout = buildFlowLayout([tall, alt], 'f1');
  440. const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
  441. const columnMiddle = (name: string) => at(name).y + at(name).height / 2;
  442. // `a` is alone in its column; `b`/`c` share the next one. Their midpoints line up.
  443. expect(columnMiddle('a')).toBeCloseTo((at('b').y + at('c').y + at('c').height) / 2, 5);
  444. });
  445. });