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;
});