ui-filecode-model.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. /**
  2. * The whole-file view's geometry (CG-52), tested without a browser.
  3. *
  4. * Everything on that screen — where a line sits, which lines are rendered,
  5. * which page has to be fetched, where a rail row lands, what an arc's path is —
  6. * is arithmetic over line numbers, and that is deliberate: measuring six
  7. * thousand laid-out lines is neither 60 fps nor possible. So the arithmetic is
  8. * the thing worth pinning, and it can be pinned here.
  9. *
  10. * The API side is `ui-filecode-api.test.ts`.
  11. */
  12. import { describe, it, expect } from 'vitest';
  13. import {
  14. ARC_COLUMN,
  15. ARC_CROWD_LIMIT,
  16. CODE_LINE_HEIGHT,
  17. CODE_TOP_PAD,
  18. PAGE_LEAD_IN,
  19. PAGE_LINES,
  20. ROW_HEIGHT,
  21. arcPath,
  22. arcSummary,
  23. arcsInRange,
  24. buildFileArcs,
  25. buildFileCallRows,
  26. buildFileRefs,
  27. documentHeight,
  28. lineAtOffset,
  29. lineCentre,
  30. lineTop,
  31. ownerAt,
  32. pageFor,
  33. pageOf,
  34. pagesForRange,
  35. railHeight,
  36. rowsInRange,
  37. visibleArcs,
  38. visibleLines,
  39. } from '../ui/src/lib/filecode-model';
  40. import type {
  41. WireFileCall,
  42. WireFileCodePayload,
  43. WireNodeRef,
  44. WireOutlineEntry,
  45. WireRelation,
  46. } from '../ui/src/lib/api';
  47. /* ------------------------------------------------------------- fixtures -- */
  48. function node(over: Partial<WireNodeRef> & { id: string; name: string }): WireNodeRef {
  49. return {
  50. kind: 'function',
  51. qualifiedName: over.name,
  52. file: 'src/a.ts',
  53. line: 1,
  54. endLine: 1,
  55. language: 'typescript',
  56. test: false,
  57. ...over,
  58. } as WireNodeRef;
  59. }
  60. function relation(target: WireNodeRef, lines: number[], over: Partial<WireRelation> = {}): WireRelation {
  61. return {
  62. node: target,
  63. edgeKinds: ['calls'],
  64. edges: lines.map((line) => ({ kind: 'calls', line, col: 4 })),
  65. edgeCount: lines.length,
  66. lines,
  67. confidence: null,
  68. uncertain: false,
  69. synthesized: false,
  70. ...over,
  71. } as WireRelation;
  72. }
  73. function call(ownerId: string, ownerLine: number, rel: WireRelation): WireFileCall {
  74. return { ownerId, ownerLine, relation: rel };
  75. }
  76. function entry(over: Partial<WireOutlineEntry> & { id: string; name: string }): WireOutlineEntry {
  77. return {
  78. kind: 'function',
  79. qualifiedName: over.name,
  80. file: 'src/a.ts',
  81. line: 1,
  82. endLine: 1,
  83. language: 'typescript',
  84. test: false,
  85. parentId: null,
  86. depth: 0,
  87. fanIn: 0,
  88. fanOut: 0,
  89. ...over,
  90. } as WireOutlineEntry;
  91. }
  92. function payloadWith(calls: WireFileCall[], outline: WireOutlineEntry[] = []): WireFileCodePayload {
  93. return {
  94. file: {
  95. path: 'src/a.ts',
  96. language: 'typescript',
  97. size: 100,
  98. indexedAt: 0,
  99. contentHash: 'h',
  100. generated: false,
  101. test: false,
  102. errors: [],
  103. id: 'file:src/a.ts',
  104. totalLines: 500,
  105. },
  106. drift: false,
  107. outline: { total: outline.length, shown: outline.length, truncated: false, items: outline },
  108. calls: { total: calls.length, shown: calls.length, truncated: false, items: calls },
  109. outside: { total: 0, shown: 0, truncated: false, items: [] },
  110. intraFileCalls: 0,
  111. timing: { elapsedMs: 0 },
  112. };
  113. }
  114. /* ---------------------------------------------------------------- pixels -- */
  115. describe('line arithmetic', () => {
  116. it('places line 1 at the top pad and every line a fixed step below', () => {
  117. expect(lineTop(1)).toBe(CODE_TOP_PAD);
  118. expect(lineTop(2)).toBe(CODE_TOP_PAD + CODE_LINE_HEIGHT);
  119. expect(lineCentre(1)).toBe(CODE_TOP_PAD + CODE_LINE_HEIGHT / 2);
  120. });
  121. it('round-trips an offset back to its line', () => {
  122. for (const line of [1, 2, 17, 400, 6820]) {
  123. expect(lineAtOffset(lineTop(line), 6820)).toBe(line);
  124. expect(lineAtOffset(lineCentre(line), 6820)).toBe(line);
  125. }
  126. // The pads above and below read as the line they are adjacent to.
  127. expect(lineAtOffset(0, 100)).toBe(1);
  128. expect(lineAtOffset(999_999, 100)).toBe(100);
  129. });
  130. it('sizes the document from the line count alone', () => {
  131. expect(documentHeight(6820)).toBe(CODE_TOP_PAD + 6820 * CODE_LINE_HEIGHT + 120);
  132. expect(documentHeight(0)).toBe(CODE_TOP_PAD + 120);
  133. });
  134. });
  135. describe('visibleLines', () => {
  136. it('renders a viewport plus overscan, never the whole file', () => {
  137. const { first, last } = visibleLines(60_000, 900, 6820);
  138. expect(first).toBeLessThan(lineAtOffset(60_000, 6820));
  139. expect(last - first).toBeLessThan(150);
  140. // The viewport itself is covered.
  141. expect(first).toBeLessThanOrEqual(lineAtOffset(60_000, 6820));
  142. expect(last).toBeGreaterThanOrEqual(lineAtOffset(60_900, 6820));
  143. });
  144. it('clamps at both ends', () => {
  145. expect(visibleLines(0, 900, 6820).first).toBe(1);
  146. expect(visibleLines(10_000_000, 900, 6820).last).toBe(6820);
  147. expect(visibleLines(0, 900, 0)).toEqual({ first: 1, last: 0 });
  148. });
  149. });
  150. describe('paging', () => {
  151. it('asks for a lead-in it then throws away', () => {
  152. const page = pageFor(3, 6820);
  153. expect(page.from).toBe(3 * PAGE_LINES + 1);
  154. expect(page.to).toBe(4 * PAGE_LINES);
  155. expect(page.requestFrom).toBe(page.from - PAGE_LEAD_IN);
  156. });
  157. it('never reaches before line 1, and never past the end', () => {
  158. expect(pageFor(0, 6820).requestFrom).toBe(1);
  159. expect(pageFor(8, 6820).to).toBe(6820);
  160. });
  161. it('stays inside the source endpoint\'s per-request line cap', () => {
  162. // MAX_SOURCE_LINES is 4000; a page plus its lead-in must fit, or the last
  163. // lines of a page would silently arrive truncated.
  164. const page = pageFor(5, 100_000);
  165. expect(page.to - page.requestFrom + 1).toBeLessThanOrEqual(4000);
  166. });
  167. it('names every page a rendered range touches', () => {
  168. expect(pagesForRange(1, 40, 6820)).toEqual([0]);
  169. expect(pagesForRange(PAGE_LINES - 2, PAGE_LINES + 2, 6820)).toEqual([0, 1]);
  170. expect(pagesForRange(1, 0, 0)).toEqual([]);
  171. expect(pageOf(1)).toBe(0);
  172. expect(pageOf(PAGE_LINES)).toBe(0);
  173. expect(pageOf(PAGE_LINES + 1)).toBe(1);
  174. });
  175. });
  176. /* ------------------------------------------------------------- ownership -- */
  177. describe('ownerAt', () => {
  178. const outline = [
  179. entry({ id: 'class', name: 'Service', kind: 'class', line: 10, endLine: 90 }),
  180. entry({ id: 'm1', name: 'run', kind: 'method', line: 20, endLine: 40, depth: 1 }),
  181. entry({ id: 'm2', name: 'stop', kind: 'method', line: 50, endLine: 60, depth: 1 }),
  182. ];
  183. it('answers with the DEEPEST symbol holding the line', () => {
  184. // Not the class: it holds every line equally, so hovering anywhere inside
  185. // it would light every arc in it.
  186. expect(ownerAt(outline, 25)).toBe('m1');
  187. expect(ownerAt(outline, 55)).toBe('m2');
  188. expect(ownerAt(outline, 45)).toBe('class');
  189. });
  190. it('answers null outside every symbol', () => {
  191. expect(ownerAt(outline, 5)).toBeNull();
  192. expect(ownerAt(outline, 200)).toBeNull();
  193. });
  194. });
  195. /* ---------------------------------------------------------------- ports -- */
  196. describe('buildFileRefs', () => {
  197. it('marks every recorded call site with its column', () => {
  198. const target = node({ id: 't', name: 'format', line: 3 });
  199. const refs = buildFileRefs(payloadWith([call('o', 1, relation(target, [8, 9]))]));
  200. expect([...refs.keys()].sort((a, b) => a - b)).toEqual([8, 9]);
  201. expect(refs.get(8)![0]).toMatchObject({ ident: 'format', col: 4, targetId: 't', outside: false });
  202. });
  203. it('still marks a call site the capped edge list left out', () => {
  204. // A relation caps its EDGES but never its `lines`; without the fallback the
  205. // overflow call sites would silently lose their ports.
  206. const target = node({ id: 't', name: 'format', line: 3 });
  207. const rel = relation(target, [8, 9, 10]);
  208. rel.edges = rel.edges.slice(0, 1);
  209. const refs = buildFileRefs(payloadWith([call('o', 1, rel)]));
  210. expect(refs.get(10)).toHaveLength(1);
  211. expect(refs.get(10)![0]!.col).toBeNull();
  212. });
  213. it('carries unresolved references, which have no destination', () => {
  214. const payload = payloadWith([]);
  215. payload.outside = {
  216. total: 1,
  217. shown: 1,
  218. truncated: false,
  219. items: [{ line: 12, col: 6, name: 'log', kind: 'calls' }],
  220. };
  221. const ref = buildFileRefs(payload).get(12)![0]!;
  222. expect(ref).toMatchObject({ ident: 'log', targetId: null, outside: true });
  223. });
  224. });
  225. /* ----------------------------------------------------------------- rail -- */
  226. describe('buildFileCallRows', () => {
  227. it('puts a row at the centre of its first call site', () => {
  228. const rows = buildFileCallRows(
  229. payloadWith([call('o', 1, relation(node({ id: 't', name: 'format' }), [100]))])
  230. );
  231. expect(rows[0]!.top).toBe(lineCentre(100) - ROW_HEIGHT / 2);
  232. });
  233. it('pushes rows apart rather than letting them overlap, keeping source order', () => {
  234. const rows = buildFileCallRows(
  235. payloadWith([
  236. call('o', 1, relation(node({ id: 'a', name: 'a' }), [10])),
  237. call('o', 1, relation(node({ id: 'b', name: 'b' }), [11])),
  238. call('o', 1, relation(node({ id: 'c', name: 'c' }), [12])),
  239. ])
  240. );
  241. expect(rows.map((r) => r.call.relation.node.name)).toEqual(['a', 'b', 'c']);
  242. for (let i = 1; i < rows.length; i++) {
  243. expect(rows[i]!.top - rows[i - 1]!.top).toBeGreaterThanOrEqual(ROW_HEIGHT);
  244. }
  245. // The first one still gets exactly the place it wanted.
  246. expect(rows[0]!.top).toBe(lineCentre(10) - ROW_HEIGHT / 2);
  247. });
  248. it('keys a row by the PAIR, so one callee from two callers is two rows', () => {
  249. const target = node({ id: 't', name: 'format' });
  250. const rows = buildFileCallRows(
  251. payloadWith([
  252. call('render', 5, relation(target, [8])),
  253. call('summarise', 20, relation(target, [22])),
  254. ])
  255. );
  256. expect(rows).toHaveLength(2);
  257. expect(new Set(rows.map((r) => r.key)).size).toBe(2);
  258. });
  259. it('sends a row with no recorded call site to the end, where a cap trims it', () => {
  260. const rows = buildFileCallRows(
  261. payloadWith([
  262. call('o', 1, relation(node({ id: 'nolines', name: 'z' }), [])),
  263. call('o', 1, relation(node({ id: 'lined', name: 'a' }), [400])),
  264. ])
  265. );
  266. expect(rows.map((r) => r.call.relation.node.id)).toEqual(['lined', 'nolines']);
  267. });
  268. it('windows by pixel range and reports the height it needs', () => {
  269. const rows = buildFileCallRows(
  270. payloadWith(
  271. [10, 200, 4000].map((line, i) =>
  272. call('o', 1, relation(node({ id: `t${i}`, name: `t${i}` }), [line]))
  273. )
  274. )
  275. );
  276. expect(rowsInRange(rows, 0, 600).map((r) => r.call.relation.node.id)).toEqual(['t0']);
  277. expect(rowsInRange(rows, 3900, 4100).map((r) => r.call.relation.node.id)).toEqual(['t1']);
  278. // A stretch of file with no calls in it draws no rows at all.
  279. expect(rowsInRange(rows, 5000, 10_000)).toEqual([]);
  280. expect(railHeight(rows)).toBeGreaterThan(lineCentre(4000));
  281. expect(railHeight([])).toBe(0);
  282. });
  283. });
  284. /* ----------------------------------------------------------------- arcs -- */
  285. describe('buildFileArcs', () => {
  286. const local = (id: string, name: string, line: number): WireNodeRef =>
  287. node({ id, name, line, endLine: line + 5, file: 'src/a.ts' });
  288. it('draws one arc per call site whose callee is defined in the same file', () => {
  289. const payload = payloadWith([
  290. call('r', 30, relation(local('fmt', 'format', 3), [31, 32])),
  291. call('r', 30, relation(node({ id: 'far', name: 'widen', file: 'src/b.ts', line: 1 }), [33])),
  292. ]);
  293. const arcs = buildFileArcs(payload, buildFileCallRows(payload));
  294. expect(arcs).toHaveLength(2);
  295. expect(arcs.map((a) => a.fromLine).sort()).toEqual([31, 32]);
  296. expect(arcs.every((a) => a.toLine === 3)).toBe(true);
  297. });
  298. it('skips a call sitting on its own callee\'s definition line', () => {
  299. const payload = payloadWith([call('r', 10, relation(local('r', 'recurse', 10), [10, 14]))]);
  300. const arcs = buildFileArcs(payload, buildFileCallRows(payload));
  301. expect(arcs.map((a) => a.fromLine)).toEqual([14]);
  302. });
  303. it('sits short arcs innermost, by their own span rather than by rank', () => {
  304. const payload = payloadWith([
  305. call('r', 100, relation(local('near', 'near', 98), [100])),
  306. call('r', 100, relation(local('far', 'far', 2), [101])),
  307. ]);
  308. const arcs = buildFileArcs(payload, buildFileCallRows(payload));
  309. const depth = (key: string): number =>
  310. Number(/A([\d.]+),/.exec(arcs.find((a) => a.targetId === key)!.d)![1]);
  311. expect(depth('near')).toBeLessThan(depth('far'));
  312. expect(depth('near')).toBeGreaterThan(0);
  313. expect(depth('far')).toBeLessThanOrEqual(ARC_COLUMN);
  314. // Filtering to one symbol must not move the survivors sideways, which is
  315. // exactly what a rank-based depth would do.
  316. const filtered = buildFileArcs(
  317. payloadWith([call('r', 100, relation(local('near', 'near', 98), [100]))]),
  318. buildFileCallRows(payloadWith([call('r', 100, relation(local('near', 'near', 98), [100]))]))
  319. );
  320. expect(Number(/A([\d.]+),/.exec(filtered[0]!.d)![1])).toBeGreaterThan(0);
  321. });
  322. it('bulges LEFT in both directions', () => {
  323. // Both ends sit on the column's right edge; the sweep flag is what keeps a
  324. // downward arc and an upward one on the same side of the gutter.
  325. expect(arcPath(10, 40, 30)).toMatch(/^M56,\d+(\.\d+)? A30\.0,\d+(\.\d+)? 0 0 0 56,/);
  326. expect(arcPath(40, 10, 30)).toMatch(/ 0 0 1 56,/);
  327. });
  328. });
  329. describe('visibleArcs', () => {
  330. const arcs = [
  331. { key: 'a', ownerId: 'x', targetId: 'y', minLine: 1, maxLine: 10 },
  332. { key: 'b', ownerId: 'z', targetId: 'w', minLine: 50, maxLine: 60 },
  333. ] as any[];
  334. it('shows everything while there are few enough to read', () => {
  335. expect(visibleArcs(arcs, null, false)).toHaveLength(2);
  336. });
  337. it('shows only the focused symbol\'s once the file is crowded — both directions', () => {
  338. expect(visibleArcs(arcs, 'x', true).map((a) => a.key)).toEqual(['a']);
  339. // A reader hovering a symbol is asking about its neighbourhood, so the
  340. // calls INTO it count too.
  341. expect(visibleArcs(arcs, 'y', true).map((a) => a.key)).toEqual(['a']);
  342. expect(visibleArcs(arcs, null, true)).toEqual([]);
  343. });
  344. it('windows by line range', () => {
  345. expect(arcsInRange(arcs, 1, 20).map((a) => a.key)).toEqual(['a']);
  346. expect(arcsInRange(arcs, 5, 55).map((a) => a.key)).toEqual(['a', 'b']);
  347. expect(arcsInRange(arcs, 20, 40)).toEqual([]);
  348. });
  349. it('the crowd limit is the spec\'s', () => {
  350. expect(ARC_CROWD_LIMIT).toBe(40);
  351. });
  352. });
  353. describe('arcSummary', () => {
  354. it('says nothing rather than "0 calls"', () => {
  355. expect(arcSummary(0)).toMatch(/No calls/);
  356. expect(arcSummary(1)).toBe('1 call stays within this file');
  357. expect(arcSummary(209)).toBe('209 calls stay within this file');
  358. });
  359. });