ui-symbol-model.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. /**
  2. * The Symbol view's decisions, without a browser (CG-44).
  3. *
  4. * Everything the screen does that could be wrong rather than merely ugly lives
  5. * in `ui/src/lib/` as plain functions over the `/api/node` payload: which lines
  6. * survive into a windowed body, which identifier a call-site link lands on,
  7. * which callers fold away, which reference is a guess. Those are the parts
  8. * worth pinning — the geometry that needs a real layout (row placement,
  9. * connector paths) is verified against a running viewer instead.
  10. */
  11. import { describe, it, expect } from 'vitest';
  12. import {
  13. assignRefs,
  14. buildCalleeRail,
  15. buildCallerRail,
  16. buildCodeBlock,
  17. buildOutline,
  18. edgeWord,
  19. graphCallLines,
  20. kindPhrase,
  21. refsByLine,
  22. showsBody,
  23. synthesizedBy,
  24. FULL_BODY_LINES,
  25. HEAD_LINES,
  26. type LineRef,
  27. } from '../ui/src/lib/symbol-model';
  28. import { newLexState, tokenize } from '../ui/src/lib/highlight';
  29. import type { WireRelation, WireSymbolPayload } from '../ui/src/lib/api';
  30. /* ------------------------------------------------------------- fixtures -- */
  31. function nodeRef(over: Partial<WireSymbolPayload['node']> = {}): any {
  32. return {
  33. id: 'method:a',
  34. kind: 'method',
  35. name: 'load',
  36. qualifiedName: 'Service::load',
  37. file: 'src/service.ts',
  38. line: 10,
  39. endLine: 20,
  40. language: 'typescript',
  41. test: false,
  42. ...over,
  43. };
  44. }
  45. function relation(over: Partial<WireRelation> & { node?: any } = {}): WireRelation {
  46. const { node, ...rest } = over;
  47. const lines = rest.lines ?? [12];
  48. return {
  49. edgeKinds: ['calls'],
  50. edges: lines.map((line) => ({ kind: 'calls' as const, line, col: 4 })),
  51. edgeCount: lines.length,
  52. lines,
  53. confidence: 0.9,
  54. uncertain: false,
  55. synthesized: false,
  56. ...rest,
  57. node: nodeRef(node),
  58. } as WireRelation;
  59. }
  60. function payload(over: Partial<WireSymbolPayload> = {}): WireSymbolPayload {
  61. return {
  62. node: { ...nodeRef(), startColumn: 2, endColumn: 3, lines: 11 },
  63. ancestors: [],
  64. members: { total: 0, shown: 0, truncated: false, items: [] },
  65. incoming: { total: 0, shown: 0, truncated: false, items: [] },
  66. outgoing: { total: 0, shown: 0, truncated: false, items: [] },
  67. typesUsed: [],
  68. counts: { callers: 0, callees: 0, typesUsed: 0, fanIn: 0, fanOut: 0, members: 0, hub: false },
  69. tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 },
  70. outsideIndex: { total: 0, byKind: {}, samples: [] },
  71. blast: null,
  72. drift: false,
  73. ...over,
  74. } as WireSymbolPayload;
  75. }
  76. const body = (count: number, from = 1): string[] =>
  77. Array.from({ length: count }, (_, i) => `line ${from + i}`);
  78. /* ---------------------------------------------------------------- words -- */
  79. describe('edge wording', () => {
  80. it('names the relationships that are not a plain call, and leaves calls unlabelled', () => {
  81. // Labelling every row "calls" is noise that hides the rows where the
  82. // relationship is something else.
  83. expect(edgeWord({ kind: 'calls' })).toBe('');
  84. expect(edgeWord({ kind: 'instantiates' })).toBe('creates');
  85. expect(edgeWord({ kind: 'references' })).toBe('uses type');
  86. expect(edgeWord({ kind: 'references', valueRef: true })).toBe('passes as value');
  87. expect(edgeWord({ kind: 'implements' })).toBe('implements');
  88. });
  89. it('names the synthesizer behind a heuristic edge, and nothing for a parsed one', () => {
  90. const parsed = relation();
  91. expect(synthesizedBy(parsed)).toBeNull();
  92. const synthesized = {
  93. ...parsed,
  94. synthesized: true,
  95. edges: [{ kind: 'calls', line: 12, provenance: 'heuristic', synthesizedBy: 'react-render' }],
  96. } as WireRelation;
  97. expect(synthesizedBy(synthesized)).toBe('react-render');
  98. });
  99. it('falls back to a truthful placeholder when the synthesizer did not name itself', () => {
  100. const synthesized = {
  101. ...relation(),
  102. synthesized: true,
  103. edges: [{ kind: 'calls', line: 12, provenance: 'heuristic' }],
  104. } as WireRelation;
  105. expect(synthesizedBy(synthesized)).toBe('synthesized');
  106. });
  107. });
  108. describe('kindPhrase', () => {
  109. it('reads the modifiers a reader acts on, and stays silent about the default ones', () => {
  110. expect(kindPhrase({ kind: 'method', async: true })).toBe('method · async');
  111. expect(kindPhrase({ kind: 'type_alias' })).toBe('type');
  112. expect(kindPhrase({ kind: 'method', visibility: 'public' })).toBe('method');
  113. expect(kindPhrase({ kind: 'method', static: true, visibility: 'private' })).toBe(
  114. 'method · static · private'
  115. );
  116. });
  117. });
  118. /* -------------------------------------------------------------- windows -- */
  119. describe('buildCodeBlock', () => {
  120. it('shows a body of 260 lines or fewer whole, with no gaps', () => {
  121. const block = buildCodeBlock(1, body(FULL_BODY_LINES), [5, 200]);
  122. expect(block.whole).toBe(true);
  123. expect(block.windows).toHaveLength(1);
  124. expect(block.windows[0]?.start).toBe(1);
  125. expect(block.windows[0]?.lines).toHaveLength(FULL_BODY_LINES);
  126. expect(block.gapsAfter).toEqual([]);
  127. expect(block.tailGap).toBe(0);
  128. });
  129. it('keeps the head plus a window round every call site once the body is longer', () => {
  130. // One call, far past the head: head + one ±4 window, one gap between them.
  131. const block = buildCodeBlock(1, body(400), [300]);
  132. expect(block.whole).toBe(false);
  133. expect(block.windows).toHaveLength(2);
  134. expect(block.windows[0]).toMatchObject({ start: 1 });
  135. expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES);
  136. expect(block.windows[1]?.start).toBe(296);
  137. expect(block.windows[1]?.lines).toHaveLength(9);
  138. expect(block.gapsAfter).toEqual([215]);
  139. // 400 − 304 lines never reached the screen, and the block says how many.
  140. expect(block.tailGap).toBe(96);
  141. });
  142. it('merges windows that all but touch, rather than drawing a one-line gap', () => {
  143. const block = buildCodeBlock(1, body(400), [300, 310]);
  144. // 296–304 and 306–314 are two apart: one window, no gap row between them.
  145. expect(block.windows).toHaveLength(2);
  146. expect(block.windows[1]).toMatchObject({ start: 296 });
  147. expect(block.windows[1]?.lines).toHaveLength(19);
  148. expect(block.gapsAfter).toEqual([215]);
  149. });
  150. it('ignores call sites already inside the head', () => {
  151. const block = buildCodeBlock(1, body(400), [3, 40]);
  152. expect(block.windows).toHaveLength(1);
  153. expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES);
  154. expect(block.tailGap).toBe(320);
  155. });
  156. it("numbers windows from the symbol's real first line, not from one", () => {
  157. const block = buildCodeBlock(778, body(400, 778), [1000]);
  158. expect(block.windows[0]?.start).toBe(778);
  159. expect(block.windows[1]?.start).toBe(996);
  160. expect(block.windows[1]?.lines[0]).toBe('line 996');
  161. });
  162. it('never runs a window past the end of the body', () => {
  163. const block = buildCodeBlock(1, body(400), [399]);
  164. const last = block.windows[block.windows.length - 1];
  165. expect((last?.start ?? 0) + (last?.lines.length ?? 0) - 1).toBe(400);
  166. expect(block.tailGap).toBe(0);
  167. });
  168. it('windows only on edges that reach the graph, not on unresolved references', () => {
  169. // A function calling `console.log` 200 times would otherwise window around
  170. // nearly every line, and the head-plus-windows rule would buy nothing.
  171. const view = payload({
  172. outgoing: { total: 1, shown: 1, truncated: false, items: [relation({ lines: [300] })] },
  173. outsideIndex: {
  174. total: 1,
  175. byKind: { calls: 1 },
  176. samples: [{ name: 'console.log', kind: 'calls', line: 350, col: 4 }],
  177. },
  178. });
  179. expect(graphCallLines(view)).toEqual([300]);
  180. expect(refsByLine(view).has(350)).toBe(true);
  181. });
  182. });
  183. /* ----------------------------------------------------------------- refs -- */
  184. describe('assignRefs', () => {
  185. const toks = (line: string) => tokenize(line, newLexState(), 'typescript');
  186. const ref = (over: Partial<LineRef>): LineRef => ({
  187. ident: 'withLock',
  188. col: null,
  189. targetId: 'method:x',
  190. uncertain: false,
  191. outside: false,
  192. title: '',
  193. ...over,
  194. });
  195. it('marks the callee, not the receiver the column actually points at', () => {
  196. // The recorded column is the start of the calling EXPRESSION, so an exact
  197. // hit is the exception: `this` sits at column 11, `withLock` at 27.
  198. const line = ' return this.indexMutex.withLock(async () => {';
  199. const tokens = toks(line);
  200. const claimed = assignRefs(tokens, [ref({ col: 11 })]);
  201. const [index] = [...claimed.keys()];
  202. expect(tokens[index as number]?.text).toBe('withLock');
  203. });
  204. it('prefers the token the column lands inside when there is one', () => {
  205. const line = 'render(); render();';
  206. const tokens = toks(line);
  207. const second = line.lastIndexOf('render');
  208. const claimed = assignRefs(tokens, [ref({ ident: 'render', col: second })]);
  209. const [index] = [...claimed.keys()];
  210. expect(tokens[index as number]?.col).toBe(second);
  211. });
  212. it('gives two refs to the same name two different tokens', () => {
  213. const tokens = toks('render(); render();');
  214. const claimed = assignRefs(tokens, [
  215. ref({ ident: 'render', col: null, targetId: 'a' }),
  216. ref({ ident: 'render', col: null, targetId: 'b' }),
  217. ]);
  218. expect(claimed.size).toBe(2);
  219. expect(new Set([...claimed.values()].map((r) => r.targetId))).toEqual(new Set(['a', 'b']));
  220. });
  221. it('claims nothing when the identifier is not on the line', () => {
  222. // Better a missing link than an accent underline on the wrong word.
  223. expect(assignRefs(toks('return 1;'), [ref({ ident: 'nowhere' })]).size).toBe(0);
  224. });
  225. it('never marks a keyword, a string or a comment as a call site', () => {
  226. const tokens = toks('// call render here');
  227. expect(assignRefs(tokens, [ref({ ident: 'render' })]).size).toBe(0);
  228. expect(assignRefs(toks('const s = "render";'), [ref({ ident: 'render' })]).size).toBe(0);
  229. });
  230. });
  231. describe('refsByLine', () => {
  232. it('carries type references too, so a line that only names a type gets its port', () => {
  233. const view = payload({
  234. typesUsed: [relation({ node: { id: 'interface:c', kind: 'interface', name: 'Config' }, lines: [11] })],
  235. });
  236. const refs = refsByLine(view);
  237. expect(refs.get(11)?.[0]).toMatchObject({ ident: 'Config', outside: false });
  238. });
  239. it('uses the last segment of a qualified name — that is what is in the source', () => {
  240. const view = payload({
  241. outgoing: {
  242. total: 1,
  243. shown: 1,
  244. truncated: false,
  245. items: [relation({ node: { id: 'm:1', name: 'Cache.read' }, lines: [12] })],
  246. },
  247. });
  248. expect(refsByLine(view).get(12)?.[0]?.ident).toBe('read');
  249. });
  250. it('drops an unresolved "name" that is not an identifier at all', () => {
  251. // The resolver's samples are raw bookkeeping; a captured arrow function
  252. // cannot be found in the line, and searching for it would claim the wrong
  253. // token.
  254. const view = payload({
  255. outsideIndex: {
  256. total: 2,
  257. byKind: { calls: 2 },
  258. samples: [
  259. { name: '(() => {\n return t', kind: 'calls', line: 12, col: 0 },
  260. { name: 'this.db', kind: 'function_ref', line: 13, col: 4 },
  261. ],
  262. },
  263. });
  264. const refs = refsByLine(view);
  265. expect(refs.has(12)).toBe(false);
  266. // `this.db` reduces to `db`, which IS in the line — kept, and marked as
  267. // outside the index so it renders as text rather than a link.
  268. expect(refs.get(13)?.[0]).toMatchObject({ ident: 'db', outside: true, targetId: null });
  269. });
  270. });
  271. /* ---------------------------------------------------------------- rails -- */
  272. describe('buildCallerRail', () => {
  273. const caller = (over: { id: string; file: string; test?: boolean; uncertain?: boolean; edges?: number }) =>
  274. ({
  275. ...relation({ lines: [4657] }),
  276. node: {
  277. ...nodeRef({ id: over.id, file: over.file, name: over.id }),
  278. test: over.test ?? false,
  279. },
  280. edgeCount: over.edges ?? 1,
  281. uncertain: over.uncertain ?? false,
  282. }) as WireRelation;
  283. it("puts the symbol's own file first and groups the rest by path", () => {
  284. const view = payload({
  285. node: { ...nodeRef({ file: 'src/service.ts' }), startColumn: 0, endColumn: 0, lines: 11 },
  286. incoming: {
  287. total: 3,
  288. shown: 3,
  289. truncated: false,
  290. items: [
  291. caller({ id: 'z', file: 'src/z.ts' }),
  292. caller({ id: 'a', file: 'src/a.ts' }),
  293. caller({ id: 'own', file: 'src/service.ts' }),
  294. ],
  295. },
  296. });
  297. const rail = buildCallerRail(view);
  298. expect(rail.groups.map((g) => g.file)).toEqual(['src/service.ts', 'src/a.ts', 'src/z.ts']);
  299. expect(rail.groups[0]?.same).toBe(true);
  300. expect(rail.groups[1]?.same).toBe(false);
  301. });
  302. it('folds test callers away with their call and file counts intact', () => {
  303. const view = payload({
  304. incoming: {
  305. total: 3,
  306. shown: 3,
  307. truncated: false,
  308. items: [
  309. caller({ id: 'prod', file: 'src/a.ts' }),
  310. caller({ id: 't1', file: '__tests__/a.test.ts', test: true, edges: 4 }),
  311. caller({ id: 't2', file: '__tests__/b.test.ts', test: true, edges: 2 }),
  312. ],
  313. },
  314. });
  315. const rail = buildCallerRail(view);
  316. expect(rail.groups).toHaveLength(1);
  317. expect(rail.tests.rows).toHaveLength(2);
  318. expect(rail.tests.calls).toBe(6);
  319. expect(rail.tests.files).toEqual(['__tests__/a.test.ts', '__tests__/b.test.ts']);
  320. // The header count stays the real one — nothing is silently dropped.
  321. expect(rail.total).toBe(3);
  322. });
  323. it('folds an uncertain test caller as uncertain, not as a test', () => {
  324. // Uncertainty is a claim about the EDGE. Filing it under "tests" would
  325. // present a name-only guess as an established call.
  326. const view = payload({
  327. incoming: {
  328. total: 1,
  329. shown: 1,
  330. truncated: false,
  331. items: [caller({ id: 'g', file: '__tests__/a.test.ts', test: true, uncertain: true })],
  332. },
  333. });
  334. const rail = buildCallerRail(view);
  335. expect(rail.uncertain).toHaveLength(1);
  336. expect(rail.tests.rows).toHaveLength(0);
  337. expect(rail.groups).toHaveLength(0);
  338. });
  339. it('reports the callers the API had to cap away', () => {
  340. const view = payload({
  341. incoming: { total: 545, shown: 1, truncated: true, items: [caller({ id: 'a', file: 'src/a.ts' })] },
  342. });
  343. expect(buildCallerRail(view).hiddenGroups).toBe(544);
  344. });
  345. });
  346. describe('buildCalleeRail', () => {
  347. it('anchors each row to its first call site and folds the guesses to the bottom', () => {
  348. const view = payload({
  349. outgoing: {
  350. total: 2,
  351. shown: 2,
  352. truncated: false,
  353. items: [
  354. relation({ node: { id: 'sure' }, lines: [12, 18] }),
  355. { ...relation({ node: { id: 'guess' }, lines: [15] }), uncertain: true, confidence: 0.4 },
  356. ],
  357. },
  358. });
  359. const rail = buildCalleeRail(view);
  360. expect(rail.rows).toHaveLength(1);
  361. expect(rail.rows[0]?.anchor).toBe(12);
  362. expect(rail.rows[0]?.lines).toEqual([12, 18]);
  363. expect(rail.uncertain).toHaveLength(1);
  364. });
  365. it('separates calls that leave the index from type references that do', () => {
  366. const view = payload({
  367. outsideIndex: { total: 24, byKind: { calls: 21, references: 2, function_ref: 1 }, samples: [] },
  368. });
  369. const rail = buildCalleeRail(view);
  370. expect(rail.outsideCalls).toBe(22);
  371. expect(rail.outsideTypeRefs).toBe(2);
  372. });
  373. it('leaves a row with no recorded line unanchored rather than guessing a height', () => {
  374. const view = payload({
  375. outgoing: {
  376. total: 1,
  377. shown: 1,
  378. truncated: false,
  379. items: [{ ...relation({ lines: [] }), lines: [], edges: [] } as WireRelation],
  380. },
  381. });
  382. expect(buildCalleeRail(view).rows[0]?.anchor).toBeNull();
  383. });
  384. });
  385. /* -------------------------------------------------------------- outline -- */
  386. describe('members outline', () => {
  387. it('dims data members and indents the ones nested a level deeper', () => {
  388. const view = payload({
  389. members: {
  390. total: 2,
  391. shown: 2,
  392. truncated: false,
  393. items: [
  394. { ...nodeRef({ kind: 'property', name: 'store' }), parentId: 'x', depth: 1, fanIn: 1, fanOut: 0 },
  395. { ...nodeRef({ kind: 'method', name: 'read' }), parentId: 'y', depth: 2, fanIn: 3, fanOut: 5 },
  396. ] as any,
  397. },
  398. });
  399. const rows = buildOutline(view);
  400. expect(rows[0]).toMatchObject({ dimmed: true, nested: false });
  401. expect(rows[1]).toMatchObject({ dimmed: false, nested: true });
  402. });
  403. });
  404. describe('showsBody', () => {
  405. it("swaps a large container's body for its outline, and keeps a large function's", () => {
  406. expect(showsBody('class', 700)).toBe(false);
  407. expect(showsBody('file', 2000)).toBe(false);
  408. expect(showsBody('class', 40)).toBe(true);
  409. // A 700-line function IS its body — there is no outline to show instead.
  410. expect(showsBody('function', 700)).toBe(true);
  411. expect(showsBody('method', 259)).toBe(true);
  412. });
  413. });
  414. /* ---------------------------------------------------------------- lexer -- */
  415. describe('tokenize', () => {
  416. const kinds = (line: string, state = newLexState(), language = 'typescript') =>
  417. tokenize(line, state, language).map((t) => `${t.cls}:${t.text}`);
  418. it('separates the four things the near-monochrome theme colours', () => {
  419. expect(kinds('const x = 1; // note')).toEqual([
  420. 'keyword:const',
  421. 'space: ',
  422. 'ident:x',
  423. 'space: ',
  424. 'punct:=',
  425. 'space: ',
  426. 'number:1',
  427. 'punct:;',
  428. 'space: ',
  429. 'comment:// note',
  430. ]);
  431. });
  432. it('carries a block comment across lines so the next line is not read as code', () => {
  433. const state = newLexState();
  434. expect(kinds('/* open', state)).toEqual(['comment:/* open']);
  435. expect(state.block).toBe(true);
  436. expect(kinds('still comment', state)).toEqual(['comment:still comment']);
  437. expect(kinds('done */ const x = 1;', state)).toEqual([
  438. 'comment:done */',
  439. 'space: ',
  440. 'keyword:const',
  441. 'space: ',
  442. 'ident:x',
  443. 'space: ',
  444. 'punct:=',
  445. 'space: ',
  446. 'number:1',
  447. 'punct:;',
  448. ]);
  449. expect(state.block).toBe(false);
  450. });
  451. it('carries a template literal across lines, and closes it on the right backtick', () => {
  452. const state = newLexState();
  453. expect(kinds('const s = `open', state)).toContain('string:`open');
  454. expect(state.stringEnd).toBe('`');
  455. expect(kinds('closed` + x', state)).toEqual([
  456. 'string:closed`',
  457. 'space: ',
  458. 'punct:+',
  459. 'space: ',
  460. 'ident:x',
  461. ]);
  462. });
  463. it('does not eat the rest of a window on an apostrophe in prose', () => {
  464. // An unterminated single-line quote is punctuation in English far more
  465. // often than a real string, so it stops at the line.
  466. const state = newLexState();
  467. kinds("// it's fine", state);
  468. expect(state.stringEnd).toBeNull();
  469. const after = kinds('const x = 1;', state);
  470. expect(after[0]).toBe('keyword:const');
  471. });
  472. it('reads a # comment as a comment in Python and as code in TypeScript', () => {
  473. expect(kinds('# note', newLexState(), 'python')).toEqual(['comment:# note']);
  474. expect(kinds('x = 1 # note', newLexState(), 'python').at(-1)).toBe('comment:# note');
  475. expect(kinds('# note', newLexState(), 'typescript')[0]).not.toBe('comment:# note');
  476. });
  477. it('closes a Python triple-quoted string on the triple, not on the first quote', () => {
  478. const state = newLexState();
  479. expect(kinds('"""docstring', state, 'python')).toEqual(['string:"""docstring']);
  480. expect(state.stringEnd).toBe('"""');
  481. expect(kinds('more"""', state, 'python')).toEqual(['string:more"""']);
  482. });
  483. it("reports each token's column, which is how a ref finds its identifier", () => {
  484. const tokens = tokenize(' return render();', newLexState(), 'typescript');
  485. const render = tokens.find((t) => t.text === 'render');
  486. expect(render?.col).toBe(' return '.length);
  487. });
  488. it('falls back to a C-family reading for a language it has no table for', () => {
  489. // Silence beats a wrong claim, but a plain `//` comment is not a claim
  490. // worth getting wrong in a language we have not enumerated.
  491. expect(kinds('// note', newLexState(), 'some-new-language')).toEqual(['comment:// note']);
  492. });
  493. });