ui-export-svg.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. /**
  2. * The SVG exporter (CG-55) — `ui/src/lib/export-svg.ts`.
  3. *
  4. * The export exists to leave the app, so the properties worth pinning are the
  5. * ones a reader on the other side depends on:
  6. *
  7. * - it is **well-formed XML**, or GitHub's sanitiser drops it and the reader
  8. * sees a broken-image icon with no explanation;
  9. * - it carries the **light** tokens whatever the viewer was set to, because a
  10. * dark image on a white comment background reads as a mistake;
  11. * - it says the **same thing the screen does** — same cards, same hops, same
  12. * dashed hops, same hidden thin links — because the whole point of exporting
  13. * from the layout object rather than the DOM is that the two cannot diverge;
  14. * - it fits the drawing, with nothing running off the edge of the canvas.
  15. *
  16. * Everything here is pure. The raster step needs a browser and is verified
  17. * over CDP against a live `codegraph ui`.
  18. */
  19. import { describe, it, expect } from 'vitest';
  20. import {
  21. EXPORT_COLORS,
  22. EXPORT_PADDING,
  23. MARK_TEXT,
  24. capRows,
  25. esc,
  26. exportFilename,
  27. flowSvg,
  28. mapSvg,
  29. truncate,
  30. wrapText,
  31. } from '../ui/src/lib/export-svg';
  32. import { buildFlowLayout } from '../ui/src/lib/flow-model';
  33. import { buildMapLayout } from '../ui/src/lib/map-model';
  34. import type {
  35. WireFlow,
  36. WireFlowBoundary,
  37. WireFlowEdge,
  38. WireFlowHop,
  39. WireMapLink,
  40. WireMapModule,
  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. line: 42,
  52. ...over,
  53. };
  54. }
  55. function ref(name: string): WireNodeRef {
  56. return {
  57. id: `method:${name}`,
  58. kind: 'method',
  59. name,
  60. qualifiedName: name,
  61. file: `src/deep/${name}.ts`,
  62. line: 10,
  63. endLine: 40,
  64. language: 'typescript',
  65. test: false,
  66. };
  67. }
  68. function hop(
  69. name: string,
  70. opts: { lines?: string[]; edge?: WireFlowEdge | null; callLine?: number } = {}
  71. ): WireFlowHop {
  72. const lines = opts.lines ?? [' const a = 1;', ' return other(a);'];
  73. return {
  74. node: ref(name),
  75. edge: opts.edge === undefined ? edge() : opts.edge,
  76. callRef:
  77. opts.callLine === undefined
  78. ? null
  79. : { line: opts.callLine, col: 9, name: 'other', targetId: 'method:other', backwards: false },
  80. source: {
  81. file: `src/deep/${name}.ts`,
  82. language: 'typescript',
  83. from: 7,
  84. to: 6 + lines.length,
  85. lines,
  86. drift: false,
  87. },
  88. };
  89. }
  90. function flow(id: string, names: string[], over: Partial<WireFlow> = {}): WireFlow {
  91. return {
  92. id,
  93. label: `${names[0]} → ${names[names.length - 1]}`,
  94. hops: names.map((name, i) =>
  95. hop(name, { edge: i === 0 ? null : edge(), callLine: i === 0 ? 8 : undefined })
  96. ),
  97. boundary: null,
  98. partial: false,
  99. ...over,
  100. };
  101. }
  102. function boundary(over: Partial<WireFlowBoundary> = {}): WireFlowBoundary {
  103. return {
  104. node: ref('routeAny'),
  105. sites: [
  106. {
  107. form: 'computed-call',
  108. label: 'computed member call',
  109. snippet: 'return table[name](payload);',
  110. line: 61,
  111. key: 'save',
  112. keyIsType: false,
  113. moreSites: 0,
  114. candidates: [{ node: ref('onSave'), display: 'onSave', named: true }],
  115. candidateNote: null,
  116. },
  117. ],
  118. uncertain: { total: 0, shown: 0, truncated: false, items: [] },
  119. further: { total: 0, shown: 0, truncated: false, items: [] },
  120. missed: [],
  121. ...over,
  122. };
  123. }
  124. function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
  125. return {
  126. id,
  127. label: id.slice(id.lastIndexOf('/') + 1) || id,
  128. files: over.files ?? 3,
  129. symbols: over.symbols ?? 30,
  130. languages: over.languages ?? [{ language: 'typescript', files: 3 }],
  131. test: over.test ?? false,
  132. facade: over.facade ?? false,
  133. fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
  134. dependents: over.dependents ?? { files: 0, modules: 0 },
  135. };
  136. }
  137. function link(source: string, target: string, count: number, declared = count): WireMapLink {
  138. return { source, target, count, declared, byKind: [{ kind: 'calls', count }], topPairs: [] };
  139. }
  140. /* ------------------------------------------------------------ utilities -- */
  141. /**
  142. * Parse the SVG the way a consumer does.
  143. *
  144. * `DOMParser` is not in Node, so this is a hand-rolled well-formedness check:
  145. * every tag balanced, every attribute quoted, no stray `<` or `&` in text. That
  146. * is exactly the class of bug an un-escaped symbol name (`Map<K,V>`, `a && b`)
  147. * would introduce, and it is the one that makes GitHub refuse the file.
  148. */
  149. function assertWellFormed(svg: string): void {
  150. const stack: string[] = [];
  151. const tag = /<(\/?)([a-zA-Z:]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
  152. let at = 0;
  153. let match: RegExpExecArray | null;
  154. while ((match = tag.exec(svg)) !== null) {
  155. const between = svg.slice(at, match.index);
  156. expect(between, `unescaped < or & in text: ${JSON.stringify(between)}`).not.toMatch(
  157. /[<]|&(?!(amp|lt|gt|quot|apos|#\d+);)/
  158. );
  159. at = match.index + match[0].length;
  160. const [, closing, name, attrs, selfClosing] = match;
  161. // Every attribute is name="value" with a balanced pair of quotes.
  162. const quotes = (attrs as string).split('"').length - 1;
  163. expect(quotes % 2, `unbalanced quotes in <${name} ${attrs}>`).toBe(0);
  164. if (closing === '/') {
  165. expect(stack.pop(), 'closing tag with no opener').toBe(name);
  166. } else if (selfClosing !== '/') {
  167. stack.push(name as string);
  168. }
  169. }
  170. expect(stack, 'unclosed tags').toEqual([]);
  171. }
  172. function viewBox(svg: string): { width: number; height: number } {
  173. const box = /viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"/.exec(svg);
  174. expect(box, 'no viewBox').toBeTruthy();
  175. return { width: Number(box![1]), height: Number(box![2]) };
  176. }
  177. function rootSize(svg: string): { width: number; height: number } {
  178. const w = /<svg[^>]*\bwidth="(\d+)"/.exec(svg);
  179. const h = /<svg[^>]*\bheight="(\d+)"/.exec(svg);
  180. return { width: Number(w![1]), height: Number(h![1]) };
  181. }
  182. /** Every x/y coordinate that appears on a drawn element, for a bounds check. */
  183. function coords(svg: string): Array<{ x: number; y: number }> {
  184. const out: Array<{ x: number; y: number }> = [];
  185. const re = /x="(-?\d+(?:\.\d+)?)"\s+y="(-?\d+(?:\.\d+)?)"/g;
  186. let m: RegExpExecArray | null;
  187. while ((m = re.exec(svg)) !== null) out.push({ x: Number(m[1]), y: Number(m[2]) });
  188. return out;
  189. }
  190. /* ------------------------------------------------------------ primitives -- */
  191. describe('esc', () => {
  192. it('escapes everything XML would choke on', () => {
  193. expect(esc('Map<K, V> & "co"')).toBe('Map&lt;K, V&gt; &amp; &quot;co&quot;');
  194. });
  195. });
  196. describe('truncate', () => {
  197. it('leaves a string that fits alone, and ellipses one that does not', () => {
  198. expect(truncate('short', 400, 12)).toBe('short');
  199. // 12px mono advances at 7.2px, so 36px holds five characters.
  200. expect(truncate('abcdefgh', 36, 12)).toBe('abcd…');
  201. });
  202. it('does not emit a lone ellipsis when there is no room at all', () => {
  203. expect(truncate('abcdefgh', 7, 12)).toBe('');
  204. });
  205. });
  206. describe('wrapText', () => {
  207. it('breaks on words, never mid-word', () => {
  208. expect(wrapText('the quick brown fox jumps over', 12)).toEqual([
  209. 'the quick',
  210. 'brown fox',
  211. 'jumps over',
  212. ]);
  213. });
  214. it('keeps an over-long word on its own line rather than losing it', () => {
  215. expect(wrapText('aa supercalifragilistic bb', 8)).toEqual(['aa', 'supercalifragilistic', 'bb']);
  216. });
  217. });
  218. describe('exportFilename', () => {
  219. it('slugs a flow label into something a filesystem accepts', () => {
  220. expect(exportFilename('flow', 'execute → getFile')).toBe('codegraph-flow-execute-getfile');
  221. expect(exportFilename('map', 'src/')).toBe('codegraph-map-src');
  222. expect(exportFilename('map', '')).toBe('codegraph-map');
  223. });
  224. });
  225. /* ------------------------------------------------------------ flow strip -- */
  226. describe('flowSvg', () => {
  227. const layout = buildFlowLayout([flow('f1', ['execute', 'openFile', 'rowToFileRecord'])], 'f1');
  228. it('is well-formed XML with a viewBox and the mark', () => {
  229. const svg = flowSvg(layout);
  230. assertWellFormed(svg);
  231. expect(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg"')).toBe(true);
  232. expect(svg.trimEnd().endsWith('</svg>')).toBe(true);
  233. expect(svg).toContain(`>${MARK_TEXT}</text>`);
  234. });
  235. it('paints the light paper whatever the viewer was set to', () => {
  236. const svg = flowSvg(layout);
  237. expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
  238. expect(svg).toContain(EXPORT_COLORS.ink);
  239. // No token from the dark set appears anywhere in the file: dark paper,
  240. // dark ink, dark accent. An export follows the reader's page, not ours.
  241. for (const dark of ['#1c1a14', '#f3f1ea', '#d48b96', '#34322a']) {
  242. expect(svg, dark).not.toContain(dark);
  243. }
  244. });
  245. it('names every hop on the strip, once each', () => {
  246. const svg = flowSvg(layout);
  247. for (const name of ['execute', 'openFile', 'rowToFileRecord']) {
  248. expect(svg.split(`>${name}<`).length - 1, name).toBe(1);
  249. }
  250. });
  251. it('keeps fonts as stacks and embeds nothing', () => {
  252. const svg = flowSvg(layout);
  253. expect(svg).toContain("'IBM Plex Mono'");
  254. expect(svg).not.toContain('@font-face');
  255. expect(svg).not.toContain('base64');
  256. });
  257. it('scales only the root size — the geometry is identical', () => {
  258. const one = flowSvg(layout, { scale: 1 });
  259. const two = flowSvg(layout, { scale: 2 });
  260. expect(viewBox(two)).toEqual(viewBox(one));
  261. expect(rootSize(two).width).toBe(rootSize(one).width * 2);
  262. expect(rootSize(two).height).toBe(rootSize(one).height * 2);
  263. // Same drawing, two envelopes: everything between the root tags matches.
  264. expect(two.slice(two.indexOf('\n'))).toBe(one.slice(one.indexOf('\n')));
  265. });
  266. it('fits the drawing inside the canvas with the padding on every side', () => {
  267. const svg = flowSvg(layout);
  268. const box = viewBox(svg);
  269. const cards = layout.cards;
  270. const spanX = Math.max(...cards.map((c) => c.x + c.width)) - Math.min(...cards.map((c) => c.x));
  271. expect(box.width).toBeGreaterThanOrEqual(spanX + EXPORT_PADDING * 2);
  272. for (const { x, y } of coords(svg)) {
  273. expect(x).toBeGreaterThanOrEqual(-1);
  274. expect(y).toBeGreaterThanOrEqual(-1);
  275. }
  276. });
  277. it('carries the edge label and the line the call was recorded at', () => {
  278. const svg = flowSvg(layout);
  279. expect(svg).toContain('>calls</text>');
  280. expect(svg).toContain('>line 42</text>');
  281. });
  282. it('dashes a synthesized hop exactly as the strip does', () => {
  283. const synthesized = flow('f2', ['a', 'b']);
  284. synthesized.hops[1]!.edge = edge({
  285. synthesized: true,
  286. label: 'via callback · registered at src/wire.ts:88',
  287. });
  288. const svg = flowSvg(buildFlowLayout([synthesized], 'f2'));
  289. expect(svg).toContain('stroke-dasharray="5 3"');
  290. // The wiring site is the evidence for a hop nobody can see in the source.
  291. expect(svg).toContain('wire.ts:88');
  292. });
  293. it('tints the call line and underlines the identifier the graph resolved', () => {
  294. const one = flow('f3', ['execute', 'other']);
  295. one.hops[0]!.callRef = {
  296. line: 8,
  297. col: 9,
  298. name: 'other',
  299. targetId: 'method:other',
  300. backwards: false,
  301. };
  302. const svg = flowSvg(buildFlowLayout([one], 'f3'));
  303. expect(svg).toContain(`fill="${EXPORT_COLORS.accentSoft}"`);
  304. expect(svg).toContain(`<tspan fill="${EXPORT_COLORS.accent}">other</tspan>`);
  305. expect(svg).toContain(`stroke="${EXPORT_COLORS.accentLine}"`);
  306. });
  307. it('preserves the indentation of every source line', () => {
  308. const svg = flowSvg(layout);
  309. expect(svg).toContain('xml:space="preserve"');
  310. expect(svg).toContain('<tspan> </tspan>');
  311. });
  312. it('escapes source that would otherwise break the document', () => {
  313. const nasty = flow('f4', ['render']);
  314. nasty.hops[0]!.source!.lines = ['const x = a < b && c > d;', 'type T = Map<K, "v">;'];
  315. const svg = flowSvg(buildFlowLayout([nasty], 'f4'));
  316. assertWellFormed(svg);
  317. expect(svg).toContain('&lt;');
  318. expect(svg).toContain('&amp;&amp;');
  319. });
  320. it('draws the end cap dashed, with the site, the key and the candidate', () => {
  321. const capped = flow('f5', ['dispatch'], { boundary: null });
  322. capped.boundary = boundary({ node: capped.hops[0]!.node });
  323. const svg = flowSvg(buildFlowLayout([capped], 'f5'));
  324. expect(svg).toContain('Where the graph stops.');
  325. expect(svg).toContain('computed member call at line 61');
  326. expect(svg).toContain('>key save</text>');
  327. expect(svg).toContain('1 candidate target');
  328. // The dotted link into a cap, and the cap's own dashed border.
  329. expect(svg).toContain('stroke-dasharray="2 4"');
  330. expect(svg).toContain('>end of</text>');
  331. // …and no arrowhead on it: the absence of a continuation is the finding.
  332. expect(svg.match(/<polygon/g)).toBeNull();
  333. });
  334. it('gives the cap room for the lines it really wraps to', () => {
  335. const long = boundary({
  336. sites: [
  337. {
  338. form: 'computed-call',
  339. label: 'reflective invoke through a registry of handlers',
  340. snippet: 'x',
  341. line: 61,
  342. key: null,
  343. keyIsType: false,
  344. moreSites: 3,
  345. candidates: [],
  346. candidateNote: 'the key is too generic to shortlist against',
  347. },
  348. ],
  349. });
  350. const rows = capRows({
  351. id: 'cap:x',
  352. anchorId: 'x',
  353. boundary: long,
  354. x: 0,
  355. y: 0,
  356. width: 240,
  357. height: 10,
  358. flows: ['f'],
  359. });
  360. // Every row is inside the cap's own text column…
  361. for (const row of rows.rows) expect(row.text.length).toBeLessThanOrEqual(32);
  362. // …and the height accounts for all of them.
  363. expect(rows.height).toBeGreaterThan(rows.rows.length * 15);
  364. });
  365. it('dims the paths that are not the picked one when several are drawn', () => {
  366. const both = [flow('a', ['start', 'left', 'end']), flow('b', ['start', 'right', 'end'])];
  367. const svg = flowSvg(buildFlowLayout(both, 'a'), { activeFlowId: 'a', showAll: true });
  368. expect(svg).toContain('opacity="0.4"');
  369. // The picked path keeps the accent border; the other does not.
  370. expect(svg).toContain(`stroke="${EXPORT_COLORS.accent}"`);
  371. expect(svg).toContain('>right</text>');
  372. });
  373. it('writes the caption next to the mark', () => {
  374. const svg = flowSvg(layout, { caption: 'execute → rowToFileRecord · 3 hops' });
  375. expect(svg).toContain('execute → rowToFileRecord · 3 hops');
  376. assertWellFormed(svg);
  377. });
  378. });
  379. /* -------------------------------------------------------------------- map -- */
  380. describe('mapSvg', () => {
  381. const payload = {
  382. modules: [
  383. mod('src/bin'),
  384. mod('src/mcp'),
  385. mod('src/db', { symbols: 1218, files: 54 }),
  386. mod('__tests__', { test: true }),
  387. ],
  388. links: [
  389. link('src/bin', 'src/mcp', 30),
  390. link('src/mcp', 'src/db', 22),
  391. link('src/bin', 'src/db', 2),
  392. link('__tests__', 'src/db', 40),
  393. ],
  394. };
  395. const layout = buildMapLayout(payload, { includeTests: false });
  396. it('is well-formed, light, and marked', () => {
  397. const svg = mapSvg(layout);
  398. assertWellFormed(svg);
  399. expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
  400. expect(svg).toContain(`>${MARK_TEXT}</text>`);
  401. });
  402. it('draws every module box with its name and its counts', () => {
  403. const svg = mapSvg(layout);
  404. expect(svg).toContain('>src/bin</text>');
  405. expect(svg).toContain('>src/db</text>');
  406. expect(svg).toContain('>1218 symbols · 54 files</text>');
  407. // Tests were filtered out of the layout, so they are not in the image.
  408. expect(svg).not.toContain('>__tests__</text>');
  409. });
  410. it('exports the weight bar the canvas draws, scaled the same way', () => {
  411. const weighted = buildMapLayout(
  412. {
  413. modules: [
  414. mod('src/types', { dependents: { files: 80, modules: 4 } }),
  415. mod('src/db', { dependents: { files: 20, modules: 2 } }),
  416. mod('src/bin'),
  417. ],
  418. links: [link('src/db', 'src/types', 30), link('src/bin', 'src/db', 30)],
  419. },
  420. { includeTests: false }
  421. );
  422. const svg = mapSvg(weighted);
  423. const nodeOf = (id: string) => weighted.nodes.find((n) => n.id === id)!;
  424. // Full bar for the heaviest, a quarter for the module a quarter as leaned
  425. // on, and NO rect at all for the one nothing depends on.
  426. // The export rounds to a tenth, as every coordinate in this file does.
  427. const tenth = (n: number) => Math.round(n * 10) / 10;
  428. const full = nodeOf('src/types');
  429. const quarter = nodeOf('src/db');
  430. expect(quarter.weight).toBeCloseTo(0.25, 5);
  431. expect(svg).toContain(`width="${tenth(full.width)}" height="4" fill="${EXPORT_COLORS.ink}"`);
  432. expect(svg).toContain(
  433. `width="${tenth(quarter.width * 0.25)}" height="4" fill="${EXPORT_COLORS.ink}"`
  434. );
  435. expect(nodeOf('src/bin').weight).toBe(0);
  436. expect(svg.match(/height="4" fill=/g)?.length).toBe(2);
  437. // …and the count rides in the meta line, as on screen.
  438. expect(svg).toContain('· 80 depend on it</text>');
  439. });
  440. it('names the top and bottom bands', () => {
  441. const svg = mapSvg(layout);
  442. expect(svg).toContain('>entry points</text>');
  443. expect(svg).toContain('>foundations — depend on nothing below</text>');
  444. });
  445. it('hides the same thin links the canvas hides', () => {
  446. const svg = mapSvg(layout);
  447. // src/bin → src/db carries 2, under MIN_WEIGHT: one path per visible link
  448. // plus one per layer rule is not a count worth asserting, so check the
  449. // stroke widths instead — a hidden link contributes none.
  450. const drawn = svg.match(/<path /g)?.length ?? 0;
  451. expect(drawn).toBe(layout.edges.filter((e) => !e.thin && !e.back).length);
  452. });
  453. it('brings a selected module’s thin links out, as the canvas does', () => {
  454. const svg = mapSvg(layout, { selected: 'src/bin' });
  455. const drawn = svg.match(/<path /g)?.length ?? 0;
  456. expect(drawn).toBe(
  457. layout.edges.filter((e) => e.source === 'src/bin' || e.target === 'src/bin').length
  458. );
  459. });
  460. it('dims a module the selection does not touch, and only that one', () => {
  461. // src/bin reaches both other modules, so a fixture needs a fourth module
  462. // standing apart before dimming has anything to say.
  463. const apart = buildMapLayout(
  464. { modules: [...payload.modules, mod('site')], links: payload.links },
  465. { includeTests: false }
  466. );
  467. const svg = mapSvg(apart, { selected: 'src/bin' });
  468. // Exactly one box goes grey: its rule and its two lines of text.
  469. expect(svg.split(`stroke="${EXPORT_COLORS.ink4}"`).length - 1).toBe(1);
  470. expect(svg.split(`fill="${EXPORT_COLORS.ink4}"`).length - 1).toBe(2);
  471. });
  472. it('scales the root only', () => {
  473. const one = mapSvg(layout, { scale: 1 });
  474. const two = mapSvg(layout, { scale: 2 });
  475. expect(viewBox(two)).toEqual(viewBox(one));
  476. expect(rootSize(two).width).toBe(rootSize(one).width * 2);
  477. });
  478. it('keeps every drawn coordinate inside the canvas', () => {
  479. const svg = mapSvg(layout);
  480. const box = viewBox(svg);
  481. for (const { x, y } of coords(svg)) {
  482. expect(x).toBeGreaterThanOrEqual(-1);
  483. expect(y).toBeGreaterThanOrEqual(-1);
  484. expect(x).toBeLessThanOrEqual(box.width + 1);
  485. expect(y).toBeLessThanOrEqual(box.height + 1);
  486. }
  487. // Layer rules are the one thing that spans the whole picture, and the one
  488. // that used to run off the right-hand edge: they follow the boxes, not the
  489. // canvas' own padded width.
  490. const rules = [...svg.matchAll(/x1="(-?[\d.]+)"[^>]*x2="(-?[\d.]+)"/g)];
  491. expect(rules.length).toBeGreaterThan(0);
  492. for (const [, x1, x2] of rules) {
  493. expect(Number(x1)).toBeGreaterThanOrEqual(0);
  494. expect(Number(x2)).toBeLessThanOrEqual(box.width);
  495. }
  496. });
  497. it('marks a test module dashed when it is included', () => {
  498. const withTests = buildMapLayout(payload, { includeTests: true });
  499. const svg = mapSvg(withTests);
  500. expect(svg).toContain('>__tests__</text>');
  501. expect(svg).toContain('stroke-dasharray="4 3"');
  502. });
  503. it('survives a module id that needs escaping', () => {
  504. const odd = buildMapLayout(
  505. { modules: [mod('src/<odd> & co'), mod('src/db')], links: [link('src/<odd> & co', 'src/db', 9)] },
  506. { includeTests: false }
  507. );
  508. const svg = mapSvg(odd);
  509. assertWellFormed(svg);
  510. expect(svg).toContain('&lt;odd&gt; &amp; co');
  511. });
  512. });