|
1 | | -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; |
| 1 | +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; |
| 2 | +import { JSDOM } from 'jsdom'; |
2 | 3 | import { |
3 | 4 | exposeGraphDots, |
4 | 5 | withEdgeFontColor, |
@@ -482,6 +483,250 @@ describe('withEdgeFontColor (vizdom bakes label color at parse time)', () => { |
482 | 483 | }); |
483 | 484 | }); |
484 | 485 |
|
| 486 | +describe('edge font color boundaries', () => { |
| 487 | + it.each([undefined, null, ''])('leaves DOT unchanged when color is %p', color => { |
| 488 | + const dot = 'digraph G { A -> B [label="1"]; }'; |
| 489 | + expect(withEdgeFontColor(dot, color)).toBe(dot); |
| 490 | + }); |
| 491 | + |
| 492 | + it.each([null, 0, false, {}])('preserves non-string input %p by identity', dot => { |
| 493 | + expect(withEdgeFontColor(dot, '#9fb0c0')).toBe(dot); |
| 494 | + }); |
| 495 | + |
| 496 | + it('does not recolor a node following an edge without attributes', () => { |
| 497 | + const dot = 'digraph G { A -> B; C [label="Node"]; D -> E [label="2"]; }'; |
| 498 | + expect(withEdgeFontColor(dot, '#9fb0c0')).toBe( |
| 499 | + 'digraph G { A -> B; C [label="Node"]; D -> E [label="2" fontcolor = "#9fb0c0"]; }'); |
| 500 | + }); |
| 501 | + |
| 502 | + it('preserves explicit colors while theming other edges and is idempotent', () => { |
| 503 | + const dot = 'A -> B [fontcolor="blue" label="1"]; B -> C [label="2"];'; |
| 504 | + const expected = 'A -> B [fontcolor="blue" label="1"]; B -> C [label="2" fontcolor = "#9fb0c0"];'; |
| 505 | + const themed = withEdgeFontColor(dot, '#9fb0c0'); |
| 506 | + expect(themed).toBe(expected); |
| 507 | + expect(withEdgeFontColor(themed, '#9fb0c0')).toBe(expected); |
| 508 | + }); |
| 509 | + |
| 510 | + it('handles quoted node IDs, ports, and multiline edge attributes', () => { |
| 511 | + const dot = '"org.example.A":out -> "org.example.B":in [\n label="3"\n color="red"\n];'; |
| 512 | + expect(withEdgeFontColor(dot, '#9fb0c0')).toBe( |
| 513 | + '"org.example.A":out -> "org.example.B":in [\n label="3"\n color="red" fontcolor = "#9fb0c0"\n];'); |
| 514 | + }); |
| 515 | +}); |
| 516 | + |
| 517 | +// Each case owns its document so event subscriptions do not accumulate and |
| 518 | +// exact redraw counts can catch accidental duplicate binding. |
| 519 | +describe('theme redraw regressions', () => { |
| 520 | + let previousWindow; |
| 521 | + let previousDocument; |
| 522 | + let dom; |
| 523 | + let media; |
| 524 | + let mediaListener; |
| 525 | + let parsed; |
| 526 | + let zoomed; |
| 527 | + let rejectDot; |
| 528 | + const classDot = 'digraph C { A -> B [label="1"]; }'; |
| 529 | + const packageDot = 'digraph P { P -> Q [label="2"]; }'; |
| 530 | + const cycleDot = 'digraph Z { X -> Y [label="3" color="red"]; }'; |
| 531 | + const data = { |
| 532 | + classMap: { dot: classDot }, |
| 533 | + packageMap: { dot: packageDot, hasEdges: true }, |
| 534 | + classCycles: { largestCycle: { cycleIdentifier: 'cycle_0', dot: cycleDot } } |
| 535 | + }; |
| 536 | + |
| 537 | + beforeEach(() => { |
| 538 | + previousWindow = globalThis.window; |
| 539 | + previousDocument = globalThis.document; |
| 540 | + dom = new JSDOM('<!doctype html><html><body></body></html>'); |
| 541 | + globalThis.window = dom.window; |
| 542 | + globalThis.document = dom.window.document; |
| 543 | + installThemeRadios('system'); |
| 544 | + mediaListener = null; |
| 545 | + media = { |
| 546 | + matches: false, |
| 547 | + addEventListener(type, listener) { |
| 548 | + expect(type).toBe('change'); |
| 549 | + mediaListener = listener; |
| 550 | + } |
| 551 | + }; |
| 552 | + window.matchMedia = () => media; |
| 553 | + parsed = []; |
| 554 | + zoomed = []; |
| 555 | + rejectDot = () => false; |
| 556 | + window.Vizdom = { |
| 557 | + DotParser: class { |
| 558 | + parse(dot) { |
| 559 | + parsed.push(dot); |
| 560 | + if (rejectDot(dot)) throw new Error('invalid graph'); |
| 561 | + return { |
| 562 | + to_directed() { return this; }, |
| 563 | + layout() { return this; }, |
| 564 | + to_svg() { return { to_string: () => '<svg width="1"></svg>' }; } |
| 565 | + }; |
| 566 | + } |
| 567 | + } |
| 568 | + }; |
| 569 | + window.svgPanZoom = (selector, options) => { |
| 570 | + zoomed.push({ svg: document.querySelector(selector), options }); |
| 571 | + }; |
| 572 | + bindThemeChartRedraw(); |
| 573 | + mediaListener(); // Prune entries from earlier tests before reusing graph IDs. |
| 574 | + document.body.insertAdjacentHTML('beforeend', |
| 575 | + '<div id="classGraph"></div><div id="packageGraph"></div><div id="cycle_0"></div>'); |
| 576 | + }); |
| 577 | + |
| 578 | + afterEach(() => { |
| 579 | + // Prune this case's graph entries before restoring the shared document. |
| 580 | + document.body.innerHTML = ''; |
| 581 | + rejectDot = () => false; |
| 582 | + mediaListener(); |
| 583 | + destroyBubbleCharts(); |
| 584 | + dom.window.close(); |
| 585 | + globalThis.window = previousWindow; |
| 586 | + globalThis.document = previousDocument; |
| 587 | + }); |
| 588 | + |
| 589 | + function selectTheme(mode) { |
| 590 | + const radio = document.getElementById(`rf-theme-${mode}`); |
| 591 | + radio.checked = true; |
| 592 | + radio.dispatchEvent(new window.Event('change', { bubbles: true })); |
| 593 | + } |
| 594 | + |
| 595 | + it('recolors every graph on OS changes and reattaches pan/zoom to fresh SVGs', async () => { |
| 596 | + await initWasmGraphs(data); |
| 597 | + expect(parsed).toEqual([classDot, packageDot, cycleDot]); |
| 598 | + const originalSvgs = zoomed.map(entry => entry.svg); |
| 599 | + parsed.length = 0; |
| 600 | + zoomed.length = 0; |
| 601 | + media.matches = true; |
| 602 | + mediaListener(); |
| 603 | + expect(parsed).toEqual([ |
| 604 | + 'digraph C { A -> B [label="1" fontcolor = "#9fb0c0"]; }', |
| 605 | + 'digraph P { P -> Q [label="2" fontcolor = "#9fb0c0"]; }', |
| 606 | + 'digraph Z { X -> Y [label="3" color="red" fontcolor = "#9fb0c0"]; }' |
| 607 | + ]); |
| 608 | + expect(zoomed).toHaveLength(3); |
| 609 | + zoomed.forEach(({ svg, options }, i) => { |
| 610 | + expect(svg).not.toBeNull(); |
| 611 | + expect(svg).not.toBe(originalSvgs[i]); |
| 612 | + expect(svg.classList.contains('fullscreen-svg')).toBe(true); |
| 613 | + expect(options).toEqual({ zoomEnabled: true, controlIconsEnabled: true }); |
| 614 | + }); |
| 615 | + parsed.length = 0; |
| 616 | + media.matches = false; |
| 617 | + mediaListener(); |
| 618 | + expect(parsed).toEqual([classDot, packageDot, cycleDot]); |
| 619 | + }); |
| 620 | + |
| 621 | + it.each(['light', 'dark'])('honors explicit %s mode when the OS preference changes', async mode => { |
| 622 | + selectTheme(mode); |
| 623 | + await initWasmGraphs({ classMap: data.classMap }); |
| 624 | + const expected = mode === 'light' ? classDot |
| 625 | + : 'digraph C { A -> B [label="1" fontcolor = "#9fb0c0"]; }'; |
| 626 | + expect(parsed).toEqual([expected]); |
| 627 | + parsed.length = 0; |
| 628 | + media.matches = true; |
| 629 | + mediaListener(); |
| 630 | + media.matches = false; |
| 631 | + mediaListener(); |
| 632 | + expect(parsed).toEqual([expected, expected]); |
| 633 | + }); |
| 634 | + |
| 635 | + it('binds graph redraw only once and ignores unrelated form changes', async () => { |
| 636 | + await initWasmGraphs({ classMap: data.classMap }); |
| 637 | + bindThemeChartRedraw(); |
| 638 | + parsed.length = 0; |
| 639 | + const unrelated = document.createElement('input'); |
| 640 | + unrelated.name = 'table-filter'; |
| 641 | + document.body.appendChild(unrelated); |
| 642 | + unrelated.dispatchEvent(new window.Event('change', { bubbles: true })); |
| 643 | + expect(parsed).toEqual([]); |
| 644 | + selectTheme('dark'); |
| 645 | + expect(parsed).toEqual(['digraph C { A -> B [label="1" fontcolor = "#9fb0c0"]; }']); |
| 646 | + }); |
| 647 | + |
| 648 | + it('uses the latest report DOT when a container is reused', async () => { |
| 649 | + await initWasmGraphs({ classMap: data.classMap }); |
| 650 | + const replacement = 'digraph New { C -> D [label="9"]; }'; |
| 651 | + await initWasmGraphs({ classMap: { dot: replacement } }); |
| 652 | + parsed.length = 0; |
| 653 | + selectTheme('dark'); |
| 654 | + selectTheme('light'); |
| 655 | + expect(parsed).toEqual([ |
| 656 | + 'digraph New { C -> D [label="9" fontcolor = "#9fb0c0"]; }', replacement |
| 657 | + ]); |
| 658 | + }); |
| 659 | + |
| 660 | + it('continues redrawing healthy graphs when another graph cannot be parsed', async () => { |
| 661 | + await initWasmGraphs(data); |
| 662 | + const previousClassSvg = document.querySelector('#classGraph svg'); |
| 663 | + const previousPackageSvg = document.querySelector('#packageGraph svg'); |
| 664 | + parsed.length = 0; |
| 665 | + rejectDot = dot => dot.startsWith('digraph C'); |
| 666 | + const warning = spyOn(console, 'warn').mockImplementation(() => {}); |
| 667 | + try { |
| 668 | + selectTheme('dark'); |
| 669 | + expect(warning).toHaveBeenCalledTimes(1); |
| 670 | + expect(warning.mock.calls[0][0]).toBe('WASM graph re-render failed:'); |
| 671 | + expect(parsed).toHaveLength(3); |
| 672 | + expect(document.querySelector('#classGraph svg')).toBe(previousClassSvg); |
| 673 | + expect(document.querySelector('#packageGraph svg')).not.toBe(previousPackageSvg); |
| 674 | + expect(parsed[2]).toContain('fontcolor = "#9fb0c0"'); |
| 675 | + } finally { |
| 676 | + warning.mockRestore(); |
| 677 | + } |
| 678 | + }); |
| 679 | + |
| 680 | + it('permanently prunes removed graphs until they are initialized again', async () => { |
| 681 | + await initWasmGraphs(data); |
| 682 | + document.getElementById('classGraph').remove(); |
| 683 | + mediaListener(); |
| 684 | + document.body.insertAdjacentHTML('beforeend', '<div id="classGraph"></div>'); |
| 685 | + parsed.length = 0; |
| 686 | + mediaListener(); |
| 687 | + expect(parsed).toEqual([packageDot, cycleDot]); |
| 688 | + expect(document.getElementById('classGraph').innerHTML).toBe(''); |
| 689 | + }); |
| 690 | + |
| 691 | + it('updates both axis colors on the existing chart without changing custom titles', () => { |
| 692 | + let config; |
| 693 | + const colors = []; |
| 694 | + window.Chart = function (_canvas, options) { |
| 695 | + config = options; |
| 696 | + return { update() { |
| 697 | + colors.push(['x', 'y'].map(axis => config.options.scales[axis].title.color())); |
| 698 | + } }; |
| 699 | + }; |
| 700 | + initBubbleChart(document.getElementById('chart_GOD'), 'Custom report', { |
| 701 | + xaxisLabel: 'Complexity (Δ)', yaxisLabel: 'Churn (%)', bubbles: [] |
| 702 | + }); |
| 703 | + selectTheme('dark'); |
| 704 | + selectTheme('light'); |
| 705 | + expect(colors).toHaveLength(2); |
| 706 | + for (const color of colors[0]) { |
| 707 | + expect(contrastAgainst(color, DARK_BG)).toBeGreaterThanOrEqual(4.5); |
| 708 | + } |
| 709 | + for (const color of colors[1]) { |
| 710 | + expect(contrastAgainst(color, LIGHT_BG)).toBeGreaterThanOrEqual(4.5); |
| 711 | + } |
| 712 | + expect(colors[0][0]).not.toBe(colors[1][0]); |
| 713 | + expect(config.options.scales.x.title.text).toBe(`Complexity (Δ) ${LONG_ARROW}`); |
| 714 | + expect(config.options.scales.y.title.text).toBe(`Churn (%) ${LONG_ARROW}`); |
| 715 | + expect(config.options.scales.x.title.display).toBe(true); |
| 716 | + expect(config.options.scales.y.title.display).toBe(true); |
| 717 | + }); |
| 718 | + |
| 719 | + it('uses default titles for empty and null axis labels', () => { |
| 720 | + let config; |
| 721 | + window.Chart = function (_canvas, options) { config = options; }; |
| 722 | + initBubbleChart(document.getElementById('chart_GOD'), 'Empty labels', { |
| 723 | + xaxisLabel: '', yaxisLabel: null, bubbles: [] |
| 724 | + }); |
| 725 | + expect(config.options.scales.x.title.text).toBe(`Effort to refactor ${LONG_ARROW}`); |
| 726 | + expect(config.options.scales.y.title.text).toBe(`Relative churn (impact) ${LONG_ARROW}`); |
| 727 | + }); |
| 728 | +}); |
| 729 | + |
485 | 730 | describe('initWasmGraphs', () => { |
486 | 731 | // Minimal DotParser stub: records the parsed dot and yields a fixed SVG. |
487 | 732 | function stubVizdom() { |
|
0 commit comments