ui-symbol-model.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  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 { decodeLine, plainLine, tokensByLine } 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) => plainLine(line);
  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 word inside a comment or a string as a call site', () => {
  226. // The classification comes from the server's grammar; what this pins is
  227. // that the overlay respects it. Anything else — a keyword, a type name a
  228. // grammar happened to scope as `storage.type` — stays claimable, because a
  229. // grammar's opinion about a scope name must not decide what navigates.
  230. const comment = [
  231. { cls: 'comment', text: '// call render here', col: 0 },
  232. ];
  233. expect(assignRefs(comment, [ref({ ident: 'render' })]).size).toBe(0);
  234. const string = [
  235. { cls: 'keyword', text: 'const', col: 0 },
  236. { cls: 'other', text: ' s = ', col: 5 },
  237. { cls: 'string', text: '"render"', col: 10 },
  238. { cls: 'other', text: ';', col: 18 },
  239. ];
  240. expect(assignRefs(string, [ref({ ident: 'render' })]).size).toBe(0);
  241. });
  242. it('still claims an identifier a grammar classified as something else', () => {
  243. // Go scopes `string` as storage.type; Java does the same to a declared
  244. // type name. A link that disappeared over that would be a highlighting
  245. // change silently breaking navigation.
  246. const tokens = [
  247. { cls: 'keyword', text: 'Duration', col: 0 },
  248. { cls: 'other', text: '.Since(t)', col: 8 },
  249. ];
  250. const claimed = assignRefs(tokens, [ref({ ident: 'Duration', col: 0 })]);
  251. expect(claimed.size).toBe(1);
  252. expect(tokens[[...claimed.keys()][0] as number]?.text).toBe('Duration');
  253. });
  254. });
  255. describe('refsByLine', () => {
  256. it('carries type references too, so a line that only names a type gets its port', () => {
  257. const view = payload({
  258. typesUsed: [relation({ node: { id: 'interface:c', kind: 'interface', name: 'Config' }, lines: [11] })],
  259. });
  260. const refs = refsByLine(view);
  261. expect(refs.get(11)?.[0]).toMatchObject({ ident: 'Config', outside: false });
  262. });
  263. it('uses the last segment of a qualified name — that is what is in the source', () => {
  264. const view = payload({
  265. outgoing: {
  266. total: 1,
  267. shown: 1,
  268. truncated: false,
  269. items: [relation({ node: { id: 'm:1', name: 'Cache.read' }, lines: [12] })],
  270. },
  271. });
  272. expect(refsByLine(view).get(12)?.[0]?.ident).toBe('read');
  273. });
  274. it('drops an unresolved "name" that is not an identifier at all', () => {
  275. // The resolver's samples are raw bookkeeping; a captured arrow function
  276. // cannot be found in the line, and searching for it would claim the wrong
  277. // token.
  278. const view = payload({
  279. outsideIndex: {
  280. total: 2,
  281. byKind: { calls: 2 },
  282. samples: [
  283. { name: '(() => {\n return t', kind: 'calls', line: 12, col: 0 },
  284. { name: 'this.db', kind: 'function_ref', line: 13, col: 4 },
  285. ],
  286. },
  287. });
  288. const refs = refsByLine(view);
  289. expect(refs.has(12)).toBe(false);
  290. // `this.db` reduces to `db`, which IS in the line — kept, and marked as
  291. // outside the index so it renders as text rather than a link.
  292. expect(refs.get(13)?.[0]).toMatchObject({ ident: 'db', outside: true, targetId: null });
  293. });
  294. });
  295. /* ---------------------------------------------------------------- rails -- */
  296. describe('buildCallerRail', () => {
  297. const caller = (over: { id: string; file: string; test?: boolean; uncertain?: boolean; edges?: number }) =>
  298. ({
  299. ...relation({ lines: [4657] }),
  300. node: {
  301. ...nodeRef({ id: over.id, file: over.file, name: over.id }),
  302. test: over.test ?? false,
  303. },
  304. edgeCount: over.edges ?? 1,
  305. uncertain: over.uncertain ?? false,
  306. }) as WireRelation;
  307. it("puts the symbol's own file first and groups the rest by path", () => {
  308. const view = payload({
  309. node: { ...nodeRef({ file: 'src/service.ts' }), startColumn: 0, endColumn: 0, lines: 11 },
  310. incoming: {
  311. total: 3,
  312. shown: 3,
  313. truncated: false,
  314. items: [
  315. caller({ id: 'z', file: 'src/z.ts' }),
  316. caller({ id: 'a', file: 'src/a.ts' }),
  317. caller({ id: 'own', file: 'src/service.ts' }),
  318. ],
  319. },
  320. });
  321. const rail = buildCallerRail(view);
  322. expect(rail.groups.map((g) => g.file)).toEqual(['src/service.ts', 'src/a.ts', 'src/z.ts']);
  323. expect(rail.groups[0]?.same).toBe(true);
  324. expect(rail.groups[1]?.same).toBe(false);
  325. });
  326. it('folds test callers away with their call and file counts intact', () => {
  327. const view = payload({
  328. incoming: {
  329. total: 3,
  330. shown: 3,
  331. truncated: false,
  332. items: [
  333. caller({ id: 'prod', file: 'src/a.ts' }),
  334. caller({ id: 't1', file: '__tests__/a.test.ts', test: true, edges: 4 }),
  335. caller({ id: 't2', file: '__tests__/b.test.ts', test: true, edges: 2 }),
  336. ],
  337. },
  338. });
  339. const rail = buildCallerRail(view);
  340. expect(rail.groups).toHaveLength(1);
  341. expect(rail.tests.rows).toHaveLength(2);
  342. expect(rail.tests.calls).toBe(6);
  343. expect(rail.tests.files).toEqual(['__tests__/a.test.ts', '__tests__/b.test.ts']);
  344. // The header count stays the real one — nothing is silently dropped.
  345. expect(rail.total).toBe(3);
  346. });
  347. it('folds an uncertain test caller as uncertain, not as a test', () => {
  348. // Uncertainty is a claim about the EDGE. Filing it under "tests" would
  349. // present a name-only guess as an established call.
  350. const view = payload({
  351. incoming: {
  352. total: 1,
  353. shown: 1,
  354. truncated: false,
  355. items: [caller({ id: 'g', file: '__tests__/a.test.ts', test: true, uncertain: true })],
  356. },
  357. });
  358. const rail = buildCallerRail(view);
  359. expect(rail.uncertain).toHaveLength(1);
  360. expect(rail.tests.rows).toHaveLength(0);
  361. expect(rail.groups).toHaveLength(0);
  362. });
  363. it('reports the callers the API had to cap away', () => {
  364. const view = payload({
  365. incoming: { total: 545, shown: 1, truncated: true, items: [caller({ id: 'a', file: 'src/a.ts' })] },
  366. });
  367. expect(buildCallerRail(view).hiddenGroups).toBe(544);
  368. });
  369. });
  370. describe('buildCalleeRail', () => {
  371. it('anchors each row to its first call site and folds the guesses to the bottom', () => {
  372. const view = payload({
  373. outgoing: {
  374. total: 2,
  375. shown: 2,
  376. truncated: false,
  377. items: [
  378. relation({ node: { id: 'sure' }, lines: [12, 18] }),
  379. { ...relation({ node: { id: 'guess' }, lines: [15] }), uncertain: true, confidence: 0.4 },
  380. ],
  381. },
  382. });
  383. const rail = buildCalleeRail(view);
  384. expect(rail.rows).toHaveLength(1);
  385. expect(rail.rows[0]?.anchor).toBe(12);
  386. expect(rail.rows[0]?.lines).toEqual([12, 18]);
  387. expect(rail.uncertain).toHaveLength(1);
  388. });
  389. it('separates calls that leave the index from type references that do', () => {
  390. const view = payload({
  391. outsideIndex: { total: 24, byKind: { calls: 21, references: 2, function_ref: 1 }, samples: [] },
  392. });
  393. const rail = buildCalleeRail(view);
  394. expect(rail.outsideCalls).toBe(22);
  395. expect(rail.outsideTypeRefs).toBe(2);
  396. });
  397. it('leaves a row with no recorded line unanchored rather than guessing a height', () => {
  398. const view = payload({
  399. outgoing: {
  400. total: 1,
  401. shown: 1,
  402. truncated: false,
  403. items: [{ ...relation({ lines: [] }), lines: [], edges: [] } as WireRelation],
  404. },
  405. });
  406. expect(buildCalleeRail(view).rows[0]?.anchor).toBeNull();
  407. });
  408. });
  409. /* -------------------------------------------------------------- outline -- */
  410. describe('members outline', () => {
  411. it('dims data members and indents the ones nested a level deeper', () => {
  412. const view = payload({
  413. members: {
  414. total: 2,
  415. shown: 2,
  416. truncated: false,
  417. items: [
  418. { ...nodeRef({ kind: 'property', name: 'store' }), parentId: 'x', depth: 1, fanIn: 1, fanOut: 0 },
  419. { ...nodeRef({ kind: 'method', name: 'read' }), parentId: 'y', depth: 2, fanIn: 3, fanOut: 5 },
  420. ] as any,
  421. },
  422. });
  423. const rows = buildOutline(view);
  424. expect(rows[0]).toMatchObject({ dimmed: true, nested: false });
  425. expect(rows[1]).toMatchObject({ dimmed: false, nested: true });
  426. });
  427. });
  428. describe('showsBody', () => {
  429. it("swaps a large container's body for its outline, and keeps a large function's", () => {
  430. expect(showsBody('class', 700)).toBe(false);
  431. expect(showsBody('file', 2000)).toBe(false);
  432. expect(showsBody('class', 40)).toBe(true);
  433. // A 700-line function IS its body — there is no outline to show instead.
  434. expect(showsBody('function', 700)).toBe(true);
  435. expect(showsBody('method', 259)).toBe(true);
  436. });
  437. });
  438. /* ---------------------------------------------------------------- lexer -- */
  439. describe('client-side token decoding', () => {
  440. // The classification itself is the server's job (`src/ui-server/highlight/`,
  441. // the engine's own tree-sitter parse); what is worth pinning here is the decoding — the
  442. // columns the call-site overlay matches against, and the plain fallback that
  443. // has to keep links working when no grammar covers a file.
  444. const CLASSES = ['other', 'ident', 'comment', 'string', 'keyword', 'number'];
  445. it('resolves class ids through the payload table', () => {
  446. const tokens = decodeLine(
  447. [
  448. [4, 'const'],
  449. [0, ' '],
  450. [1, 'x'],
  451. [0, ' = '],
  452. [5, '1'],
  453. [0, '; '],
  454. [2, '// note'],
  455. ],
  456. CLASSES
  457. );
  458. expect(tokens.map((t) => `${t.cls}:${t.text}`)).toEqual([
  459. 'keyword:const',
  460. 'other: ',
  461. 'ident:x',
  462. 'other: = ',
  463. 'number:1',
  464. 'other:; ',
  465. 'comment:// note',
  466. ]);
  467. });
  468. it('derives each column from the running text, which is how a ref finds its identifier', () => {
  469. const tokens = decodeLine(
  470. [
  471. [0, ' '],
  472. [4, 'return'],
  473. [0, ' '],
  474. [1, 'render'],
  475. [0, '();'],
  476. ],
  477. CLASSES
  478. );
  479. expect(tokens.find((t) => t.text === 'render')?.col).toBe(' return '.length);
  480. expect(tokens.at(-1)?.col).toBe(' return render'.length);
  481. });
  482. it('treats an unknown class id as unstyled rather than throwing', () => {
  483. expect(decodeLine([[99, 'x']], CLASSES)[0]?.cls).toBe('other');
  484. });
  485. it('splits identifiers even with no grammar, so the links still land', () => {
  486. expect(plainLine(' return this.mutex.withLock();').map((t) => `${t.cls}:${t.text}`)).toEqual([
  487. 'other: ',
  488. 'ident:return',
  489. 'other: ',
  490. 'ident:this',
  491. 'other:.',
  492. 'ident:mutex',
  493. 'other:.',
  494. 'ident:withLock',
  495. 'other:();',
  496. ]);
  497. });
  498. it('splits non-ASCII identifiers, because a symbol name can be one', () => {
  499. expect(plainLine('取得データ()').map((t) => t.cls)).toEqual(['ident', 'other']);
  500. });
  501. it('keys a slice by real file line, not by offset into the slice', () => {
  502. const byLine = tokensByLine(['a();', 'b();'], 120, {
  503. engine: 'tree-sitter',
  504. grammar: 'typescript',
  505. classes: CLASSES,
  506. lines: [
  507. [
  508. [1, 'a'],
  509. [0, '();'],
  510. ],
  511. [
  512. [1, 'b'],
  513. [0, '();'],
  514. ],
  515. ],
  516. });
  517. expect([...byLine.keys()]).toEqual([120, 121]);
  518. expect(byLine.get(121)?.[0]?.text).toBe('b');
  519. });
  520. it('falls back per line when the payload carries no highlight block at all', () => {
  521. const byLine = tokensByLine(['render();'], 5, undefined);
  522. expect(byLine.get(5)?.map((t) => t.cls)).toEqual(['ident', 'other']);
  523. });
  524. });