diff --git a/AGENTS.md b/AGENTS.md index 303480d..1ac8491 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,11 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket). of the breadcrumbs' left-edge alignment) — palettes selected in `app/globals.css` via `:root:has(#rf-theme-…:checked)` + `prefers-color-scheme`; an inline pre-paint script in `app/layout.jsx` - only restores/persists the choice (CSP-hashed by the build). Guards: + only restores/persists the choice (CSP-hashed by the build). Chart + legends are canvas-drawn, so they can't follow the CSS switch: + `resolveLegendTextColor`/`bindThemeChartRedraw` in `lib/report-view.js` + resolve a per-theme legend color (>= 4.5:1 on each `--bg-color`) and + redraw live charts on radio/`prefers-color-scheme` changes. Guards: `tests/unit/theme-css.test.js` (the two dark blocks must stay byte-identical; all hex colors live in the palette blocks; dark palette passes the same WCAG AA math as `tests/unit/css-a11y.test.js`) diff --git a/components/report-view.jsx b/components/report-view.jsx index c982187..07c1b5b 100644 --- a/components/report-view.jsx +++ b/components/report-view.jsx @@ -20,7 +20,8 @@ import { enhanceReport, bindPopupHandlers, stashStatefulDom, - graftStatefulDom + graftStatefulDom, + destroyBubbleCharts } from '../lib/report-view'; import { enhanceTables } from '../lib/table-enhancer'; import { renderErrorPage, logError } from '../lib/error-handler'; @@ -101,6 +102,12 @@ export default function ReportView({ const controller = new AbortController(); const container = containerRef.current; + // Every fetch cycle replaces the report markup — loading placeholder, + // then the fresh render — so release the previous cycle's Chart.js + // instances before any of that happens; enhanceReport builds new charts + // for the new canvases once the payload arrives. + destroyBubbleCharts(); + /** Fetches the trusted template and the selected repository report. */ async function run() { setPayload(null); @@ -141,7 +148,13 @@ export default function ReportView({ } run(); - return () => controller.abort(); + return () => { + controller.abort(); + // Unmounting (or a dependency change) throws the report's canvases + // away with the markup; release their charts alongside the aborted + // request instead of leaking live instances on dead canvases. + destroyBubbleCharts(); + }; }, [username, repository, branch, environmentProp, platformBaseUrlProp, attempt]); // Render effect: re-renders the report whenever the payload arrives or the diff --git a/lib/report-view.js b/lib/report-view.js index fe5afc8..3820583 100644 --- a/lib/report-view.js +++ b/lib/report-view.js @@ -367,13 +367,137 @@ export function bindSectionNavLinks(root) { // Chart.js bubble charts per disharmony. // --------------------------------------------------------------------------- +// Legend text colors mirroring --muted-color of each palette in +// app/globals.css. The chart canvas is transparent, so legend text sits on +// --bg-color (#ffffff light / #10161d dark); each value keeps >= 4.5:1 +// contrast against its own background (WCAG 1.4.3), which no single color +// can achieve on both. +const CHART_LEGEND_TEXT = Object.freeze({ + light: '#5c6b7a', // 5.5:1 on #ffffff + dark: '#9fb0c0' // 8.2:1 on #10161d +}); + +/** + * Returns 'light' or 'dark' for the active palette, mirroring the pure-CSS + * selection in app/globals.css: the explicit radios win, the system radio + * defers to prefers-color-scheme, and anything else is light. + * + * @returns {'light'|'dark'} Active palette name. + */ +export function activeChartTheme() { + const doc = typeof document === 'undefined' ? null : document; + if (doc) { + const dark = doc.getElementById('rf-theme-dark'); + const light = doc.getElementById('rf-theme-light'); + if (dark && dark.checked) return 'dark'; + if (light && light.checked) return 'light'; + } + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + try { + if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark'; + } catch { + // Media query evaluation unavailable: fall through to light. + } + } + return 'light'; +} + +/** + * Legend text color for the currently active theme (>= 4.5:1 against the + * theme's --bg-color). Resolved at draw time so a redraw picks up theme + * switches. + * + * @returns {string} Hex color for legend item text. + */ +export function resolveLegendTextColor() { + return CHART_LEGEND_TEXT[activeChartTheme()] || CHART_LEGEND_TEXT.light; +} + +// Live Chart.js instances by canvas, so theme changes can redraw the +// canvas-drawn legend text (the palette switch itself is pure CSS). +const bubbleCharts = new Map(); + +/** + * Redraws every live bubble chart so legend text re-resolves against the + * newly active theme. Entries whose canvas left the DOM (report re-rendered + * or torn down) are destroyed — releasing their Chart.js resources — and + * pruned instead of redrawn. + * + * @returns {void} + */ +function redrawChartsForTheme() { + for (const [canvas, chart] of bubbleCharts) { + if (!canvas.isConnected) { + if (chart && typeof chart.destroy === 'function') chart.destroy(); + bubbleCharts.delete(canvas); + continue; + } + if (chart && typeof chart.update === 'function') chart.update(); + } +} + +/** + * Destroys every live bubble chart and clears the registry. The report + * component calls this before a payload change replaces the report markup + * (container.innerHTML), so no Chart.js instance outlives its removed + * canvas; enhanceReport then builds fresh charts for the new canvases. + * + * @returns {void} + */ +export function destroyBubbleCharts() { + for (const chart of bubbleCharts.values()) { + if (chart && typeof chart.destroy === 'function') chart.destroy(); + } + bubbleCharts.clear(); +} + +// The MediaQueryList for prefers-color-scheme, retained so the listener +// (and the redraw callback it holds) stays reachable for the page lifetime. +let systemThemeMediaQuery = null; + +/** + * Binds one delegated listener redrawing charts on theme changes: the + * rf-theme radios (components/theme-toggle.jsx) for explicit switches and + * prefers-color-scheme for the system radio. Idempotent per document. + * + * @returns {void} + */ +export function bindThemeChartRedraw() { + const doc = typeof document === 'undefined' ? null : document; + if (!doc || doc.documentElement.hasAttribute('data-rf-theme-redraw-bound')) return; + doc.documentElement.setAttribute('data-rf-theme-redraw-bound', ''); + doc.addEventListener('change', event => { + const target = event.target; + if (target && target.name === 'rf-theme') redrawChartsForTheme(); + }); + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + try { + // Retain the MediaQueryList and subscribe with the best listener API + // it exposes: addEventListener, or the deprecated addListener on + // engines that never grew the modern one. + systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + if (systemThemeMediaQuery && typeof systemThemeMediaQuery.addEventListener === 'function') { + systemThemeMediaQuery.addEventListener('change', redrawChartsForTheme); + } else if (systemThemeMediaQuery && typeof systemThemeMediaQuery.addListener === 'function') { + systemThemeMediaQuery.addListener(redrawChartsForTheme); + } + } catch { + // Older engines without MQL listeners: explicit radios still redraw. + } + } +} + export function initBubbleChart(canvas, title, chartData) { const ChartLib = window.Chart; if (!ChartLib) { console.warn('Chart.js not loaded'); return; } - new ChartLib(canvas.getContext('2d'), { + // Chart.js refuses a canvas that still backs a live instance; drop the + // previous chart (and its registry entry) before re-initializing. + const previous = bubbleCharts.get(canvas); + if (previous && typeof previous.destroy === 'function') previous.destroy(); + const chart = new ChartLib(canvas.getContext('2d'), { type: 'bubble', data: { datasets: [{ @@ -396,11 +520,20 @@ export function initBubbleChart(canvas, title, chartData) { padding: 15, boxWidth: 20, boxHeight: 20, + // Chart.js renders each legend item's text from + // item.fontColor (plugin.legend.js); labels.color only + // feeds the DEFAULT generateLabels, which this chart + // overrides below. The color is resolved per draw so + // the theme-change redraw (bindThemeChartRedraw) + // re-syncs it; each palette's value keeps >= 4.5:1 + // against its --bg-color (#ffffff light / #10161d + // dark), which no single color can do for both. generateLabels: () => ['High Priority (1)', 'Medium Priority', 'Low Priority (max)'].map((label, i) => ({ text: label, fillStyle: ['rgb(235, 64, 52)', 'rgb(137, 119, 74)', 'rgb(39, 174, 96)'][i], strokeStyle: ['rgb(235, 64, 52)', 'rgb(137, 119, 74)', 'rgb(39, 174, 96)'][i], + fontColor: resolveLegendTextColor(), lineWidth: 1, hidden: false, index: i @@ -433,6 +566,7 @@ export function initBubbleChart(canvas, title, chartData) { } } }); + bubbleCharts.set(canvas, chart); } export function initDisharmonyCharts(disharmonies) { @@ -587,6 +721,9 @@ export async function enhanceReport(root, data) { if (event.key === 'Escape') hidePopup(); }); } + // Theme switches are pure CSS, but chart legends are canvas-drawn: bind + // their redraw once so the legend color keeps >= 4.5:1 in every palette. + bindThemeChartRedraw(); bindPopupHandlers(root); // Menu links: explicit scrolling because the report renders too late for diff --git a/tests/integration/report-view.test.jsx b/tests/integration/report-view.test.jsx index f5d9fb9..4bf543b 100644 --- a/tests/integration/report-view.test.jsx +++ b/tests/integration/report-view.test.jsx @@ -693,6 +693,122 @@ describe('enhanced report tables: stateful widgets survive interactions', () => expect(document.getElementById('overlay').style.display).toBe('block'); hideAllPopups(); }); + + test('a payload change destroys the previous charts before the loading markup and re-creates them', async () => { + // Every Chart.js construction/teardown, in order, so the test can pin + // that the old instances are released before the fetch effect clears + // the payload (loading placeholder) and before the replacement render + // builds fresh charts on the new canvases. + const events = []; + const instances = []; + window.Chart = function () { + const chart = { + destroyed: false, + update() {}, + destroy() { + chart.destroyed = true; + const canvas = document.querySelector('canvas#chart_GOD'); + events.push(canvas && canvas.isConnected ? 'destroy:attached' : 'destroy:detached'); + } + }; + instances.push(chart); + events.push('create'); + return chart; + }; + markWidgetReady('chart'); + respondJsonFor(sampleJson); + const utils = await renderReport(); + const firstCanvas = utils.container.querySelector('canvas#chart_GOD'); + const firstRenderCreates = instances.length; + expect(firstRenderCreates).toBeGreaterThan(0); + + // Switching branches re-runs the fetch effect: it must release the old + // Chart.js instances (they would otherwise outlive their removed + // canvases) before the loading placeholder replaces the report markup, + // and the fresh render then builds new charts for the new canvases. + utils.rerender(_jsx(ReportView, { + username: 'junit-team', + repository: 'junit4', + branch: 'develop', + widgetSettleMs: 25 + })); + // The rerender passes through loading/graft phases that reuse or drop + // the old canvas node; the destroys happen up front at the fetch + // cycle's start, so wait on them and then on the fresh render. + await waitFor(() => { + expect(events.filter(e => e !== 'create').length).toBe(firstRenderCreates); + }); + await waitFor(() => { + expect(instances.length).toBe(2 * firstRenderCreates); + }); + expect(utils.container.querySelector('canvas#chart_GOD')).not.toBe(firstCanvas); + + expect(instances.slice(0, firstRenderCreates).every(c => c.destroyed)).toBe(true); + // Destroyed while the canvases were still attached: before the loading + // markup swap, not after the report markup was thrown away. + const destroys = events.filter(e => e !== 'create'); + expect(destroys.every(e => e === 'destroy:attached')).toBe(true); + expect(events).not.toContain('destroy:detached'); + // ...and all destroys precede every replacement construction. + expect(events.findIndex(e => e !== 'create')).toBeLessThan(events.lastIndexOf('create')); + // enhanceReport still builds charts for the replacement canvases. + expect(instances.length).toBe(2 * firstRenderCreates); + }, SLOW_TEST_MS); + + test('unmounting the report destroys its live charts', async () => { + const instances = []; + window.Chart = function () { + const chart = { + destroyed: false, + update() {}, + destroy() { chart.destroyed = true; } + }; + instances.push(chart); + return chart; + }; + markWidgetReady('chart'); + respondJsonFor(sampleJson); + const utils = await renderReport(); + expect(instances.length).toBeGreaterThan(0); + expect(instances.every(c => c.destroyed)).toBe(false); + + // The fetch effect's cleanup aborts the in-flight request; it must + // release the report's Chart.js instances as well, since the canvases + // go away with the unmounted container. + utils.unmount(); + expect(instances.every(c => c.destroyed)).toBe(true); + }); + + test('a refetch destroys the previous charts up front, even if it never delivers a payload', async () => { + const instances = []; + window.Chart = function () { + const chart = { + destroyed: false, + update() {}, + destroy() { chart.destroyed = true; } + }; + instances.push(chart); + return chart; + }; + markWidgetReady('chart'); + respondJsonFor(sampleJson); + const utils = await renderReport(); + expect(instances.length).toBeGreaterThan(0); + expect(instances.every(c => c.destroyed)).toBe(false); + + // A branch switch whose fetch never resolves: the old Chart.js + // instances must be released when the fetch cycle starts — before the + // payload is cleared and the loading markup replaces the report — not + // wait for a replacement payload that may never arrive. + mockFetch.mockImplementation(() => new Promise(() => {})); + utils.rerender(_jsx(ReportView, { + username: 'junit-team', + repository: 'junit4', + branch: 'develop', + widgetSettleMs: 25 + })); + expect(instances.every(c => c.destroyed)).toBe(true); + }); }); async function hideAllPopups() { diff --git a/tests/unit/report-view.test.js b/tests/unit/report-view.test.js index 5e2ab3d..23bfc88 100644 --- a/tests/unit/report-view.test.js +++ b/tests/unit/report-view.test.js @@ -10,11 +10,53 @@ import { bindSectionNavLinks, scrollToSectionHash, enhanceReport, + resolveLegendTextColor, + bindThemeChartRedraw, + destroyBubbleCharts, statefulElementIds, stashStatefulDom, graftStatefulDom } from '../../lib/report-view.js'; +// Theme radios as rendered by components/theme-toggle.jsx in app/layout.jsx. +// The palette switch itself is pure CSS; these tests only pin what the +// canvas-drawn chart legend resolves from the radio state. +function installThemeRadios(mode, osPrefersDark) { + document.body.innerHTML = ` + + + + `; + if (osPrefersDark === undefined) { + delete window.matchMedia; // jsdom default: no media queries at all + } else { + window.matchMedia = () => ({ matches: osPrefersDark, addEventListener() {} }); + } +} + +// WCAG relative luminance + contrast ratio (www.w3.org/TR/WCAG22/#dfn-contrast-ratio). +function relLuminance(r, g, b) { + const lin = c => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +function contrastAgainst(hex, background) { + const channels = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || ''); + if (!channels) return 0; + const [r, g, b] = [1, 2, 3].map(i => parseInt(channels[i], 16)); + const fg = relLuminance(r, g, b); + const bg = relLuminance(...background); + return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); +} + +// --bg-color per palette in app/globals.css: the transparent chart canvas +// sits directly on the page background. +const LIGHT_BG = [0xff, 0xff, 0xff]; +const DARK_BG = [0x10, 0x16, 0x1d]; + describe('exposeGraphDots', () => { it('exposes class, package and cycle DOT strings as window globals', () => { exposeGraphDots({ @@ -62,6 +104,281 @@ describe('initBubbleChart', () => { expect(config.options.scales.x.title.text).toBe('Effort to refactor'); expect(config.options.scales.y.title.text).toBe('Relative churn (impact)'); }); + + it('renders legend labels in a theme-resolved color with >= 4.5:1 contrast', () => { + for (const [mode, background] of [['light', LIGHT_BG], ['dark', DARK_BG]]) { + installThemeRadios(mode); + const created = []; + window.Chart = function (ctx, config) { created.push(config); }; + const canvas = document.getElementById('chart_GOD'); + initBubbleChart(canvas, 'God Classes', { bubbles: [] }); + const items = created[0].options.plugins.legend.labels.generateLabels(); + expect(items.length).toBeGreaterThan(0); + + // Chart.js draws each legend item's text from item.fontColor — with a + // custom generateLabels, options.plugins.legend.labels.color is ignored. + const legendColor = items[0].fontColor; + items.forEach(item => expect(item.fontColor).toBe(legendColor)); + expect(legendColor).toBe(resolveLegendTextColor()); + expect(contrastAgainst(legendColor, background)).toBeGreaterThanOrEqual(4.5); + } + delete window.Chart; + }); +}); + +describe('resolveLegendTextColor', () => { + afterEach(() => { delete window.matchMedia; }); + + it('resolves a color that passes 4.5:1 on the light background', () => { + installThemeRadios('light'); + expect(contrastAgainst(resolveLegendTextColor(), LIGHT_BG)).toBeGreaterThanOrEqual(4.5); + }); + + it('resolves a color that passes 4.5:1 on the dark background', () => { + installThemeRadios('dark'); + expect(contrastAgainst(resolveLegendTextColor(), DARK_BG)).toBeGreaterThanOrEqual(4.5); + }); + + it('switches color per theme instead of one compromise gray', () => { + installThemeRadios('light'); + const light = resolveLegendTextColor(); + installThemeRadios('dark'); + const dark = resolveLegendTextColor(); + expect(light).not.toBe(dark); + }); + + it('follows prefers-color-scheme while the system radio is checked', () => { + installThemeRadios('system', true); + const osDark = resolveLegendTextColor(); + installThemeRadios('dark'); + expect(resolveLegendTextColor()).toBe(osDark); + + installThemeRadios('system', false); + const osLight = resolveLegendTextColor(); + installThemeRadios('light'); + expect(resolveLegendTextColor()).toBe(osLight); + }); + + it('falls back to the light color when media queries are unavailable', () => { + installThemeRadios('system'); // no window.matchMedia (jsdom default) + const color = resolveLegendTextColor(); + installThemeRadios('light'); + expect(resolveLegendTextColor()).toBe(color); + }); +}); + +describe('theme-change chart redraw', () => { + afterEach(() => { + delete window.Chart; + delete window.matchMedia; + }); + + // The document (and thus the idempotent binding guard) is shared across + // test files in a full run, so earlier files may already have bound the + // radio listener. Reset the guard to force a fresh binding here — that + // attaches the prefers-color-scheme listener this test captures. Later + // tests use relative counts so extra stacked listeners stay harmless. + it('redraws charts when the OS preference flips while system is checked', () => { + installThemeRadios('system', false); + let mediaListener = null; + window.matchMedia = () => ({ + matches: false, + addEventListener(type, listener) { mediaListener = listener; } + }); + document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); + bindThemeChartRedraw(); + expect(mediaListener).not.toBeNull(); + + const updates = []; + window.Chart = function () { return { update: () => updates.push('redraw') }; }; + initBubbleChart(document.getElementById('chart_GOD'), 'God Classes', { bubbles: [] }); + expect(updates.length).toBe(0); + mediaListener(); + expect(updates.length).toBeGreaterThan(0); + }); + + it('prefers addEventListener when the MediaQueryList supports both listener APIs', () => { + installThemeRadios('system'); + const bound = []; + window.matchMedia = () => ({ + matches: false, + addEventListener(type, listener) { bound.push(['modern', type, listener]); }, + addListener(listener) { bound.push(['legacy', listener]); } + }); + document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); + bindThemeChartRedraw(); + + expect(bound).toHaveLength(1); + expect(bound[0][0]).toBe('modern'); + expect(bound[0][1]).toBe('change'); + expect(bound[0][2]).toBeTypeOf('function'); + }); + + it('falls back to addListener when the MediaQueryList lacks addEventListener', () => { + installThemeRadios('system'); + let legacyListener = null; + window.matchMedia = () => ({ + matches: false, + addListener(listener) { legacyListener = listener; } + }); + document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); + bindThemeChartRedraw(); + expect(legacyListener).not.toBeNull(); + + // The legacy subscription must drive the same redraw callback. + const updates = []; + window.Chart = function () { return { update: () => updates.push('redraw') }; }; + initBubbleChart(document.getElementById('chart_GOD'), 'God Classes', { bubbles: [] }); + expect(updates.length).toBe(0); + legacyListener(); + expect(updates.length).toBeGreaterThan(0); + }); + + it('keeps working when the MediaQueryList exposes neither listener API', () => { + installThemeRadios('system'); + window.matchMedia = () => ({ matches: false }); + document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); + expect(() => bindThemeChartRedraw()).not.toThrow(); + }); + + it('redraws live charts when a theme radio changes', () => { + installThemeRadios('system'); + bindThemeChartRedraw(); // no-op: already bound above (and possibly earlier files) + const updates = []; + window.Chart = function () { return { update: () => updates.push('redraw') }; }; + initBubbleChart(document.getElementById('chart_GOD'), 'God Classes', { bubbles: [] }); + expect(updates.length).toBe(0); + + const dark = document.getElementById('rf-theme-dark'); + dark.checked = true; + dark.dispatchEvent(new window.Event('change', { bubbles: true })); + expect(updates.length).toBeGreaterThan(0); + }); + + it('replaces the previous chart when the same canvas is re-initialized', () => { + installThemeRadios('light'); + const instances = []; + window.Chart = function () { + const chart = { destroyed: false, update() {} }; + chart.destroy = () => { chart.destroyed = true; }; + instances.push(chart); + return chart; + }; + const canvas = document.getElementById('chart_GOD'); + initBubbleChart(canvas, 'God Classes', { bubbles: [] }); + initBubbleChart(canvas, 'God Classes', { bubbles: [] }); + expect(instances.length).toBe(2); + expect(instances[0].destroyed).toBe(true); + expect(instances[1].destroyed).toBe(false); + }); + + it('prunes charts whose canvas left the DOM and skips them on redraw', () => { + installThemeRadios('system'); + bindThemeChartRedraw(); + document.body.innerHTML = ` + + + + + `; + let lastChart = null; + window.Chart = function () { + const updates = []; + lastChart = { updates, update: () => updates.push('redraw') }; + return lastChart; + }; + initBubbleChart(document.getElementById('chart_GOD'), 'God Classes', { bubbles: [] }); + const god = lastChart; + initBubbleChart(document.getElementById('chart_BRAIN'), 'Brain Methods', { bubbles: [] }); + const brain = lastChart; + + const trigger = () => { + const radio = document.getElementById('rf-theme-light'); + radio.checked = true; + radio.dispatchEvent(new window.Event('change', { bubbles: true })); + }; + trigger(); + const godRedraws = god.updates.length; + const brainRedraws = brain.updates.length; + expect(godRedraws).toBeGreaterThan(0); + expect(brainRedraws).toBe(godRedraws); // both live: redrawn together + + document.getElementById('chart_BRAIN').remove(); + trigger(); + expect(god.updates.length).toBeGreaterThan(godRedraws); // still live + expect(brain.updates.length).toBe(brainRedraws); // stale entry pruned, not redrawn + }); + + it('destroys the chart of a canvas that left the DOM before pruning it', () => { + installThemeRadios('system'); + bindThemeChartRedraw(); + document.body.innerHTML = ` + + + + `; + const chart = { + destroyed: false, + update() {}, + destroy() { chart.destroyed = true; } + }; + window.Chart = function () { return chart; }; + initBubbleChart(document.getElementById('chart_GOD'), 'God Classes', { bubbles: [] }); + expect(chart.destroyed).toBe(false); + + // The re-render threw the canvas away: the next theme change must + // release the Chart.js instance, not merely drop the registry entry. + document.getElementById('chart_GOD').remove(); + const radio = document.getElementById('rf-theme-light'); + radio.checked = true; + radio.dispatchEvent(new window.Event('change', { bubbles: true })); + + expect(chart.destroyed).toBe(true); + }); +}); + +describe('destroyBubbleCharts', () => { + afterEach(() => { delete window.Chart; }); + + it('destroys every live chart and clears the registry', () => { + installThemeRadios('light'); + const instances = []; + window.Chart = function () { + const chart = { + destroyed: false, + update() {}, + destroy() { chart.destroyed = true; } + }; + instances.push(chart); + return chart; + }; + initBubbleChart(document.getElementById('chart_GOD'), 'God Classes', { bubbles: [] }); + const second = document.createElement('canvas'); + second.id = 'chart_BRAIN'; + document.body.appendChild(second); + initBubbleChart(second, 'Brain Methods', { bubbles: [] }); + expect(instances.length).toBe(2); + + destroyBubbleCharts(); + expect(instances.map(c => c.destroyed)).toEqual([true, true]); + + // Registry cleared: a theme change must not touch the destroyed charts. + const updates = []; + instances.forEach(c => (c.update = () => updates.push('redraw'))); + const dark = document.getElementById('rf-theme-dark'); + dark.checked = true; + dark.dispatchEvent(new window.Event('change', { bubbles: true })); + expect(updates.length).toBe(0); + }); + + it('tolerates chart stubs without a destroy method', () => { + installThemeRadios('light'); + window.Chart = function () { + return { update() {} }; // no destroy: must not throw + }; + initBubbleChart(document.getElementById('chart_GOD'), 'God Classes', { bubbles: [] }); + expect(() => destroyBubbleCharts()).not.toThrow(); + }); }); describe('initDisharmonyCharts', () => { @@ -451,6 +768,9 @@ describe('enhanceReport', () => { expect(typeof window.hidePopup).toBe('function'); expect(typeof window.createForceGraph).toBe('function'); expect(created.length).toBe(1); + // Charts must redraw when the theme radios change (legend contrast); + // binding is idempotent across enhanceReport runs. + expect(document.documentElement.hasAttribute('data-rf-theme-redraw-bound')).toBe(true); delete window.Chart; });