Skip to content

Commit e51a53d

Browse files
coderabbitai[bot]jimbethancourt
authored andcommitted
Add regression tests for report theming, table filters, and CSS accessibility
1 parent d2c13cd commit e51a53d

3 files changed

Lines changed: 425 additions & 1 deletion

File tree

‎tests/unit/report-view.test.js‎

Lines changed: 246 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
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';
23
import {
34
exposeGraphDots,
45
withEdgeFontColor,
@@ -482,6 +483,250 @@ describe('withEdgeFontColor (vizdom bakes label color at parse time)', () => {
482483
});
483484
});
484485

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+
485730
describe('initWasmGraphs', () => {
486731
// Minimal DotParser stub: records the parsed dot and yields a fixed SVG.
487732
function stubVizdom() {

‎tests/unit/table-enhancer.test.js‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,102 @@ describe('search injection', () => {
159159
});
160160
});
161161

162+
describe('filter clear regressions', () => {
163+
for (const method of ['click', 'Escape']) {
164+
it(`${method} clears immediately, resets the page, and requests focus restoration`, () => {
165+
setup({
166+
debounceMs: 30,
167+
tableStates: { 'class-relationships': {
168+
search: 'Class1', page: 3, sortKey: 'priority', sortDir: 'desc'
169+
} }
170+
});
171+
const input = root.querySelector('input[type="search"]');
172+
const clear = root.querySelector('.rf-search-clear');
173+
expect(clear.hidden).toBe(false);
174+
expect(clear.type).toBe('button');
175+
expect(clear.getAttribute('aria-label')).toBe('Clear the Class relationships to remove, in priority order table filter');
176+
if (method === 'click') {
177+
clear.click();
178+
} else {
179+
const event = new window.KeyboardEvent('keydown', {
180+
key: 'Escape', bubbles: true, cancelable: true
181+
});
182+
input.dispatchEvent(event);
183+
expect(event.defaultPrevented).toBe(true);
184+
}
185+
expect(input.value).toBe('');
186+
expect(clear.hidden).toBe(true);
187+
// The patch must preserve the caller's sort state when merged.
188+
expect(actions).toEqual([{
189+
id: 'class-relationships',
190+
patch: { search: '', page: 1 },
191+
meta: { restoreFocus: true }
192+
}]);
193+
});
194+
195+
it(`${method} prevents a pending debounced search from restoring the cleared term`, async () => {
196+
setup({ debounceMs: 20 });
197+
const input = root.querySelector('input[type="search"]');
198+
const clear = root.querySelector('.rf-search-clear');
199+
input.value = 'Class1';
200+
input.dispatchEvent(new window.Event('input', { bubbles: true }));
201+
expect(clear.hidden).toBe(false);
202+
expect(actions).toEqual([]);
203+
if (method === 'click') clear.click();
204+
else input.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
205+
expect(actions).toHaveLength(1);
206+
expect(actions[0].patch).toEqual({ search: '', page: 1 });
207+
await new Promise(resolve => setTimeout(resolve, 60));
208+
expect(input.value).toBe('');
209+
expect(clear.hidden).toBe(true);
210+
// A queued callback may run, but it must never replay the old query.
211+
for (const action of actions) {
212+
expect(action.patch).toEqual({ search: '', page: 1 });
213+
expect(action.meta).toEqual({ restoreFocus: true });
214+
}
215+
});
216+
}
217+
218+
it('updates clear visibility immediately while coalescing actual search actions', async () => {
219+
setup({ debounceMs: 20 });
220+
const input = root.querySelector('input[type="search"]');
221+
const clear = root.querySelector('.rf-search-clear');
222+
for (const value of ['C', 'Class', 'Class2']) {
223+
input.value = value;
224+
input.dispatchEvent(new window.Event('input', { bubbles: true }));
225+
expect(clear.hidden).toBe(false);
226+
expect(actions).toEqual([]);
227+
}
228+
await new Promise(resolve => setTimeout(resolve, 60));
229+
expect(actions).toEqual([{
230+
id: 'class-relationships', patch: { search: 'Class2', page: 1 }, meta: { restoreFocus: true }
231+
}]);
232+
});
233+
234+
it('offers a clear control for whitespace-only input', () => {
235+
setup({ tableStates: { 'class-relationships': { search: ' ' } } });
236+
const clear = root.querySelector('.rf-search-clear');
237+
expect(clear.hidden).toBe(false);
238+
clear.click();
239+
expect(root.querySelector('input').value).toBe('');
240+
expect(clear.hidden).toBe(true);
241+
expect(actions[0].patch).toEqual({ search: '', page: 1 });
242+
});
243+
244+
it('leaves ordinary key events uncanceled and the current filter visible', () => {
245+
setup({ tableStates: { 'class-relationships': { search: 'Class1' } } });
246+
const input = root.querySelector('input[type="search"]');
247+
for (const key of ['Tab', 'Enter', 'ArrowLeft']) {
248+
const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
249+
input.dispatchEvent(event);
250+
expect(event.defaultPrevented).toBe(false);
251+
}
252+
expect(input.value).toBe('Class1');
253+
expect(root.querySelector('.rf-search-clear').hidden).toBe(false);
254+
expect(actions).toEqual([]);
255+
});
256+
});
257+
162258
describe('sortable headers', () => {
163259
it('toggles the default-sorted priority column to descending on first click', () => {
164260
// The renderer applies the configured default (priority ascending) when no

0 commit comments

Comments
 (0)