From 48e3e31830ca8022844d360082fe6e662b69f39f Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:55:13 +0100 Subject: [PATCH 01/14] test(palette): add unit tests for paletteHelpers Step 1 of 3: Tests for the helpers in paletteHelpers.ts (pure functions). Brief: section-tests-1 Co-Authored-By: Antigravity (Gemini 4 Argon) --- .../sections/palette/paletteHelpers.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 app/src/sections/palette/paletteHelpers.test.ts diff --git a/app/src/sections/palette/paletteHelpers.test.ts b/app/src/sections/palette/paletteHelpers.test.ts new file mode 100644 index 00000000000..8cefe2d194c --- /dev/null +++ b/app/src/sections/palette/paletteHelpers.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { + COMPARISONS, + DE_T_BAD, + DE_T_OK, + DE_T_WARN, + deltaECellBg, + oklchToRgb, + proseColumnSx, + sectionSx, + starPoints, + textOn, + tooltipSlotProps, +} from 'src/sections/palette/paletteHelpers'; + +describe('paletteHelpers', () => { + describe('thresholds and comparison palettes', () => { + it('exports the Petroff 2021 ΔE thresholds in ascending order', () => { + expect([DE_T_BAD, DE_T_WARN, DE_T_OK]).toEqual([5, 10, 15]); + }); + + it('exports the four comparison palettes with valid hex and OKLCH entries', () => { + expect(COMPARISONS.map(c => c.id)).toEqual(['okabe', 'tol', 'set2', 'prev']); + for (const comp of COMPARISONS) { + expect(comp.name.length).toBeGreaterThan(0); + expect(comp.hexes.length).toBeGreaterThanOrEqual(7); + for (const swatch of comp.hexes) { + expect(swatch.hex).toMatch(/^#[0-9A-F]{6}$/); + expect(swatch.L).toBeGreaterThan(0); + expect(swatch.L).toBeLessThanOrEqual(1); + expect(swatch.C).toBeGreaterThanOrEqual(0); + expect(swatch.H).toBeGreaterThanOrEqual(0); + expect(swatch.H).toBeLessThan(360); + } + } + }); + }); + + describe('oklchToRgb', () => { + it('maps black (L=0, C=0) and white (L=1, C=0) to the sRGB endpoints', () => { + expect(oklchToRgb(0, 0, 0)).toEqual([0, 0, 0]); + expect(oklchToRgb(1, 0, 0)).toEqual([255, 255, 255]); + }); + + it('produces neutral grey with equal R, G, B channels when chroma is zero', () => { + const [r, g, b] = oklchToRgb(0.5, 0, 180); + expect(r).toBe(g); + expect(g).toBe(b); + expect(r).toBeGreaterThan(0); + expect(r).toBeLessThan(255); + }); + + it('approximates the brand-green sRGB triplet (#009E73) at its OKLCH coordinates', () => { + const [r, g, b] = oklchToRgb(0.62, 0.13, 165.5); + expect(r).toBeLessThanOrEqual(2); + expect(g).toBeGreaterThanOrEqual(156); + expect(g).toBeLessThanOrEqual(160); + expect(b).toBeGreaterThanOrEqual(113); + expect(b).toBeLessThanOrEqual(117); + }); + + it('clamps out-of-gamut high-chroma coordinates into 0..255 integers', () => { + const rgb = oklchToRgb(0.85, 0.4, 30); + for (const ch of rgb) { + expect(Number.isInteger(ch)).toBe(true); + expect(ch).toBeGreaterThanOrEqual(0); + expect(ch).toBeLessThanOrEqual(255); + } + }); + + it('is periodic modulo 360 degrees in hue', () => { + expect(oklchToRgb(0.72, 0.14, 0)).toEqual(oklchToRgb(0.72, 0.14, 360)); + }); + }); + + describe('starPoints', () => { + it('builds a 10-vertex alternating star starting at 12 o’clock', () => { + const points = starPoints(100, 100, 10, 5).split(' '); + expect(points).toHaveLength(10); + // Vertex 0: 12 o'clock outer tip (cx, cy - rOuter) + expect(points[0]).toBe('100.00,90.00'); + // Vertex 5: 6 o'clock inner valley (cx, cy + rInner) + expect(points[5]).toBe('100.00,105.00'); + }); + }); + + describe('textOn', () => { + it('returns dark ink (#1A1A17) on light swatches and light ink (#F0EFE8) on dark swatches', () => { + expect(textOn('#FFFFFF')).toBe('#1A1A17'); + expect(textOn('#F5F3EC')).toBe('#1A1A17'); + expect(textOn('#DDCC77')).toBe('#1A1A17'); + expect(textOn('#000000')).toBe('#F0EFE8'); + expect(textOn('#1A1A17')).toBe('#F0EFE8'); + expect(textOn('#4467A3')).toBe('#F0EFE8'); + expect(textOn('009E73')).toBe('#F0EFE8'); + }); + }); + + describe('deltaECellBg', () => { + it('returns transparent on the diagonal and for comfortable distances (>= 15)', () => { + expect(deltaECellBg(0)).toBe('transparent'); + expect(deltaECellBg(-1)).toBe('transparent'); + expect(deltaECellBg(DE_T_OK)).toBe('transparent'); + expect(deltaECellBg(25)).toBe('transparent'); + }); + + it('returns the red, amber, and yellow tints across the three warning bands', () => { + expect(deltaECellBg(0.1)).toBe('rgba(174, 48, 48, 0.26)'); + expect(deltaECellBg(DE_T_BAD - 0.01)).toBe('rgba(174, 48, 48, 0.26)'); + expect(deltaECellBg(DE_T_BAD)).toBe('rgba(189, 130, 51, 0.22)'); + expect(deltaECellBg(DE_T_WARN - 0.01)).toBe('rgba(189, 130, 51, 0.22)'); + expect(deltaECellBg(DE_T_WARN)).toBe('rgba(221, 204, 119, 0.18)'); + expect(deltaECellBg(DE_T_OK - 0.01)).toBe('rgba(221, 204, 119, 0.18)'); + }); + }); + + describe('layout and tooltip style constants', () => { + it('defines tooltip slot props and section layout sx objects', () => { + expect(tooltipSlotProps.tooltip.sx.maxWidth).toBe(320); + expect(sectionSx).toEqual({ py: { xs: 2, md: 3 } }); + expect(proseColumnSx).toEqual({ maxWidth: 880, mx: 'auto' }); + }); + }); +}); From 1b890ee4a8b7bfe6c9855e015d5847dce09ab3a2 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:57:49 +0100 Subject: [PATCH 02/14] test(palette): add render tests for ChromaWheel, CodeBlock, and DeltaEMatrix Step 2 of 3: Render tests for ChromaWheel, CodeBlock, and DeltaEMatrix. Brief: section-tests-1 Co-Authored-By: Antigravity (Gemini 4 Argon) --- app/src/sections/palette/ChromaWheel.test.tsx | 80 +++++++++++++++++++ app/src/sections/palette/CodeBlock.test.tsx | 54 +++++++++++++ .../sections/palette/DeltaEMatrix.test.tsx | 58 ++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 app/src/sections/palette/ChromaWheel.test.tsx create mode 100644 app/src/sections/palette/CodeBlock.test.tsx create mode 100644 app/src/sections/palette/DeltaEMatrix.test.tsx diff --git a/app/src/sections/palette/ChromaWheel.test.tsx b/app/src/sections/palette/ChromaWheel.test.tsx new file mode 100644 index 00000000000..171d4a3cb8c --- /dev/null +++ b/app/src/sections/palette/ChromaWheel.test.tsx @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ChromaWheel } from 'src/sections/palette/ChromaWheel'; +import type { WheelDot } from 'src/sections/palette/types'; +import { render, screen } from 'src/test-utils'; + +const SAMPLE_DOTS: WheelDot[] = [ + { hex: '#009E73', L: 0.62, C: 0.13, H: 165.5, name: 'brand green' }, + { hex: '#C475FD', L: 0.704, C: 0.202, H: 308.9, name: 'lavender' }, + { hex: '#4467A3', L: 0.516, C: 0.104, H: 260.6 }, +]; + +const OVERLAY_DOTS: WheelDot[] = [ + { hex: '#D55E00', L: 0.621, C: 0.17, H: 47.5 }, + { hex: '#56B4E9', L: 0.735, C: 0.117, H: 236.2 }, +]; + +function stubCanvas2D() { + const putImageData = vi.fn(); + const createImageData = vi.fn((w: number, h: number) => ({ + width: w, + height: h, + colorSpace: 'srgb' as PredefinedColorSpace, + data: new Uint8ClampedArray(w * h * 4), + })); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( + () => + ({ + createImageData, + putImageData, + }) as unknown as CanvasRenderingContext2D + ); + return { createImageData, putImageData }; +} + +describe('ChromaWheel', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('renders angle labels, brand star title, and dot titles in full mode and paints the canvas once per size', () => { + const { putImageData } = stubCanvas2D(); + const { rerender } = render(); + + expect(screen.getByText('0°')).toBeInTheDocument(); + expect(screen.getByText('90°')).toBeInTheDocument(); + expect(screen.getByText('180°')).toBeInTheDocument(); + expect(screen.getByText('270°')).toBeInTheDocument(); + + expect(screen.getByText('★ brand green · #009E73')).toBeInTheDocument(); + expect(screen.getByText('lavender · #C475FD')).toBeInTheDocument(); + expect(screen.getByText('#4467A3')).toBeInTheDocument(); + expect(putImageData).toHaveBeenCalledTimes(1); + + // Re-rendering with the same size and dpr skips the per-pixel repaint. + rerender(); + expect(putImageData).toHaveBeenCalledTimes(1); + }); + + it('hides angle labels in compact mode and renders overlay palette rings when provided', () => { + stubCanvas2D(); + render(); + + expect(screen.queryByText('0°')).not.toBeInTheDocument(); + expect(screen.queryByText('90°')).not.toBeInTheDocument(); + expect(screen.getByText('#D55E00')).toBeInTheDocument(); + expect(screen.getByText('#56B4E9')).toBeInTheDocument(); + }); + + it('renders cleanly with default size when 2D canvas context is unavailable', () => { + vi.stubGlobal('devicePixelRatio', 0); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + + render(); + + expect(screen.getByText('★ brand green · #009E73')).toBeInTheDocument(); + expect(screen.getByText('180°')).toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/palette/CodeBlock.test.tsx b/app/src/sections/palette/CodeBlock.test.tsx new file mode 100644 index 00000000000..ace6a22badb --- /dev/null +++ b/app/src/sections/palette/CodeBlock.test.tsx @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CodeBlock } from 'src/sections/palette/CodeBlock'; +import { act, fireEvent, render, screen } from 'src/test-utils'; + +describe('CodeBlock', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('renders the code text and copies it to the clipboard with a 1500 ms feedback reset', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + render(); + + expect(screen.getByText("const palette = ['#009E73'];")).toBeInTheDocument(); + const copyButton = screen.getByRole('button', { name: 'copy' }); + + await act(async () => { + fireEvent.click(copyButton); + }); + + expect(writeText).toHaveBeenCalledWith("const palette = ['#009E73'];"); + expect(screen.getByRole('button', { name: 'copied' })).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1499); + }); + expect(screen.getByRole('button', { name: 'copied' })).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument(); + }); + + it('ignores clipboard write failures without flipping to copied state', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('Clipboard blocked')); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'copy' })); + }); + + expect(writeText).toHaveBeenCalledWith("print('imprint')"); + expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/palette/DeltaEMatrix.test.tsx b/app/src/sections/palette/DeltaEMatrix.test.tsx new file mode 100644 index 00000000000..d76c1b6fcad --- /dev/null +++ b/app/src/sections/palette/DeltaEMatrix.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { DeltaEMatrix } from 'src/sections/palette/DeltaEMatrix'; +import { render, screen, within } from 'src/test-utils'; + +const MINI_MATRIX: number[][] = [ + [0, 4.2, 8.6], + [4.2, 0, 18.4], + [8.6, 18.4, 0], +]; + +describe('DeltaEMatrix', () => { + it('renders the title, diagonal dots, rounded ΔE values, and default pairwise tooltips', () => { + render(); + + expect(screen.getByText('normal vision')).toBeInTheDocument(); + const table = screen.getByRole('table'); + const cells = within(table).getAllByRole('cell'); + + // 3x3 matrix = 9 data cells; 3 diagonal cells show '·' + expect(cells).toHaveLength(9); + expect(cells[0]).toHaveTextContent('·'); + expect(cells[0]).toHaveAttribute('title', 'green'); + + // Off-diagonal cells show rounded ΔE and pairwise tooltip + expect(cells[1]).toHaveTextContent('4'); + expect(cells[1]).toHaveAttribute('title', 'green ↔ lavender — ΔE 4.2'); + expect(cells[2]).toHaveTextContent('9'); + expect(cells[2]).toHaveAttribute('title', 'green ↔ blue — ΔE 8.6'); + expect(cells[5]).toHaveTextContent('18'); + expect(cells[5]).toHaveAttribute('title', 'lavender ↔ blue — ΔE 18.4'); + }); + + it('formats off-diagonal cell tooltips from the per-CVD breakdown when provided', () => { + const deut = [ + [0, 3.5], + [3.5, 0], + ]; + const prot = [ + [0, 6.1], + [6.1, 0], + ]; + render( + + ); + + const table = screen.getByRole('table'); + const cells = within(table).getAllByRole('cell'); + expect(cells[1]).toHaveAttribute('title', 'green ↔ lavender — deut 3.5 · prot 6.1'); + }); +}); From 2ff23e5bb7fcd149654f093c583999a455a7fc0c Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:01:32 +0100 Subject: [PATCH 03/14] test(palette): add render and interaction tests for the eight palette sections Step 3 of 3: Render and interaction tests for the eight section components. Brief: section-tests-1 Co-Authored-By: Antigravity (Gemini 4 Argon) --- .../palette/ContinuousCmapsSection.test.tsx | 23 ++++ .../palette/CopySnippetsSection.test.tsx | 64 +++++++++++ .../sections/palette/DeltaESection.test.tsx | 29 +++++ app/src/sections/palette/HeroSection.test.tsx | 107 ++++++++++++++++++ .../sections/palette/LineageSection.test.tsx | 26 +++++ .../palette/PaletteGridSection.test.tsx | 106 +++++++++++++++++ .../palette/SemanticAnchorsSection.test.tsx | 80 +++++++++++++ .../palette/WcagContrastSection.test.tsx | 39 +++++++ 8 files changed, 474 insertions(+) create mode 100644 app/src/sections/palette/ContinuousCmapsSection.test.tsx create mode 100644 app/src/sections/palette/CopySnippetsSection.test.tsx create mode 100644 app/src/sections/palette/DeltaESection.test.tsx create mode 100644 app/src/sections/palette/HeroSection.test.tsx create mode 100644 app/src/sections/palette/LineageSection.test.tsx create mode 100644 app/src/sections/palette/PaletteGridSection.test.tsx create mode 100644 app/src/sections/palette/SemanticAnchorsSection.test.tsx create mode 100644 app/src/sections/palette/WcagContrastSection.test.tsx diff --git a/app/src/sections/palette/ContinuousCmapsSection.test.tsx b/app/src/sections/palette/ContinuousCmapsSection.test.tsx new file mode 100644 index 00000000000..cb78149a400 --- /dev/null +++ b/app/src/sections/palette/ContinuousCmapsSection.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { ContinuousCmapsSection } from 'src/sections/palette/ContinuousCmapsSection'; +import { render, screen } from 'src/test-utils'; + +describe('ContinuousCmapsSection', () => { + it('renders sequential (imprint_seq) and diverging (imprint_div) colormaps with endpoint stops', () => { + render(); + + expect(screen.getByRole('heading', { name: /continuous cmaps/i })).toBeInTheDocument(); + expect(screen.getByText('imprint_seq')).toBeInTheDocument(); + expect(screen.getByText('single-polarity · density / magnitude')).toBeInTheDocument(); + expect(screen.getByText('#009E73 brand-green')).toBeInTheDocument(); + + expect(screen.getByText('imprint_div')).toBeInTheDocument(); + expect(screen.getByText('diverging · correlations / signed deviations')).toBeInTheDocument(); + expect(screen.getByText('#AE3030 red')).toBeInTheDocument(); + expect(screen.getByText('theme-adaptive midpoint')).toBeInTheDocument(); + expect(screen.getAllByText('#4467A3 blue')).toHaveLength(2); + expect(screen.getByText('#FAF8F1')).toBeInTheDocument(); + expect(screen.getByText('#1A1A17')).toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/palette/CopySnippetsSection.test.tsx b/app/src/sections/palette/CopySnippetsSection.test.tsx new file mode 100644 index 00000000000..e4bb2ea3f27 --- /dev/null +++ b/app/src/sections/palette/CopySnippetsSection.test.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; + +import { describe, expect, it } from 'vitest'; + +import { type Lang, PALETTE } from 'src/pages/PalettePage.helpers'; +import { CopySnippetsSection } from 'src/sections/palette/CopySnippetsSection'; +import { fireEvent, render, screen } from 'src/test-utils'; + +function StatefulCopySnippets() { + const [lang, setLang] = useState('python'); + const [oklch, setOklch] = useState(false); + + return ( + + ); +} + +describe('CopySnippetsSection', () => { + it('switches language tabs and updates the snippet text across Python, R, Julia, and JavaScript', () => { + render(); + + expect(screen.getByRole('heading', { name: /copy & paste/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText(/from types import SimpleNamespace/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: 'R' })); + expect(screen.getByRole('tab', { name: 'R' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText(/IMPRINT <- list\(/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: 'Julia' })); + expect(screen.getByRole('tab', { name: 'Julia' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText(/using Colors/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: 'JavaScript' })); + expect(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByText(/const IMPRINT = \{/)).toBeInTheDocument(); + }); + + it('toggles the OKLCH switch to annotate Python/R/Julia snippets and emit oklch() literals in JavaScript', () => { + render(); + + const oklchSwitch = screen.getByRole('switch', { name: 'OKLCH' }); + expect(oklchSwitch).not.toBeChecked(); + expect(screen.queryByText(/# oklch\(0\.620 0\.130 165\.5\)/)).not.toBeInTheDocument(); + + // Toggle OKLCH on in Python mode: adds trailing # oklch(...) comments + fireEvent.click(oklchSwitch); + expect(oklchSwitch).toBeChecked(); + expect(screen.getByText(/# oklch\(0\.620 0\.130 165\.5\)/)).toBeInTheDocument(); + + // Switch to JavaScript while OKLCH is on: emits bare oklch(...) string values + fireEvent.click(screen.getByRole('tab', { name: 'JavaScript' })); + expect(screen.getByText(/"oklch\(0\.620 0\.130 165\.5\)"/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/palette/DeltaESection.test.tsx b/app/src/sections/palette/DeltaESection.test.tsx new file mode 100644 index 00000000000..3fa0f2c678d --- /dev/null +++ b/app/src/sections/palette/DeltaESection.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { DeltaESection } from 'src/sections/palette/DeltaESection'; +import { render, screen, within } from 'src/test-utils'; + +describe('DeltaESection', () => { + it('renders both normal-vision and worst-CVD ΔE matrices alongside the Petroff 2021 legend', () => { + render(); + + expect(screen.getByRole('heading', { name: /ΔE colour distance/i })).toBeInTheDocument(); + expect(screen.getByText('normal vision')).toBeInTheDocument(); + expect(screen.getByText('worst of deuter / protan / tritan')).toBeInTheDocument(); + + const tables = screen.getAllByRole('table'); + expect(tables).toHaveLength(2); + + // Normal matrix has standard ΔE tooltips; worst-CVD matrix has deut/prot/trit breakdown tooltips + expect(within(tables[0]).getByTitle('green ↔ lavender — ΔE 54.1')).toBeInTheDocument(); + expect( + within(tables[1]).getByTitle('green ↔ lavender — deut 36.2 · prot 42.4 · trit 38.6') + ).toBeInTheDocument(); + + expect(screen.getByText('<5 indistinct')).toBeInTheDocument(); + expect(screen.getByText('<10 weak')).toBeInTheDocument(); + expect(screen.getByText('<15 acceptable')).toBeInTheDocument(); + expect(screen.getByText('≥15 comfortable')).toBeInTheDocument(); + expect(screen.getByText(/thresholds: Petroff 2021/i)).toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/palette/HeroSection.test.tsx b/app/src/sections/palette/HeroSection.test.tsx new file mode 100644 index 00000000000..bca83cb5fae --- /dev/null +++ b/app/src/sections/palette/HeroSection.test.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PALETTE } from 'src/pages/PalettePage.helpers'; +import { HeroSection } from 'src/sections/palette/HeroSection'; +import { COMPARISONS } from 'src/sections/palette/paletteHelpers'; +import type { WheelDot } from 'src/sections/palette/types'; +import { fireEvent, render, screen } from 'src/test-utils'; + +const IMPRINT_DOTS: WheelDot[] = PALETTE.map(s => ({ + hex: s.hex, + L: s.L, + C: s.C, + H: s.H, + name: s.name, +})); + +function StatefulHero() { + const [wheelOpen, setWheelOpen] = useState(false); + const [compareId, setCompareId] = useState(null); + const overlayDots = COMPARISONS.find(c => c.id === compareId)?.hexes; + + return ( + + ); +} + +describe('HeroSection', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('expands and collapses the hue–chroma wheel and updates aria-expanded', () => { + render(); + + expect(screen.getByRole('heading', { name: /anyplot's imprint palette/i })).toBeInTheDocument(); + expect(screen.getByText('compare ▸')).toBeInTheDocument(); + + const expandButton = screen.getByRole('button', { + name: 'Expand the hue–chroma wheel to compare palettes', + }); + expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(expandButton); + + const collapseButton = screen.getByRole('button', { + name: 'Collapse the hue–chroma wheel', + }); + expect(collapseButton).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('click to close')).toBeInTheDocument(); + + fireEvent.click(collapseButton); + expect( + screen.getByRole('button', { + name: 'Expand the hue–chroma wheel to compare palettes', + }) + ).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByText('compare ▸')).toBeInTheDocument(); + }); + + it('picks and unpicks a comparison palette and updates aria-pressed and overlay rings', () => { + render(); + + fireEvent.click( + screen.getByRole('button', { + name: 'Expand the hue–chroma wheel to compare palettes', + }) + ); + + const okabeButton = screen.getByRole('button', { name: 'Okabe-Ito' }); + const tolButton = screen.getByRole('button', { name: 'Tol muted' }); + expect(okabeButton).toHaveAttribute('aria-pressed', 'false'); + expect(tolButton).toHaveAttribute('aria-pressed', 'false'); + expect(screen.queryByText(/rings =/i)).not.toBeInTheDocument(); + + // Pick Okabe-Ito + fireEvent.click(okabeButton); + expect(okabeButton).toHaveAttribute('aria-pressed', 'true'); + expect(tolButton).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText(/· rings = Okabe-Ito/)).toBeInTheDocument(); + expect(screen.getByText('#D55E00')).toBeInTheDocument(); + + // Switch directly to Tol muted + fireEvent.click(tolButton); + expect(tolButton).toHaveAttribute('aria-pressed', 'true'); + expect(okabeButton).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText(/· rings = Tol muted/)).toBeInTheDocument(); + + // Unpick Tol muted + fireEvent.click(tolButton); + expect(tolButton).toHaveAttribute('aria-pressed', 'false'); + expect(screen.queryByText(/rings =/i)).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/palette/LineageSection.test.tsx b/app/src/sections/palette/LineageSection.test.tsx new file mode 100644 index 00000000000..f7435d3e40c --- /dev/null +++ b/app/src/sections/palette/LineageSection.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { LineageSection } from 'src/sections/palette/LineageSection'; +import { render, screen } from 'src/test-utils'; + +describe('LineageSection', () => { + it('renders the three palette history entries and reference links', () => { + render(); + + expect(screen.getByRole('heading', { name: /lineage/i })).toBeInTheDocument(); + expect(screen.getByText('version timeline')).toBeInTheDocument(); + + expect(screen.getByText('v3 — imprint (current)')).toBeInTheDocument(); + expect(screen.getByText('v1 — variant D ("balanced")')).toBeInTheDocument(); + expect(screen.getByText('v0 — Okabe-Ito')).toBeInTheDocument(); + + const petroffLink = screen.getByRole('link', { name: 'Petroff 2021 (arXiv)' }); + expect(petroffLink).toHaveAttribute('href', 'https://arxiv.org/abs/2107.02270'); + + const okabeLink = screen.getByRole('link', { name: 'jfly.uni-koeln.de' }); + expect(okabeLink).toHaveAttribute('href', 'https://jfly.uni-koeln.de/color/'); + + const draculaLink = screen.getByRole('link', { name: 'dracula-palette' }); + expect(draculaLink).toHaveAttribute('href', 'https://anselmoo.github.io/dracula-palette/'); + }); +}); diff --git a/app/src/sections/palette/PaletteGridSection.test.tsx b/app/src/sections/palette/PaletteGridSection.test.tsx new file mode 100644 index 00000000000..daa6b6bf31b --- /dev/null +++ b/app/src/sections/palette/PaletteGridSection.test.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { PALETTE, type Swatch } from 'src/pages/PalettePage.helpers'; +import { PaletteGridSection } from 'src/sections/palette/PaletteGridSection'; +import { act, fireEvent, render, screen, within } from 'src/test-utils'; + +const CVD_OPTIMAL_ORDER: number[] = [0, 1, 7, 6, 4, 5, 2, 3]; + +function StatefulPaletteGrid() { + const [sort, setSort] = useState<'imprint' | 'cvd'>('imprint'); + const [copiedHex, setCopiedHex] = useState(null); + + const sortedPalette: Swatch[] = + sort === 'imprint' ? PALETTE : CVD_OPTIMAL_ORDER.map(i => PALETTE[i]); + + const copyHex = (hex: string) => { + void navigator.clipboard + .writeText(hex) + .then(() => { + setCopiedHex(hex); + setTimeout(() => setCopiedHex(c => (c === hex ? null : c)), 1500); + }) + .catch(() => {}); + }; + + return ( + + ); +} + +describe('PaletteGridSection', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('switches between imprint default and CVD-optimal sort order and toggles the trade-off table', () => { + render(); + + expect(screen.getByRole('heading', { name: /the 8 categorical hues/i })).toBeInTheDocument(); + expect(screen.queryByText(/why use CVD-optimal sort\?/i)).not.toBeInTheDocument(); + + // Default imprint order: [brand green, lavender, blue, ochre, ...] + const defaultButtons = screen.getAllByRole('button', { name: /Copy #[0-9A-F]{6}/i }); + expect(defaultButtons).toHaveLength(8); + expect(defaultButtons[0]).toHaveAccessibleName('Copy #009E73 (brand green) to clipboard'); + expect(defaultButtons[2]).toHaveAccessibleName('Copy #4467A3 (blue) to clipboard'); + expect(defaultButtons[3]).toHaveAccessibleName('Copy #BD8233 (ochre) to clipboard'); + + // Switch to CVD-optimal order + fireEvent.click( + screen.getByRole('button', { name: 'CVD-optimal (max ΔE under colorblindness)' }) + ); + + expect(screen.getByText(/why use CVD-optimal sort\?/i)).toBeInTheDocument(); + const table = screen.getByRole('table'); + expect(within(table).getByText('21.45 (+5.11)')).toBeInTheDocument(); + expect(within(table).getByText('36.19 (=)')).toBeInTheDocument(); + + const cvdButtons = screen.getAllByRole('button', { name: /Copy #[0-9A-F]{6}/i }); + expect(cvdButtons[2]).toHaveAccessibleName('Copy #99B314 (lime) to clipboard'); + expect(cvdButtons[3]).toHaveAccessibleName('Copy #954477 (rose) to clipboard'); + + // Switch back to imprint (default) + fireEvent.click(screen.getByRole('button', { name: 'imprint (default)' })); + expect(screen.queryByText(/why use CVD-optimal sort\?/i)).not.toBeInTheDocument(); + }); + + it('copies a swatch hex to the clipboard, shows copied feedback, and resets after 1500 ms', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + render(); + + const brandButton = screen.getByRole('button', { + name: 'Copy #009E73 (brand green) to clipboard', + }); + expect(within(brandButton).getByText('#009E73')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(brandButton); + }); + + expect(writeText).toHaveBeenCalledWith('#009E73'); + expect(within(brandButton).getByText('copied ✓')).toBeInTheDocument(); + // Unclicked swatches keep showing their hex + expect(screen.getByText('#C475FD')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1500); + }); + + expect(within(brandButton).queryByText('copied ✓')).not.toBeInTheDocument(); + expect(within(brandButton).getByText('#009E73')).toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/palette/SemanticAnchorsSection.test.tsx b/app/src/sections/palette/SemanticAnchorsSection.test.tsx new file mode 100644 index 00000000000..b7bdd0c8252 --- /dev/null +++ b/app/src/sections/palette/SemanticAnchorsSection.test.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SemanticAnchorsSection } from 'src/sections/palette/SemanticAnchorsSection'; +import { act, fireEvent, render, screen } from 'src/test-utils'; + +function StatefulAnchors() { + const [copiedHex, setCopiedHex] = useState(null); + const copyHex = (hex: string) => { + void navigator.clipboard + .writeText(hex) + .then(() => { + setCopiedHex(hex); + setTimeout(() => setCopiedHex(c => (c === hex ? null : c)), 1500); + }) + .catch(() => {}); + }; + + return ; +} + +describe('SemanticAnchorsSection', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('renders amber, adaptive neutral, and adaptive muted anchors with their swatch buttons', () => { + render(); + + expect(screen.getByRole('heading', { name: /semantic anchors/i })).toBeInTheDocument(); + expect(screen.getByText('palette.amber')).toBeInTheDocument(); + expect(screen.getByText('palette.neutral — adaptive')).toBeInTheDocument(); + expect(screen.getByText('palette.muted — adaptive')).toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: 'Copy #DDCC77 (amber) to clipboard' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy #1A1A17 (neutral) to clipboard' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy #F0EFE8 (neutral) to clipboard' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy #6B6A63 (muted) to clipboard' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy #A8A79F (muted) to clipboard' }) + ).toBeInTheDocument(); + }); + + it('copies an anchor swatch and resets the copied feedback after 1500 ms', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + render(); + + const amberSwatch = screen.getByRole('button', { + name: 'Copy #DDCC77 (amber) to clipboard', + }); + expect(amberSwatch).toHaveTextContent('#DDCC77'); + + await act(async () => { + fireEvent.click(amberSwatch); + }); + + expect(writeText).toHaveBeenCalledWith('#DDCC77'); + expect(amberSwatch).toHaveTextContent('copied ✓'); + + act(() => { + vi.advanceTimersByTime(1500); + }); + + expect(amberSwatch).toHaveTextContent('#DDCC77'); + }); +}); diff --git a/app/src/sections/palette/WcagContrastSection.test.tsx b/app/src/sections/palette/WcagContrastSection.test.tsx new file mode 100644 index 00000000000..4a2ed7c93ef --- /dev/null +++ b/app/src/sections/palette/WcagContrastSection.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { WcagContrastSection } from 'src/sections/palette/WcagContrastSection'; +import { render, screen } from 'src/test-utils'; + +describe('WcagContrastSection', () => { + it('renders the WCAG contrast table with pass (✓) and fail (✗) ratios on cream and on dark', () => { + render(); + + expect(screen.getByRole('heading', { name: /WCAG contrast/i })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'on cream' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'on dark' })).toBeInTheDocument(); + + // Brand green passes both; matte red passes cream, fails dark; lavender fails cream, passes dark + expect(screen.getByText('3.08:1 ✓')).toBeInTheDocument(); + expect(screen.getByText('5.48:1 ✓')).toBeInTheDocument(); + expect(screen.getByText('5.79:1 ✓')).toBeInTheDocument(); + expect(screen.getByText('2.92:1 ✗')).toBeInTheDocument(); + expect(screen.getByText('2.59:1 ✗')).toBeInTheDocument(); + expect(screen.getByText('6.53:1 ✓')).toBeInTheDocument(); + }); + + it('renders both light and dark theme variants across all nine palette and anchor rows', () => { + const { rerender } = render(); + + expect(screen.getByRole('cell', { name: '#C475FD' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'lavender' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '#AE3030' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'matte-red' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '#DDCC77' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'amber' })).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('cell', { name: '#C475FD' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '#AE3030' })).toBeInTheDocument(); + expect(screen.getByText('1.46:1 ✗')).toBeInTheDocument(); + expect(screen.getByText('11.59:1 ✓')).toBeInTheDocument(); + }); +}); From 02950355ac6aade0d86a1e6e875996fecf4fa816 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:19:27 +0100 Subject: [PATCH 04/14] test(debug): add unit tests for debugFormat helpers Step 1 of 3: Tests for debugFormat.ts. Brief: section-tests-2 Co-Authored-By: Antigravity (Gemini 4 Argon) --- app/src/sections/debug/debugFormat.test.ts | 93 ++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 app/src/sections/debug/debugFormat.test.ts diff --git a/app/src/sections/debug/debugFormat.test.ts b/app/src/sections/debug/debugFormat.test.ts new file mode 100644 index 00000000000..c202f2312f7 --- /dev/null +++ b/app/src/sections/debug/debugFormat.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import { LIBRARIES } from 'src/constants'; +import { + countImpls, + formatDateShort, + formatNum, + pingColor, + scoreColor, +} from 'src/sections/debug/debugFormat'; +import type { SpecStatus } from 'src/sections/debug/SpecMatrix'; +import { colors } from 'src/theme'; + +function makeSpec(overrides: Partial = {}): SpecStatus { + return { + id: 'scatter-basic', + title: 'Basic Scatter', + updated: '2026-01-15T12:00:00Z', + avg_score: null, + altair: null, + bokeh: null, + chartjs: null, + d3: null, + echarts: null, + ggplot2: null, + highcharts: null, + letsplot: null, + makie: null, + matplotlib: null, + muix: null, + plotly: null, + plotnine: null, + pygal: null, + seaborn: null, + ...overrides, + }; +} + +describe('debugFormat', () => { + describe('countImpls', () => { + it('counts non-null library scores across all libraries', () => { + expect(countImpls(makeSpec())).toBe(0); + expect(countImpls(makeSpec({ matplotlib: 95, seaborn: 91, d3: 88 }))).toBe(3); + + const fullOverrides = Object.fromEntries( + LIBRARIES.map(lib => [lib, 92]) + ) as Partial; + expect(countImpls(makeSpec(fullOverrides))).toBe(LIBRARIES.length); + }); + }); + + describe('scoreColor', () => { + it('returns muted ink for null and threshold colors for scores', () => { + expect(scoreColor(null)).toBe('var(--ink-muted)'); + expect(scoreColor(90)).toBe(colors.success); + expect(scoreColor(96.5)).toBe(colors.success); + expect(scoreColor(89.9)).toBe(colors.warning); + expect(scoreColor(75)).toBe(colors.warning); + expect(scoreColor(74.9)).toBe(colors.error); + expect(scoreColor(50)).toBe(colors.error); + }); + }); + + describe('formatNum', () => { + it('formats millions, thousands, and smaller numbers', () => { + expect(formatNum(1_000_000)).toBe('1.0M'); + expect(formatNum(2_450_000)).toBe('2.5M'); + expect(formatNum(1_000)).toBe('1.0K'); + expect(formatNum(15_640)).toBe('15.6K'); + expect(formatNum(999)).toBe((999).toLocaleString()); + expect(formatNum(0)).toBe('0'); + }); + }); + + describe('formatDateShort', () => { + it('formats valid ISO dates as short US month and day and returns invalid strings unchanged', () => { + expect(formatDateShort('2026-01-15T12:00:00Z')).toBe('Jan 15'); + expect(formatDateShort('not-a-date')).toBe('not-a-date'); + }); + }); + + describe('pingColor', () => { + it('maps response latency to success, warning, and error thresholds', () => { + expect(pingColor(0)).toBe(colors.success); + expect(pingColor(42)).toBe(colors.success); + expect(pingColor(74.9)).toBe(colors.success); + expect(pingColor(75)).toBe(colors.warning); + expect(pingColor(199.9)).toBe(colors.warning); + expect(pingColor(200)).toBe(colors.error); + expect(pingColor(500)).toBe(colors.error); + }); + }); +}); From 4816a61b816ebce9e6c0b666878e9716a0ee0716 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:21:20 +0100 Subject: [PATCH 05/14] test(debug): add render tests for SpecMatrix, SortableHeader, and HeaderCell Step 2 of 3: Render tests for the table and header components (SpecMatrix, SortableHeader, HeaderCell) with sorting and filters. Brief: section-tests-2 Co-Authored-By: Antigravity (Gemini 4 Argon) --- app/src/sections/debug/SpecMatrix.test.tsx | 293 +++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 app/src/sections/debug/SpecMatrix.test.tsx diff --git a/app/src/sections/debug/SpecMatrix.test.tsx b/app/src/sections/debug/SpecMatrix.test.tsx new file mode 100644 index 00000000000..fee1901a8dc --- /dev/null +++ b/app/src/sections/debug/SpecMatrix.test.tsx @@ -0,0 +1,293 @@ +import { useMemo, useState } from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { LIBRARIES } from 'src/constants'; +import { countImpls } from 'src/sections/debug/debugFormat'; +import { HeaderCell } from 'src/sections/debug/HeaderCell'; +import { SortableHeader, type SortDir } from 'src/sections/debug/SortableHeader'; +import { SpecMatrix, type SpecStatus } from 'src/sections/debug/SpecMatrix'; +import { render, screen, userEvent } from 'src/test-utils'; + +function makeSpec(overrides: Partial & { id: string; title: string }): SpecStatus { + return { + updated: '2026-01-15T12:00:00Z', + avg_score: null, + altair: null, + bokeh: null, + chartjs: null, + d3: null, + echarts: null, + ggplot2: null, + highcharts: null, + letsplot: null, + makie: null, + matplotlib: null, + muix: null, + plotly: null, + plotnine: null, + pygal: null, + seaborn: null, + ...overrides, + }; +} + +const ALL_LIBRARIES_FULL = Object.fromEntries( + LIBRARIES.map(lib => [lib, 95]) +) as Partial; + +const SAMPLE_SPECS: SpecStatus[] = [ + makeSpec({ + id: 'scatter-basic', + title: 'Basic Scatter Plot', + updated: '2026-01-15T12:00:00Z', + avg_score: 95.0, + ...ALL_LIBRARIES_FULL, + }), + makeSpec({ + id: 'bar-grouped', + title: 'Grouped Bar Chart', + updated: '2026-02-20T12:00:00Z', + avg_score: 84.5, + matplotlib: 91, + seaborn: 78, + }), + makeSpec({ + id: 'heatmap-annotated', + title: 'Annotated Heatmap', + updated: null, + avg_score: null, + }), +]; + +type SortCol = keyof SpecStatus | 'coverage'; + +function StatefulSpecMatrix({ specs = SAMPLE_SPECS }: { specs?: SpecStatus[] }) { + const [searchText, setSearchText] = useState(''); + const [showIncomplete, setShowIncomplete] = useState(false); + const [showLowScores, setShowLowScores] = useState(false); + const [missingLibrary, setMissingLibrary] = useState(''); + const [sortCol, setSortCol] = useState('updated'); + const [sortDir, setSortDir] = useState('desc'); + + const filteredSpecs = useMemo(() => { + let list = [...specs]; + if (searchText) { + const q = searchText.toLowerCase(); + list = list.filter(s => s.id.toLowerCase().includes(q) || s.title.toLowerCase().includes(q)); + } + if (showIncomplete) { + list = list.filter(s => countImpls(s) < LIBRARIES.length); + } + if (showLowScores) { + list = list.filter(s => + LIBRARIES.some(lib => { + const score = s[lib as keyof SpecStatus] as number | null; + return score !== null && score < 90; + }) + ); + } + if (missingLibrary) { + list = list.filter(s => s[missingLibrary as keyof SpecStatus] === null); + } + list.sort((a, b) => { + let cmp = 0; + switch (sortCol) { + case 'updated': + cmp = (a.updated || '').localeCompare(b.updated || ''); + break; + case 'id': + cmp = a.id.localeCompare(b.id); + break; + case 'title': + cmp = a.title.localeCompare(b.title); + break; + case 'avg_score': + cmp = (a.avg_score ?? 0) - (b.avg_score ?? 0); + break; + } + return sortDir === 'desc' ? -cmp : cmp; + }); + return list; + }, [specs, searchText, showIncomplete, showLowScores, missingLibrary, sortCol, sortDir]); + + const handleSort = (col: SortCol) => { + if (sortCol === col) { + setSortDir(d => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortCol(col); + setSortDir(col === 'updated' || col === 'avg_score' ? 'desc' : 'asc'); + } + }; + + return ( + + ); +} + +/** Returns the spec IDs in the order they appear in the desktop table links. */ +function renderedSpecOrder(): string[] { + const knownIds = new Set(SAMPLE_SPECS.map(s => s.id)); + return screen + .getAllByRole('link') + .map(link => link.textContent ?? '') + .filter(text => knownIds.has(text)); +} + +describe('HeaderCell', () => { + it('renders header text in both default and centered alignments', () => { + const { rerender } = render(#); + expect(screen.getByText('#')).toBeInTheDocument(); + + rerender(mpl); + expect(screen.getByText('mpl')).toBeInTheDocument(); + }); +}); + +describe('SortableHeader', () => { + it('shows the direction indicator only when active and triggers onClick', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const { rerender } = render( + + ); + + const button = screen.getByRole('button', { name: 'spec id' }); + expect(button).not.toHaveTextContent('▲'); + expect(button).not.toHaveTextContent('▼'); + + await user.click(button); + expect(onClick).toHaveBeenCalledTimes(1); + + rerender(); + expect(screen.getByRole('button', { name: /spec id/ })).toHaveTextContent('▲'); + + rerender(); + expect(screen.getByRole('button', { name: /spec id/ })).toHaveTextContent('▼'); + }); +}); + +describe('SpecMatrix', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('renders spec rows with implementation counts, scores, links, and fallback dashes', () => { + render(); + + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + expect(screen.getByText(`${LIBRARIES.length}/${LIBRARIES.length}`)).toBeInTheDocument(); + expect(screen.getByText(`2/${LIBRARIES.length}`)).toBeInTheDocument(); + expect(screen.getByText(`0/${LIBRARIES.length}`)).toBeInTheDocument(); + + // Average score formatting in desktop and mobile views + expect(screen.getByText('95.0')).toBeInTheDocument(); + expect(screen.getByText('84.5')).toBeInTheDocument(); + expect(screen.getByText('avg 95')).toBeInTheDocument(); + expect(screen.getByText('avg 85')).toBeInTheDocument(); + expect(screen.getByText('avg —')).toBeInTheDocument(); + + // Updated date formatting and missing activity fallback + expect(screen.getByText('Jan 15')).toBeInTheDocument(); + expect(screen.getByText('Feb 20')).toBeInTheDocument(); + expect(screen.getByText('no activity')).toBeInTheDocument(); + + // Links to spec pages and implementation pages + const specLinks = screen.getAllByRole('link', { name: 'scatter-basic' }); + expect(specLinks[0]).toHaveAttribute('href', '/scatter-basic'); + + const implLinks = screen.getAllByRole('link', { name: '78' }); + expect(implLinks[0]).toHaveAttribute('href', '/bar-grouped/python/seaborn'); + }); + + it('sorts specs by each sortable column and toggles direction', async () => { + const user = userEvent.setup(); + render(); + + // Initial sort is updated desc: bar-grouped (Feb 20), scatter-basic (Jan 15), heatmap-annotated (null) + expect(renderedSpecOrder()).toEqual(['bar-grouped', 'scatter-basic', 'heatmap-annotated']); + + // Toggle updated to asc + await user.click(screen.getByRole('button', { name: /updated/ })); + expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'scatter-basic', 'bar-grouped']); + + // Sort by spec id (defaults to asc, then toggle to desc) + await user.click(screen.getByRole('button', { name: /spec id/ })); + expect(renderedSpecOrder()).toEqual(['bar-grouped', 'heatmap-annotated', 'scatter-basic']); + + await user.click(screen.getByRole('button', { name: /spec id/ })); + expect(renderedSpecOrder()).toEqual(['scatter-basic', 'heatmap-annotated', 'bar-grouped']); + + // Sort by title (defaults to asc, then toggle to desc) + await user.click(screen.getByRole('button', { name: /title/ })); + expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'scatter-basic', 'bar-grouped']); + + await user.click(screen.getByRole('button', { name: /title/ })); + expect(renderedSpecOrder()).toEqual(['bar-grouped', 'scatter-basic', 'heatmap-annotated']); + + // Sort by avg (defaults to desc, then toggle to asc) + await user.click(screen.getByRole('button', { name: /avg/ })); + expect(renderedSpecOrder()).toEqual(['scatter-basic', 'bar-grouped', 'heatmap-annotated']); + + await user.click(screen.getByRole('button', { name: /avg/ })); + expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'bar-grouped', 'scatter-basic']); + }); + + it('filters specs by search text, incomplete coverage, low scores, and missing library', async () => { + const user = userEvent.setup(); + render(); + + // Incomplete filter (< 15 libraries): excludes scatter-basic (15/15) + const incompleteBtn = screen.getByRole('button', { + name: new RegExp(`incomplete <${LIBRARIES.length}`, 'i'), + }); + await user.click(incompleteBtn); + expect(screen.getByText('2 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['bar-grouped', 'heatmap-annotated']); + + // Toggle incomplete off + await user.click(incompleteBtn); + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + + // Low scores filter (< 90): keeps only bar-grouped (seaborn = 78) + const lowScoresBtn = screen.getByRole('button', { name: /low scores <90/i }); + await user.click(lowScoresBtn); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['bar-grouped']); + + // Toggle low scores off + await user.click(lowScoresBtn); + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + + // Missing library filter: matplotlib is present on scatter-basic and bar-grouped, missing on heatmap-annotated + const missingSelect = screen.getByRole('combobox'); + await user.selectOptions(missingSelect, 'matplotlib'); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['heatmap-annotated']); + + // Reset missing library filter + await user.selectOptions(missingSelect, ''); + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + + // Search input filter by title or id + const searchInput = screen.getByPlaceholderText('search spec id or title...'); + await user.type(searchInput, 'Grouped'); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['bar-grouped']); + }); +}); From 2f6121f390e4f08d7674b9d445bacf9a65081721 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:24:46 +0100 Subject: [PATCH 06/14] test(debug): add render tests for feedback and overview debug sections Step 3 of 3: Render tests for the remaining sections listed under Context. Brief: section-tests-2 Co-Authored-By: Antigravity (Gemini 4 Argon) --- .../debug/DebugOverviewSections.test.tsx | 268 +++++++++++++++ .../sections/debug/FeedbackSection.test.tsx | 310 ++++++++++++++++++ 2 files changed, 578 insertions(+) create mode 100644 app/src/sections/debug/DebugOverviewSections.test.tsx create mode 100644 app/src/sections/debug/FeedbackSection.test.tsx diff --git a/app/src/sections/debug/DebugOverviewSections.test.tsx b/app/src/sections/debug/DebugOverviewSections.test.tsx new file mode 100644 index 00000000000..997f4ccb948 --- /dev/null +++ b/app/src/sections/debug/DebugOverviewSections.test.tsx @@ -0,0 +1,268 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CommonWeaknesses } from 'src/sections/debug/CommonWeaknesses'; +import { DailyActivityChart } from 'src/sections/debug/DailyActivityChart'; +import { KPIGrid } from 'src/sections/debug/KPIGrid'; +import { LibraryCoverage } from 'src/sections/debug/LibraryCoverage'; +import { ProblemAreas } from 'src/sections/debug/ProblemAreas'; +import { RecentActivity } from 'src/sections/debug/RecentActivity'; +import { SystemHealthRow } from 'src/sections/debug/SystemHealthRow'; +import { render, screen } from 'src/test-utils'; + +describe('SystemHealthRow', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('shows initial system health before live pings arrive and updates with ping history', () => { + const systemData = { + database_connected: true, + api_response_time_ms: 42, + timestamp: '2026-05-20T12:00:00Z', + }; + + const { rerender } = render( + + ); + + expect(screen.getByText('database connected')).toBeInTheDocument(); + expect(screen.getByText('42ms')).toBeInTheDocument(); + expect(screen.queryByText(/avg /i)).not.toBeInTheDocument(); + expect(screen.getByText(/updated /i)).toBeInTheDocument(); + + // Rerender with mixed pings where the latest ping failed + rerender( + + ); + + expect(screen.getByText('database down')).toBeInTheDocument(); + expect(screen.getByText('250ms')).toBeInTheDocument(); + expect(screen.getByText('avg 100')).toBeInTheDocument(); + + // Rerender where latest ping is ok + rerender( + + ); + expect(screen.getByText('database connected')).toBeInTheDocument(); + expect(screen.getByText('80ms')).toBeInTheDocument(); + expect(screen.getByText('avg 70')).toBeInTheDocument(); + }); +}); + +describe('KPIGrid', () => { + it('renders formatted KPI values with suffixes and em-dash for null', () => { + render( + + ); + + expect(screen.getByText('120')).toBeInTheDocument(); + expect(screen.getByText('specs')).toBeInTheDocument(); + expect(screen.getByText('1.8K')).toBeInTheDocument(); + expect(screen.getByText('impls')).toBeInTheDocument(); + expect(screen.getByText('92.4%')).toBeInTheDocument(); + expect(screen.getByText('coverage')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + expect(screen.getByText('avg quality')).toBeInTheDocument(); + }); +}); + +describe('DailyActivityChart', () => { + it('renders activity header, date range endpoints, and handles empty data', () => { + const { rerender } = render( + + ); + + expect(screen.getByText('daily activity')).toBeInTheDocument(); + expect(screen.getByText('implementation updates · last 30 days')).toBeInTheDocument(); + expect(screen.getByText('2026-05-01')).toBeInTheDocument(); + expect(screen.getByText('2026-05-30')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('daily activity')).toBeInTheDocument(); + }); +}); + +describe('LibraryCoverage', () => { + it('sorts libraries by implementation count descending and formats scores and null fallbacks', () => { + render( + + ); + + expect(screen.getByText('libraries')).toBeInTheDocument(); + expect(screen.getByText('impl count · avg · min–max')).toBeInTheDocument(); + + const names = screen.getAllByText(/Matplotlib|Seaborn|Pygal/).map(el => el.textContent); + expect(names).toEqual(['Matplotlib', 'Seaborn', 'Pygal']); + + expect(screen.getByText('90')).toBeInTheDocument(); + expect(screen.getByText('93.4')).toBeInTheDocument(); + expect(screen.getByText('80–99')).toBeInTheDocument(); + expect(screen.getByText('72–96')).toBeInTheDocument(); + expect(screen.getByText('—–—')).toBeInTheDocument(); + }); +}); + +describe('RecentActivity', () => { + it('renders nothing when empty and lists recent implementation links when populated', () => { + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + + rerender( + + ); + + expect(screen.getByText('recent activity')).toBeInTheDocument(); + const links = screen.getAllByRole('link'); + expect(links[0]).toHaveAttribute('href', '/scatter-basic/python/matplotlib'); + expect(links[0]).toHaveTextContent('Basic Scatter'); + expect(links[0]).toHaveTextContent('95'); + expect(links[0]).toHaveTextContent('impl-generate'); + + expect(links[1]).toHaveAttribute('href', '/bar-grouped/javascript/d3'); + expect(links[1]).toHaveTextContent('Grouped Bar'); + }); +}); + +describe('CommonWeaknesses', () => { + it('renders nothing when empty and displays weakness tags across frequency ratios', () => { + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + + rerender( + + ); + + expect(screen.getByText('common weaknesses')).toBeInTheDocument(); + expect(screen.getByText('overlapping labels')).toBeInTheDocument(); + expect(screen.getByText('20')).toBeInTheDocument(); + expect(screen.getByText('missing units')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getByText('low contrast grid')).toBeInTheDocument(); + expect(screen.getByText('6')).toBeInTheDocument(); + expect(screen.getByText('clipped title')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); +}); + +describe('ProblemAreas', () => { + it('renders collapsible problem sections with counts and links to affected specs', () => { + render( + + ); + + expect(screen.getByText('problem areas')).toBeInTheDocument(); + expect(screen.getByText('low scores (avg < 90)')).toBeInTheDocument(); + expect(screen.getByText('missing previews')).toBeInTheDocument(); + expect(screen.getByText('missing tags')).toBeInTheDocument(); + expect(screen.getByText('oldest specs')).toBeInTheDocument(); + + const scatterLink = screen.getByRole('link', { name: /scatter-basic/ }); + expect(scatterLink).toHaveAttribute('href', '/scatter-basic'); + expect(scatterLink).toHaveTextContent('seaborn below 90'); + expect(scatterLink).toHaveTextContent('78.0'); + + const heatmapLink = screen.getByRole('link', { name: /heatmap-annotated/ }); + expect(heatmapLink).toHaveAttribute('href', '/heatmap-annotated'); + expect(heatmapLink).toHaveTextContent('no domain tag'); + }); +}); diff --git a/app/src/sections/debug/FeedbackSection.test.tsx b/app/src/sections/debug/FeedbackSection.test.tsx new file mode 100644 index 00000000000..8020d089d5c --- /dev/null +++ b/app/src/sections/debug/FeedbackSection.test.tsx @@ -0,0 +1,310 @@ +import { useEffect, useState } from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DEBUG_API_URL } from 'src/constants'; +import { fetchWithAuth } from 'src/lib/api'; +import { + FEEDBACK_STATUS_OPTIONS, + type FeedbackMessageFilter, + type FeedbackStatus, +} from 'src/sections/debug/constants'; +import { CopyClaudePromptButton } from 'src/sections/debug/CopyClaudePromptButton'; +import { type FeedbackMessage, FeedbackSection } from 'src/sections/debug/FeedbackSection'; +import { FeedbackTopList, type FeedbackTopPage } from 'src/sections/debug/FeedbackTopList'; +import { act, fireEvent, render, screen, userEvent, waitFor } from 'src/test-utils'; +import { colors } from 'src/theme'; + +const TOP_UP: FeedbackTopPage[] = [ + { path: '/scatter-basic', count: 14, last_seen: '2026-05-20T10:00:00Z' }, + { path: '/bar-grouped', count: 5, last_seen: null }, +]; + +const TOP_DOWN: FeedbackTopPage[] = [ + { path: '/heatmap-annotated', count: 3, last_seen: '2026-05-19T08:00:00Z' }, +]; + +const INITIAL_MESSAGES: FeedbackMessage[] = [ + { + id: 'fb-1', + reaction: 'bug', + message: 'Axis labels overlap on mobile', + contact: 'alice@example.com', + path: '/scatter-basic', + status: 'new', + created_at: '2026-05-21T09:00:00Z', + }, + { + id: 'fb-2', + reaction: 'idea', + message: 'Add dark mode export', + contact: null, + path: null, + status: 'in_progress', + created_at: '2026-05-20T09:00:00Z', + }, + { + id: 'fb-3', + reaction: 'thumbs_up', + message: null, + contact: null, + path: '/bar-grouped', + status: 'done', + created_at: '2026-05-19T09:00:00Z', + }, + { + id: 'fb-4', + reaction: 'thumbs_down', + message: 'Colors are hard to distinguish', + contact: null, + path: '/heatmap-annotated', + status: 'wont_solve', + created_at: '2026-05-18T09:00:00Z', + }, + { + id: 'fb-5', + reaction: null, + message: 'General note without reaction', + contact: null, + path: null, + status: 'new', + created_at: '2026-05-17T09:00:00Z', + }, +]; + +function StatefulFeedbackHarness({ token = 'test-admin-token' }: { token?: string }) { + const [filter, setFilter] = useState('open'); + const [messages, setMessages] = useState([]); + const [topUp, setTopUp] = useState([]); + const [topDown, setTopDown] = useState([]); + + useEffect(() => { + const qs = filter ? `?status=${filter}&limit=50` : '?limit=50'; + Promise.all([ + fetchWithAuth(`${DEBUG_API_URL}/debug/feedback/top?reaction=thumbs_up&limit=15`, token).then( + r => (r.ok ? (r.json() as Promise) : []) + ), + fetchWithAuth( + `${DEBUG_API_URL}/debug/feedback/top?reaction=thumbs_down&limit=15`, + token + ).then(r => (r.ok ? (r.json() as Promise) : [])), + fetchWithAuth(`${DEBUG_API_URL}/debug/feedback/messages${qs}`, token).then(r => + r.ok ? (r.json() as Promise) : [] + ), + ]).then(([up, down, msgs]) => { + setTopUp(up); + setTopDown(down); + setMessages(msgs); + }); + }, [filter, token]); + + const handleStatusChange = async (id: string, newStatus: string) => { + const prev = messages; + setMessages(prev.map(m => (m.id === id ? { ...m, status: newStatus as FeedbackStatus } : m))); + try { + const r = await fetchWithAuth(`${DEBUG_API_URL}/debug/feedback/${id}`, token, { + method: 'PATCH', + body: JSON.stringify({ status: newStatus }), + }); + if (!r.ok) throw new Error(`${r.status}`); + } catch { + setMessages(prev); + } + }; + + return ( + + ); +} + +describe('Feedback constants', () => { + it('lists the four feedback triage statuses in order', () => { + expect(FEEDBACK_STATUS_OPTIONS).toEqual(['new', 'in_progress', 'done', 'wont_solve']); + }); +}); + +describe('FeedbackTopList', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('shows empty state when no entries exist and renders links and counts when populated', () => { + const { rerender } = render( + + ); + expect(screen.getByText('👍 thumbs up')).toBeInTheDocument(); + expect(screen.getByText('no entries yet')).toBeInTheDocument(); + + rerender(); + expect(screen.queryByText('no entries yet')).not.toBeInTheDocument(); + expect(screen.getByText('14')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '/scatter-basic' })).toHaveAttribute( + 'href', + '/scatter-basic' + ); + expect(screen.getByRole('link', { name: '/bar-grouped' })).toHaveAttribute( + 'href', + '/bar-grouped' + ); + }); +}); + +describe('CopyClaudePromptButton', () => { + const originalLocation = window.location; + const originalClipboard = navigator.clipboard; + + afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('copies a formatted Claude Code prompt to the clipboard and resets feedback after timeout', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...originalLocation, + origin: 'https://anyplot.ai', + href: 'https://anyplot.ai/debug', + }, + }); + + render( + + ); + + const button = screen.getByRole('button', { name: 'Copy Claude Code prompt' }); + fireEvent.mouseOver(button); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.getByRole('tooltip')).toHaveTextContent('copy prompt for Claude Code'); + + await act(async () => { + fireEvent.click(button); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0][0]).toContain('https://anyplot.ai/scatter-basic'); + expect(writeText.mock.calls[0][0]).toContain('bug report'); + expect(writeText.mock.calls[0][0]).toContain('> Legend overlaps data points'); + + // Tooltip text updates to the copied confirmation until the 2000ms timer expires + expect(screen.getByRole('tooltip')).toHaveTextContent('copied — paste into Claude Code'); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(screen.getByRole('tooltip')).toHaveTextContent('copy prompt for Claude Code'); + }); +}); + +describe('FeedbackSection', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('renders messages with reactions, contact info, status filter changes, and status updates via fetchWithAuth', async () => { + const user = userEvent.setup(); + let patchShouldFail = false; + + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (url.includes('/debug/feedback/top?reaction=thumbs_up')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(TOP_UP) }); + } + if (url.includes('/debug/feedback/top?reaction=thumbs_down')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(TOP_DOWN) }); + } + if (url.includes('/debug/feedback/messages')) { + const parsed = new URL(url, 'https://api.anyplot.ai'); + const status = parsed.searchParams.get('status'); + if (status === 'open') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(INITIAL_MESSAGES), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + } + if (init?.method === 'PATCH') { + if (patchShouldFail) { + return Promise.resolve({ ok: false, status: 500 }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'ok' }) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + + // Wait for initial messages to render + expect(await screen.findByText('Axis labels overlap on mobile')).toBeInTheDocument(); + expect(screen.getByText('contact: alice@example.com')).toBeInTheDocument(); + expect(screen.getByText('Add dark mode export')).toBeInTheDocument(); + expect(screen.getByText('Colors are hard to distinguish')).toBeInTheDocument(); + expect(screen.getByText('General note without reaction')).toBeInTheDocument(); + + // Reaction glyphs rendered + expect(screen.getByText('🪲')).toBeInTheDocument(); + expect(screen.getByText('💡')).toBeInTheDocument(); + expect(screen.getByText('👍')).toBeInTheDocument(); + expect(screen.getByText('👎')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + + // Change the status of the first message (fb-1) from 'new' to 'done' + const selects = screen.getAllByRole('combobox'); + // selects[0] is the section filter; selects[1] is fb-1's status select + const firstMsgSelect = selects[1]; + expect(firstMsgSelect).toHaveValue('new'); + + await user.selectOptions(firstMsgSelect, 'done'); + expect(firstMsgSelect).toHaveValue('done'); + + const patchCall = fetchMock.mock.calls.find(call => call[1]?.method === 'PATCH'); + expect(patchCall?.[0]).toContain('/debug/feedback/fb-1'); + expect(patchCall?.[1]?.body).toBe(JSON.stringify({ status: 'done' })); + + // Failed PATCH reverts optimistic status update + patchShouldFail = true; + await user.selectOptions(firstMsgSelect, 'wont_solve'); + await waitFor(() => { + expect(firstMsgSelect).toHaveValue('done'); + }); + + // Filtering by a status with no messages shows the status-specific empty message + const filterSelect = selects[0]; + await user.selectOptions(filterSelect, 'done'); + expect(await screen.findByText('no messages with status "done"')).toBeInTheDocument(); + + // Filtering by all statuses ('') with no messages shows generic empty message + await user.selectOptions(filterSelect, ''); + expect(await screen.findByText('no messages')).toBeInTheDocument(); + }); +}); From bebc83b7bd498a60814a089644b2d6c441475e68 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:31:13 +0100 Subject: [PATCH 07/14] test(map): add render tests for MapLegend, MapHoverPanel, and MapWeights Step 1 of 3: Render tests for MapLegend, MapHoverPanel, and MapWeights. Brief: section-tests-3 Co-Authored-By: Antigravity (Gemini 4 Argon) --- app/src/sections/map/MapHoverPanel.test.tsx | 95 ++++++++++++++++++ app/src/sections/map/MapLegend.test.tsx | 106 ++++++++++++++++++++ app/src/sections/map/MapWeights.test.tsx | 90 +++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 app/src/sections/map/MapHoverPanel.test.tsx create mode 100644 app/src/sections/map/MapLegend.test.tsx create mode 100644 app/src/sections/map/MapWeights.test.tsx diff --git a/app/src/sections/map/MapHoverPanel.test.tsx b/app/src/sections/map/MapHoverPanel.test.tsx new file mode 100644 index 00000000000..7fb308f3adf --- /dev/null +++ b/app/src/sections/map/MapHoverPanel.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MapPanelData } from 'src/sections/map/useMapSelection'; +import { render, screen } from 'src/test-utils'; + +import { MapHoverPanel } from './MapHoverPanel'; + +const mockIsDark = { current: false }; + +vi.mock('src/hooks/useLayoutContext', () => ({ + useTheme: () => ({ isDark: mockIsDark.current }), +})); + +function makePanelData(overrides: Partial = {}): MapPanelData { + return { + title: 'Basic Scatter Plot', + plotType: 'scatter', + chipColor: '#009E73', + tags: ['domain:finance', 'feature:annotated'], + previewUrl: 'https://example.com/scatter_800.webp', + ...overrides, + }; +} + +describe('MapHoverPanel', () => { + beforeEach(() => { + mockIsDark.current = false; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('stays aria-hidden and empty when nothing is hovered or pinned', () => { + render(); + + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + const hiddenAside = screen.getByRole('complementary', { hidden: true }); + expect(hiddenAside).toHaveAttribute('aria-hidden', 'true'); + expect(hiddenAside).toBeEmptyDOMElement(); + }); + + it('reveals the panel with preview image, title, plot type, and tags when a node is hovered', () => { + const panelData = makePanelData(); + + render(); + + const aside = screen.getByRole('complementary'); + expect(aside).toHaveAttribute('aria-hidden', 'false'); + expect(screen.getByText('Basic Scatter Plot')).toBeInTheDocument(); + expect(screen.getByText('scatter')).toBeInTheDocument(); + expect(screen.getByText('domain:finance')).toBeInTheDocument(); + expect(screen.getByText('feature:annotated')).toBeInTheDocument(); + + const img = aside.querySelector('img'); + expect(img).not.toBeNull(); + expect(img).toHaveAttribute('src', 'https://example.com/scatter_800.webp'); + expect(img).toHaveAttribute('alt', ''); + }); + + it('reveals the panel without an img element when a node is pinned and has no previewUrl', () => { + mockIsDark.current = true; + const panelData = makePanelData({ + title: 'Pinned Line Plot', + plotType: 'line', + chipColor: null, + tags: [], + previewUrl: null, + }); + + render(); + + const aside = screen.getByRole('complementary'); + expect(aside).toHaveAttribute('aria-hidden', 'false'); + expect(screen.getByText('Pinned Line Plot')).toBeInTheDocument(); + expect(screen.getByText('line')).toBeInTheDocument(); + expect(aside.querySelector('img')).toBeNull(); + }); + + it('omits the plot type row when plotType is null and renders in dark mode with a preview', () => { + mockIsDark.current = true; + const panelData = makePanelData({ + title: 'Custom Chart', + plotType: null, + tags: [], + }); + + render(); + + expect(screen.getByRole('complementary')).toHaveAttribute('aria-hidden', 'false'); + expect(screen.getByText('Custom Chart')).toBeInTheDocument(); + expect(screen.queryByText('scatter')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/map/MapLegend.test.tsx b/app/src/sections/map/MapLegend.test.tsx new file mode 100644 index 00000000000..0d447290bc8 --- /dev/null +++ b/app/src/sections/map/MapLegend.test.tsx @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { MapGraphData } from 'src/hooks/useForceGraphSimulation'; +import { fireEvent, render, screen } from 'src/test-utils'; + +import { MapLegend } from './MapLegend'; + +function makeGraphData(overrides: Partial = {}): MapGraphData { + return { + nodes: [], + links: [], + topTypes: ['scatter', 'bar'], + typeCounts: new Map([ + ['scatter', 14], + ['bar', 7], + ]), + idf: new Map(), + ...overrides, + }; +} + +describe('MapLegend', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('renders nothing when topTypes is empty', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /legend/i })).not.toBeInTheDocument(); + expect(screen.queryByText('plot_type')).not.toBeInTheDocument(); + }); + + it('renders the active category and each top type with its count', () => { + const graphData = makeGraphData({ + topTypes: ['scatter', 'bar', 'violin'], + typeCounts: new Map([ + ['scatter', 14], + ['bar', 7], + ]), + }); + + render( + + ); + + expect(screen.getByText('plot_type')).toBeInTheDocument(); + expect(screen.getByText('scatter')).toBeInTheDocument(); + expect(screen.getByText('14')).toBeInTheDocument(); + expect(screen.getByText('bar')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + expect(screen.getByText('violin')).toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('toggles the mobile legend button between closed and open states', () => { + render( + + ); + + const toggle = screen.getByRole('button', { name: 'legend ▸' }); + fireEvent.click(toggle); + expect(screen.getByRole('button', { name: 'legend ▾' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'legend ▾' })); + expect(screen.getByRole('button', { name: 'legend ▸' })).toBeInTheDocument(); + }); + + it('calls setHoverType on mouse enter and clears it on mouse leave', () => { + const setHoverType = vi.fn(); + + render( + + ); + + const barLabel = screen.getByText('bar'); + fireEvent.mouseEnter(barLabel); + expect(setHoverType).toHaveBeenCalledWith('bar'); + + fireEvent.mouseLeave(barLabel); + expect(setHoverType).toHaveBeenCalledWith(null); + }); +}); diff --git a/app/src/sections/map/MapWeights.test.tsx b/app/src/sections/map/MapWeights.test.tsx new file mode 100644 index 00000000000..6a00802b230 --- /dev/null +++ b/app/src/sections/map/MapWeights.test.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_CATEGORY_WEIGHT, TAG_CATEGORIES } from 'src/pages/MapPage.helpers'; +import { fireEvent, render, screen } from 'src/test-utils'; + +import { MapWeights } from './MapWeights'; + +describe('MapWeights', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('starts collapsed and opens on toggle click to show all category sliders and threshold', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'weights ▸' })).toBeInTheDocument(); + expect(screen.queryAllByRole('slider')).toHaveLength(0); + expect(screen.queryByRole('button', { name: 'reset' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'weights ▸' })); + + expect(screen.getByRole('button', { name: 'weights ▾' })).toBeInTheDocument(); + expect(screen.getAllByRole('slider')).toHaveLength(TAG_CATEGORIES.length + 1); + for (const cat of TAG_CATEGORIES) { + expect(screen.getByText(cat)).toBeInTheDocument(); + } + expect(screen.getByText('threshold')).toBeInTheDocument(); + expect(screen.getByText('2.0')).toBeInTheDocument(); + expect(screen.getByText('0.15')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'weights ▾' })); + expect(screen.queryAllByRole('slider')).toHaveLength(0); + }); + + it('calls setWeights, setMinSim, and resetWeights when controls are used', () => { + const setWeightsSpy = vi.fn(); + const setMinSimSpy = vi.fn(); + const resetWeightsSpy = vi.fn(); + + function Harness() { + const [weights, setWeights] = useState>({ + ...DEFAULT_CATEGORY_WEIGHT, + }); + const [minSim, setMinSim] = useState(0.15); + return ( + { + setWeightsSpy(updater); + setWeights(updater); + }} + minSim={minSim} + setMinSim={val => { + setMinSimSpy(val); + setMinSim(val); + }} + resetWeights={resetWeightsSpy} + /> + ); + } + + render(); + fireEvent.click(screen.getByRole('button', { name: 'weights ▸' })); + + const sliders = screen.getAllByRole('slider'); + // First slider corresponds to plot_type (first entry in TAG_CATEGORIES) + fireEvent.change(sliders[0], { target: { value: '3.5' } }); + expect(setWeightsSpy).toHaveBeenCalled(); + expect(screen.getByText('3.5')).toBeInTheDocument(); + + // Last slider corresponds to the edge threshold + fireEvent.change(sliders[sliders.length - 1], { target: { value: '0.25' } }); + expect(setMinSimSpy).toHaveBeenCalledWith(0.25); + expect(screen.getByText('0.25')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'reset' })); + expect(resetWeightsSpy).toHaveBeenCalledTimes(1); + }); +}); From 1ff545b27094f044c46255286ed60d4a4671d67e Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:32:27 +0100 Subject: [PATCH 08/14] test(map): add render and keyboard tests for MapSearchPill Step 2 of 3: Render and keyboard tests for MapSearchPill. Brief: section-tests-3 Co-Authored-By: Antigravity (Gemini 4 Argon) --- app/src/sections/map/MapSearchPill.test.tsx | 180 ++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 app/src/sections/map/MapSearchPill.test.tsx diff --git a/app/src/sections/map/MapSearchPill.test.tsx b/app/src/sections/map/MapSearchPill.test.tsx new file mode 100644 index 00000000000..c31872ab955 --- /dev/null +++ b/app/src/sections/map/MapSearchPill.test.tsx @@ -0,0 +1,180 @@ +import { useRef, useState } from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { SpecMapItem } from 'src/pages/MapPage.helpers'; +import { act, fireEvent, render, screen } from 'src/test-utils'; + +import { MapSearchPill } from './MapSearchPill'; + +const mockMatches: SpecMapItem[] = [ + { + id: 'scatter-basic', + title: 'Basic Scatter Plot', + preview_url_light: null, + preview_url_dark: null, + quality_score: 90, + tags: { plot_type: ['scatter'] }, + impl_tags: null, + }, + { + id: 'line-basic', + title: 'Basic Line Chart', + preview_url_light: null, + preview_url_dark: null, + quality_score: 92, + tags: { plot_type: ['line'] }, + impl_tags: null, + }, +]; + +interface HarnessProps { + initialQuery?: string; + initialOpen?: boolean; + initialIdx?: number; + matches?: SpecMapItem[]; + onSelectMatch?: (spec: SpecMapItem) => void; +} + +function SearchPillHarness({ + initialQuery = '', + initialOpen = false, + initialIdx = 0, + matches = mockMatches, + onSelectMatch = vi.fn(), +}: HarnessProps) { + const [searchQuery, setSearchQuery] = useState(initialQuery); + const [searchOpen, setSearchOpen] = useState(initialOpen); + const [searchIdx, setSearchIdx] = useState(initialIdx); + const searchInputRef = useRef(null); + + return ( + + ); +} + +describe('MapSearchPill', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('renders the search input and hides the dropdown when closed or when query is blank', () => { + render(); + + const input = screen.getByPlaceholderText('specs.search()'); + expect(input).toHaveValue('scatter'); + expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument(); + }); + + it('opens on focus, shows matching results, and closes after blur timeout', () => { + vi.useFakeTimers(); + render(); + + const input = screen.getByPlaceholderText('specs.search()'); + fireEvent.focus(input); + + expect(screen.getByText('Basic Scatter Plot')).toBeInTheDocument(); + expect(screen.getByText('scatter-basic')).toBeInTheDocument(); + expect(screen.getByText('Basic Line Chart')).toBeInTheDocument(); + expect(screen.getByText('line-basic')).toBeInTheDocument(); + + fireEvent.blur(input); + act(() => { + vi.advanceTimersByTime(150); + }); + expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument(); + }); + + it('shows the empty state when searchOpen is true with a non-empty query and no matches', () => { + render(); + + expect(screen.getByLabelText('No matches')).toHaveTextContent('results.empty()'); + }); + + it('navigates results with ArrowDown/ArrowUp, selects with Enter, and clears with Escape', () => { + const selectMatch = vi.fn(); + render( + + ); + + const input = screen.getByPlaceholderText('specs.search()'); + input.focus(); + + // Move down to index 1, clamp at the last item, then press Enter + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(selectMatch).toHaveBeenCalledTimes(1); + expect(selectMatch).toHaveBeenLastCalledWith(mockMatches[1]); + + // Move back up to index 0, clamp at 0, then press Enter + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(selectMatch).toHaveBeenCalledTimes(2); + expect(selectMatch).toHaveBeenLastCalledWith(mockMatches[0]); + + // Typing resets index to 0 and updates the query + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.change(input, { target: { value: 'line' } }); + expect(input).toHaveValue('line'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(selectMatch).toHaveBeenLastCalledWith(mockMatches[0]); + + // Escape clears the query and blurs the input + fireEvent.keyDown(input, { key: 'Escape' }); + expect(input).toHaveValue(''); + expect(document.activeElement).not.toBe(input); + }); + + it('selects a result on mouseDown and updates active index on mouseEnter', () => { + const selectMatch = vi.fn(); + render( + + ); + + const input = screen.getByPlaceholderText('specs.search()'); + const secondItem = screen.getByText('Basic Line Chart'); + + fireEvent.mouseEnter(secondItem); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(selectMatch).toHaveBeenCalledWith(mockMatches[1]); + + selectMatch.mockClear(); + fireEvent.mouseDown(screen.getByText('Basic Scatter Plot')); + expect(selectMatch).toHaveBeenCalledWith(mockMatches[0]); + }); + + it('does not call selectMatch on Enter when there are no matches', () => { + const selectMatch = vi.fn(); + render( + + ); + + const input = screen.getByPlaceholderText('specs.search()'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(selectMatch).not.toHaveBeenCalled(); + }); +}); From 46458a4efcc802c50e84c7085547684f653bd3c4 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:34:27 +0100 Subject: [PATCH 09/14] test(map): add render tests for MapCanvas with ForceGraph2D stubbed Step 3 of 3: Render tests for MapCanvas with ForceGraph2D stubbed. Brief: section-tests-3 Co-Authored-By: Antigravity (Gemini 4 Argon) --- app/src/sections/map/MapCanvas.test.tsx | 263 ++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 app/src/sections/map/MapCanvas.test.tsx diff --git a/app/src/sections/map/MapCanvas.test.tsx b/app/src/sections/map/MapCanvas.test.tsx new file mode 100644 index 00000000000..1a4e53c7fe4 --- /dev/null +++ b/app/src/sections/map/MapCanvas.test.tsx @@ -0,0 +1,263 @@ +import { createRef, forwardRef, useImperativeHandle } from 'react'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MapForceConfig, MapGraphData } from 'src/hooks/useForceGraphSimulation'; +import type { MapLink, MapNode } from 'src/pages/MapPage.helpers'; +import { render, screen } from 'src/test-utils'; + +import { MapCanvas } from './MapCanvas'; + +const mockIsDark = { current: false }; + +vi.mock('src/hooks/useLayoutContext', () => ({ + useTheme: () => ({ isDark: mockIsDark.current }), +})); + +type FgProps = Record; +const lastFgProps: { current: FgProps | null } = { current: null }; + +interface FgInstance { + centerAt: ReturnType; + zoom: ReturnType; + d3Force: ReturnType; + d3ReheatSimulation: ReturnType; + refresh: ReturnType; + __forcesWired?: boolean; +} + +const fgInstance: FgInstance = { + centerAt: vi.fn(), + zoom: vi.fn(), + d3Force: vi.fn(), + d3ReheatSimulation: vi.fn(), + refresh: vi.fn(), +}; + +vi.mock('react-force-graph-2d', () => ({ + default: forwardRef((props, ref) => { + lastFgProps.current = props; + useImperativeHandle(ref, () => fgInstance, []); + const data = props.graphData as { nodes: unknown[]; links: unknown[] }; + return ( +
+ ); + }), +})); + +function makeNode( + overrides: Partial & { id: string; x?: number; y?: number } +): MapNode & { x?: number; y?: number } { + return { + title: overrides.id, + tags: [], + thumbUrl: null, + imgs: new Map(), + pendingTiers: new Set(), + colorBucket: 'scatter', + ...overrides, + }; +} + +function makeForceConfig(): MapForceConfig { + return { + cooldownTicks: 300, + alphaDecay: 0.015, + alphaMin: 0.01, + velocityDecay: 0.35, + chargeStrength: -50, + linkDistance: () => 80, + linkStrength: () => 0.2, + centerGravity: 0.04, + createCollideForce: vi.fn(), + createOutlierSquashForce: vi.fn(), + }; +} + +function makeProps(overrides: Partial[0]> = {}) { + const n1 = makeNode({ id: 'scatter-basic', title: 'Basic Scatter Plot', x: 10, y: 20 }); + const n2 = makeNode({ + id: 'line-basic', + title: 'Basic Line Chart', + colorBucket: 'line', + x: 110, + y: 120, + }); + const nodes = [n1, n2]; + const links: MapLink[] = [{ source: 'scatter-basic', target: 'line-basic', weight: 0.6 }]; + const graphData: MapGraphData = { + nodes, + links, + topTypes: ['scatter', 'line'], + typeCounts: new Map([ + ['scatter', 1], + ['line', 1], + ]), + idf: new Map(), + }; + const fgRef = createRef(); + + return { + ready: true, + fgRef, + graphData, + size: { w: 800, h: 600 }, + forceConfig: makeForceConfig(), + hoverId: null, + hoverType: null, + nodeById: new Map(nodes.map(n => [n.id, n])), + neighbors: new Map([ + ['scatter-basic', new Set(['line-basic'])], + ['line-basic', new Set(['scatter-basic'])], + ]), + onNodeClick: vi.fn(), + setPinnedId: vi.fn(), + setHoverId: vi.fn(), + onNodeHover: vi.fn(), + markSettled: vi.fn(), + handleEngineTick: vi.fn(), + settled: false, + tickProgress: 0, + ...overrides, + }; +} + +describe('MapCanvas', () => { + beforeEach(() => { + mockIsDark.current = false; + lastFgProps.current = null; + fgInstance.centerAt.mockReset(); + fgInstance.zoom.mockReset(); + fgInstance.d3Force + .mockReset() + .mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }); + fgInstance.d3ReheatSimulation.mockReset(); + fgInstance.refresh.mockReset(); + fgInstance.__forcesWired = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('renders nothing before ready is true', () => { + render(); + + expect(screen.queryByTestId('force-graph-2d')).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows the settling overlay with progress while cooling and hides it from assistive tech once settled', () => { + const { rerender } = render( + + ); + + const status = screen.getByRole('status', { name: 'Computing map layout' }); + expect(status).toHaveTextContent('map.simulate()'); + + const progressBar = screen.getByRole('progressbar', { name: 'Layout computation progress' }); + expect(progressBar).toHaveAttribute('aria-valuenow', '45'); + + mockIsDark.current = true; + rerender(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('passes graphData, dimensions, and click/hover/tick handlers to ForceGraph2D', () => { + const props = makeProps(); + render(); + + const fg = screen.getByTestId('force-graph-2d'); + expect(fg).toHaveAttribute('data-node-count', '2'); + expect(fg).toHaveAttribute('data-link-count', '1'); + + const captured = lastFgProps.current!; + expect(captured.graphData).toBe(props.graphData); + expect(captured.width).toBe(800); + expect(captured.height).toBe(600); + expect(captured.onNodeClick).toBe(props.onNodeClick); + expect(captured.onNodeHover).toBe(props.onNodeHover); + expect(captured.onEngineTick).toBe(props.handleEngineTick); + + const nodeLabel = captured.nodeLabel as (n: MapNode) => string; + expect(nodeLabel(props.graphData.nodes[0])).toBe('Basic Scatter Plot'); + + const onBackgroundClick = captured.onBackgroundClick as () => void; + onBackgroundClick(); + expect(props.setPinnedId).toHaveBeenCalledWith(null); + expect(props.setHoverId).toHaveBeenCalledWith(null); + }); + + it('frames the cluster on engine stop and wires d3 forces once in onRenderFramePre', () => { + const props = makeProps(); + render(); + + const captured = lastFgProps.current!; + const onEngineStop = captured.onEngineStop as () => void; + onEngineStop(); + + expect(fgInstance.centerAt).toHaveBeenCalledWith(expect.any(Number), expect.any(Number), 0); + expect(fgInstance.zoom).toHaveBeenCalledWith(expect.any(Number), 0); + expect(props.markSettled).toHaveBeenCalledTimes(1); + + const onRenderFramePre = captured.onRenderFramePre as () => void; + onRenderFramePre(); + expect(fgInstance.d3Force).toHaveBeenCalledWith('charge'); + expect(fgInstance.d3Force).toHaveBeenCalledWith('link'); + expect(fgInstance.d3Force).toHaveBeenCalledWith('collide', undefined); + expect(fgInstance.d3Force).toHaveBeenCalledWith('center'); + expect(fgInstance.d3Force).toHaveBeenCalledWith('outlier-squash', undefined); + expect(fgInstance.d3ReheatSimulation).toHaveBeenCalledTimes(1); + + // Second frame is a no-op because __forcesWired is now true + onRenderFramePre(); + expect(fgInstance.d3ReheatSimulation).toHaveBeenCalledTimes(1); + }); + + it('computes linkColor and linkWidth across hoverId and hoverType states', () => { + const props = makeProps({ hoverId: 'scatter-basic', hoverType: 'scatter' }); + const { rerender } = render(); + + const linkColor = lastFgProps.current!.linkColor as (l: unknown) => string; + const linkWidth = lastFgProps.current!.linkWidth as (l: unknown) => number; + + const involvedColor = linkColor({ + source: 'scatter-basic', + target: { id: 'line-basic' }, + weight: 0.6, + }); + expect(involvedColor).toMatch(/^#/); + expect( + linkWidth({ source: 'scatter-basic', target: 'line-basic', weight: 0.6 }) + ).toBeGreaterThan(linkWidth({ source: 'other-a', target: 'other-b', weight: 0.6 })); + + // Uninvolved link while hoverId is active (hoverType null) + rerender(); + const uninvolvedHoverColor = (lastFgProps.current!.linkColor as (l: unknown) => string)({ + source: 'line-basic', + target: 'other', + weight: 0.5, + }); + expect(uninvolvedHoverColor).toContain('rgba'); + + // hoverType active without hoverId + rerender(); + const hoverTypeLinkColor = lastFgProps.current!.linkColor as (l: unknown) => string; + const intraColor = hoverTypeLinkColor({ + source: 'scatter-basic', + target: 'scatter-basic', + weight: 0.5, + }); + const interColor = hoverTypeLinkColor({ + source: 'scatter-basic', + target: 'line-basic', + weight: 0.5, + }); + expect(intraColor).not.toBe(interColor); + }); +}); From 2fc363c3660c2b9bf5e0becb20289bf2e7698ece Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:44:19 +0100 Subject: [PATCH 10/14] docs(changelog): add fragment for section-tests Brief: section-tests Co-Authored-By: Antigravity (Gemini 4 Argon) --- changelog.d/section-tests.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 changelog.d/section-tests.md diff --git a/changelog.d/section-tests.md b/changelog.d/section-tests.md new file mode 100644 index 00000000000..6a3f7255fe1 --- /dev/null +++ b/changelog.d/section-tests.md @@ -0,0 +1,10 @@ +### Added + +- **Render and unit tests for the palette, debug, and map section components.** + Add co-located Vitest suites under `app/src/sections/palette/`, + `app/src/sections/debug/`, and `app/src/sections/map/` covering the section + modules extracted from `PalettePage`, `DebugPage`, and `MapPage`, including + the hue–chroma wheel, comparison palette and sort toggles, swatch copy + timers, snippet tabs, spec matrix sorting and filters, feedback triage + status updates, and the map legend, hover panel, search pill, force weights, + and canvas shell. From aa80c44e1df01557f8e0b4fabb9ad3ba91ac64e1 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:10:17 +0100 Subject: [PATCH 11/14] test(map): tighten assertions and timer handling in map section tests Step 1 of 1: address Review Round 1 feedback on MapWeights, MapSearchPill, MapCanvas, and MapHoverPanel tests. Brief: section-tests-3 Co-Authored-By: Antigravity (Gemini 4 Argon) --- app/src/sections/map/MapCanvas.test.tsx | 41 ++++++++++++++++----- app/src/sections/map/MapHoverPanel.test.tsx | 2 +- app/src/sections/map/MapSearchPill.test.tsx | 9 ++++- app/src/sections/map/MapWeights.test.tsx | 1 + 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/app/src/sections/map/MapCanvas.test.tsx b/app/src/sections/map/MapCanvas.test.tsx index 1a4e53c7fe4..6f8c5937e87 100644 --- a/app/src/sections/map/MapCanvas.test.tsx +++ b/app/src/sections/map/MapCanvas.test.tsx @@ -63,6 +63,15 @@ function makeNode( }; } +const collideForceSentinel: ReturnType = { + initialize: () => {}, + radius: () => collideForceSentinel, + iterations: () => collideForceSentinel, + strength: () => collideForceSentinel, + distance: () => collideForceSentinel, +}; +const outlierSquashSentinel = Object.assign(() => {}, { initialize: () => {} }); + function makeForceConfig(): MapForceConfig { return { cooldownTicks: 300, @@ -73,8 +82,8 @@ function makeForceConfig(): MapForceConfig { linkDistance: () => 80, linkStrength: () => 0.2, centerGravity: 0.04, - createCollideForce: vi.fn(), - createOutlierSquashForce: vi.fn(), + createCollideForce: vi.fn(() => collideForceSentinel), + createOutlierSquashForce: vi.fn(() => outlierSquashSentinel), }; } @@ -180,12 +189,24 @@ describe('MapCanvas', () => { expect(captured.graphData).toBe(props.graphData); expect(captured.width).toBe(800); expect(captured.height).toBe(600); - expect(captured.onNodeClick).toBe(props.onNodeClick); - expect(captured.onNodeHover).toBe(props.onNodeHover); - expect(captured.onEngineTick).toBe(props.handleEngineTick); + + const firstNode = props.graphData.nodes[0]; + const onNodeHover = captured.onNodeHover as (n: MapNode | null) => void; + onNodeHover(firstNode); + expect(props.onNodeHover).toHaveBeenCalledWith(firstNode); + onNodeHover(null); + expect(props.onNodeHover).toHaveBeenCalledWith(null); + + const onNodeClick = captured.onNodeClick as (n: MapNode) => void; + onNodeClick(firstNode); + expect(props.onNodeClick).toHaveBeenCalledWith(firstNode); + + const onEngineTick = captured.onEngineTick as () => void; + onEngineTick(); + expect(props.handleEngineTick).toHaveBeenCalledTimes(1); const nodeLabel = captured.nodeLabel as (n: MapNode) => string; - expect(nodeLabel(props.graphData.nodes[0])).toBe('Basic Scatter Plot'); + expect(nodeLabel(firstNode)).toBe('Basic Scatter Plot'); const onBackgroundClick = captured.onBackgroundClick as () => void; onBackgroundClick(); @@ -201,17 +222,17 @@ describe('MapCanvas', () => { const onEngineStop = captured.onEngineStop as () => void; onEngineStop(); - expect(fgInstance.centerAt).toHaveBeenCalledWith(expect.any(Number), expect.any(Number), 0); - expect(fgInstance.zoom).toHaveBeenCalledWith(expect.any(Number), 0); + expect(fgInstance.centerAt).toHaveBeenCalledWith(60, 70, 0); + expect(fgInstance.zoom).toHaveBeenCalledWith(4.8, 0); expect(props.markSettled).toHaveBeenCalledTimes(1); const onRenderFramePre = captured.onRenderFramePre as () => void; onRenderFramePre(); expect(fgInstance.d3Force).toHaveBeenCalledWith('charge'); expect(fgInstance.d3Force).toHaveBeenCalledWith('link'); - expect(fgInstance.d3Force).toHaveBeenCalledWith('collide', undefined); + expect(fgInstance.d3Force).toHaveBeenCalledWith('collide', collideForceSentinel); expect(fgInstance.d3Force).toHaveBeenCalledWith('center'); - expect(fgInstance.d3Force).toHaveBeenCalledWith('outlier-squash', undefined); + expect(fgInstance.d3Force).toHaveBeenCalledWith('outlier-squash', outlierSquashSentinel); expect(fgInstance.d3ReheatSimulation).toHaveBeenCalledTimes(1); // Second frame is a no-op because __forcesWired is now true diff --git a/app/src/sections/map/MapHoverPanel.test.tsx b/app/src/sections/map/MapHoverPanel.test.tsx index 7fb308f3adf..6ee74db40e7 100644 --- a/app/src/sections/map/MapHoverPanel.test.tsx +++ b/app/src/sections/map/MapHoverPanel.test.tsx @@ -78,7 +78,7 @@ describe('MapHoverPanel', () => { expect(aside.querySelector('img')).toBeNull(); }); - it('omits the plot type row when plotType is null and renders in dark mode with a preview', () => { + it('omits the plot type row when plotType is null', () => { mockIsDark.current = true; const panelData = makePanelData({ title: 'Custom Chart', diff --git a/app/src/sections/map/MapSearchPill.test.tsx b/app/src/sections/map/MapSearchPill.test.tsx index c31872ab955..72a6595c0d8 100644 --- a/app/src/sections/map/MapSearchPill.test.tsx +++ b/app/src/sections/map/MapSearchPill.test.tsx @@ -71,11 +71,16 @@ describe('MapSearchPill', () => { }); it('renders the search input and hides the dropdown when closed or when query is blank', () => { - render(); + const { unmount } = render(); const input = screen.getByPlaceholderText('specs.search()'); expect(input).toHaveValue('scatter'); expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument(); + unmount(); + + render(); + expect(screen.getByPlaceholderText('specs.search()')).toHaveValue(' '); + expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument(); }); it('opens on focus, shows matching results, and closes after blur timeout', () => { @@ -104,6 +109,7 @@ describe('MapSearchPill', () => { }); it('navigates results with ArrowDown/ArrowUp, selects with Enter, and clears with Escape', () => { + vi.useFakeTimers(); const selectMatch = vi.fn(); render( { fireEvent.keyDown(input, { key: 'Escape' }); expect(input).toHaveValue(''); expect(document.activeElement).not.toBe(input); + expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument(); }); it('selects a result on mouseDown and updates active index on mouseEnter', () => { diff --git a/app/src/sections/map/MapWeights.test.tsx b/app/src/sections/map/MapWeights.test.tsx index 6a00802b230..f7091ab2b5e 100644 --- a/app/src/sections/map/MapWeights.test.tsx +++ b/app/src/sections/map/MapWeights.test.tsx @@ -77,6 +77,7 @@ describe('MapWeights', () => { // First slider corresponds to plot_type (first entry in TAG_CATEGORIES) fireEvent.change(sliders[0], { target: { value: '3.5' } }); expect(setWeightsSpy).toHaveBeenCalled(); + expect(sliders[0]).toHaveAttribute('aria-valuenow', '3.5'); expect(screen.getByText('3.5')).toBeInTheDocument(); // Last slider corresponds to the edge threshold From 83e89d4fafcc0f7e71643f5dd5b2ab0fa0a04ef8 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:13:31 +0100 Subject: [PATCH 12/14] test(palette): add PalettePage test and test section contracts directly Render the real PalettePage to verify 1,499 ms/1,500 ms copy timing, CVD-optimal vs imprint sorting, state wiring, and pageview analytics; replace copied page logic in PaletteGridSection and SemanticAnchorsSection tests with prop contract assertions; drop layout constant restatements in paletteHelpers.test.ts. Brief: section-tests-1 Co-Authored-By: Antigravity (Gemini 4 Argon) --- app/src/pages/PalettePage.test.tsx | 162 ++++++++++++++++++ .../palette/PaletteGridSection.test.tsx | 131 ++++++-------- .../palette/SemanticAnchorsSection.test.tsx | 55 ++---- .../palette/WcagContrastSection.test.tsx | 15 -- .../sections/palette/paletteHelpers.test.ts | 11 -- 5 files changed, 230 insertions(+), 144 deletions(-) create mode 100644 app/src/pages/PalettePage.test.tsx diff --git a/app/src/pages/PalettePage.test.tsx b/app/src/pages/PalettePage.test.tsx new file mode 100644 index 00000000000..f57e77bf030 --- /dev/null +++ b/app/src/pages/PalettePage.test.tsx @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PalettePage } from 'src/pages/PalettePage'; +import { paths } from 'src/routes/paths'; +import { act, fireEvent, render, screen, within } from 'src/test-utils'; + +const mockTrackPageview = vi.fn(); +const mockIsDark = { current: false }; + +vi.mock('src/hooks', () => ({ + useAnalytics: () => ({ + trackPageview: mockTrackPageview, + }), + useTheme: () => ({ + isDark: mockIsDark.current, + }), +})); + +const IMPRINT_LABELS = [ + 'Copy #009E73 (brand green) to clipboard', + 'Copy #C475FD (lavender) to clipboard', + 'Copy #4467A3 (blue) to clipboard', + 'Copy #BD8233 (ochre) to clipboard', + 'Copy #AE3030 (matte red) to clipboard', + 'Copy #2ABCCD (cyan) to clipboard', + 'Copy #954477 (rose) to clipboard', + 'Copy #99B314 (lime) to clipboard', +]; + +const CVD_OPTIMAL_LABELS = [ + 'Copy #009E73 (brand green) to clipboard', + 'Copy #C475FD (lavender) to clipboard', + 'Copy #99B314 (lime) to clipboard', + 'Copy #954477 (rose) to clipboard', + 'Copy #AE3030 (matte red) to clipboard', + 'Copy #2ABCCD (cyan) to clipboard', + 'Copy #4467A3 (blue) to clipboard', + 'Copy #BD8233 (ochre) to clipboard', +]; + +describe('PalettePage', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockTrackPageview.mockReset(); + mockIsDark.current = false; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('emits a pageview analytics event for paths.palette on mount', () => { + render(); + + expect(mockTrackPageview).toHaveBeenCalledTimes(1); + expect(mockTrackPageview).toHaveBeenCalledWith(paths.palette); + }); + + it('shows copied ✓ for 1,499 ms and restores the hex at 1,500 ms for both a palette swatch and a semantic anchor', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + render(); + + const brandButton = screen.getByRole('button', { + name: 'Copy #009E73 (brand green) to clipboard', + }); + expect(within(brandButton).getByText('#009E73')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(brandButton); + }); + + expect(writeText).toHaveBeenCalledWith('#009E73'); + expect(within(brandButton).getByText('copied ✓')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1499); + }); + expect(within(brandButton).getByText('copied ✓')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(within(brandButton).queryByText('copied ✓')).not.toBeInTheDocument(); + expect(within(brandButton).getByText('#009E73')).toBeInTheDocument(); + + const amberButton = screen.getByRole('button', { + name: 'Copy #DDCC77 (amber) to clipboard', + }); + expect(amberButton).toHaveTextContent('#DDCC77'); + + await act(async () => { + fireEvent.click(amberButton); + }); + + expect(writeText).toHaveBeenCalledWith('#DDCC77'); + expect(amberButton).toHaveTextContent('copied ✓'); + + act(() => { + vi.advanceTimersByTime(1499); + }); + expect(amberButton).toHaveTextContent('copied ✓'); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(amberButton).toHaveTextContent('#DDCC77'); + }); + + it('reorders the palette grid to CVD-optimal order (lime third) and restores imprint default order', () => { + render(); + + const gridSection = screen + .getByRole('heading', { name: /the 8 categorical hues/i }) + .closest('section')!; + const getGridLabels = () => + within(gridSection) + .getAllByRole('button', { name: /^Copy #[0-9A-F]{6}/ }) + .map(btn => btn.getAttribute('aria-label')); + + expect(getGridLabels()).toEqual(IMPRINT_LABELS); + + fireEvent.click( + screen.getByRole('button', { name: 'CVD-optimal (max ΔE under colorblindness)' }) + ); + expect(getGridLabels()).toEqual(CVD_OPTIMAL_LABELS); + + fireEvent.click(screen.getByRole('button', { name: 'imprint (default)' })); + expect(getGridLabels()).toEqual(IMPRINT_LABELS); + }); + + it('wires wheel expansion, comparison palette selection, language tabs, and the OKLCH switch through page state', () => { + mockIsDark.current = true; + render(); + + const expandWheel = screen.getByRole('button', { + name: 'Expand the hue–chroma wheel to compare palettes', + }); + expect(expandWheel).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(expandWheel); + expect(screen.getByRole('button', { name: 'Collapse the hue–chroma wheel' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + + const okabeButton = screen.getByRole('button', { name: 'Okabe-Ito' }); + expect(okabeButton).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(okabeButton); + expect(okabeButton).toHaveAttribute('aria-pressed', 'true'); + + fireEvent.click(screen.getByRole('tab', { name: 'R' })); + expect(screen.getByText(/IMPRINT <- list\(/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('switch', { name: 'OKLCH' })); + expect(screen.getByText(/# oklch\(0\.620 0\.130 165\.5\)/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/sections/palette/PaletteGridSection.test.tsx b/app/src/sections/palette/PaletteGridSection.test.tsx index daa6b6bf31b..5b73742d368 100644 --- a/app/src/sections/palette/PaletteGridSection.test.tsx +++ b/app/src/sections/palette/PaletteGridSection.test.tsx @@ -1,106 +1,85 @@ -import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { PALETTE, type Swatch } from 'src/pages/PalettePage.helpers'; +import { PALETTE } from 'src/pages/PalettePage.helpers'; import { PaletteGridSection } from 'src/sections/palette/PaletteGridSection'; -import { act, fireEvent, render, screen, within } from 'src/test-utils'; - -const CVD_OPTIMAL_ORDER: number[] = [0, 1, 7, 6, 4, 5, 2, 3]; - -function StatefulPaletteGrid() { - const [sort, setSort] = useState<'imprint' | 'cvd'>('imprint'); - const [copiedHex, setCopiedHex] = useState(null); - - const sortedPalette: Swatch[] = - sort === 'imprint' ? PALETTE : CVD_OPTIMAL_ORDER.map(i => PALETTE[i]); - - const copyHex = (hex: string) => { - void navigator.clipboard - .writeText(hex) - .then(() => { - setCopiedHex(hex); - setTimeout(() => setCopiedHex(c => (c === hex ? null : c)), 1500); - }) - .catch(() => {}); - }; - - return ( - - ); -} +import { fireEvent, render, screen, within } from 'src/test-utils'; describe('PaletteGridSection', () => { - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('switches between imprint default and CVD-optimal sort order and toggles the trade-off table', () => { - render(); + it('calls setSort("cvd") and setSort("imprint") from the sort buttons and shows the trade-off table when sort="cvd"', () => { + const setSort = vi.fn(); + const { rerender } = render( + + ); expect(screen.getByRole('heading', { name: /the 8 categorical hues/i })).toBeInTheDocument(); expect(screen.queryByText(/why use CVD-optimal sort\?/i)).not.toBeInTheDocument(); - // Default imprint order: [brand green, lavender, blue, ochre, ...] - const defaultButtons = screen.getAllByRole('button', { name: /Copy #[0-9A-F]{6}/i }); - expect(defaultButtons).toHaveLength(8); - expect(defaultButtons[0]).toHaveAccessibleName('Copy #009E73 (brand green) to clipboard'); - expect(defaultButtons[2]).toHaveAccessibleName('Copy #4467A3 (blue) to clipboard'); - expect(defaultButtons[3]).toHaveAccessibleName('Copy #BD8233 (ochre) to clipboard'); + const buttons = screen.getAllByRole('button', { name: /Copy #[0-9A-F]{6}/i }); + expect(buttons).toHaveLength(8); + expect(buttons[0]).toHaveAccessibleName('Copy #009E73 (brand green) to clipboard'); - // Switch to CVD-optimal order fireEvent.click( screen.getByRole('button', { name: 'CVD-optimal (max ΔE under colorblindness)' }) ); + expect(setSort).toHaveBeenCalledWith('cvd'); + + fireEvent.click(screen.getByRole('button', { name: 'imprint (default)' })); + expect(setSort).toHaveBeenCalledWith('imprint'); + + rerender( + + ); expect(screen.getByText(/why use CVD-optimal sort\?/i)).toBeInTheDocument(); const table = screen.getByRole('table'); expect(within(table).getByText('21.45 (+5.11)')).toBeInTheDocument(); expect(within(table).getByText('36.19 (=)')).toBeInTheDocument(); - - const cvdButtons = screen.getAllByRole('button', { name: /Copy #[0-9A-F]{6}/i }); - expect(cvdButtons[2]).toHaveAccessibleName('Copy #99B314 (lime) to clipboard'); - expect(cvdButtons[3]).toHaveAccessibleName('Copy #954477 (rose) to clipboard'); - - // Switch back to imprint (default) - fireEvent.click(screen.getByRole('button', { name: 'imprint (default)' })); - expect(screen.queryByText(/why use CVD-optimal sort\?/i)).not.toBeInTheDocument(); }); - it('copies a swatch hex to the clipboard, shows copied feedback, and resets after 1500 ms', async () => { - vi.useFakeTimers(); - const writeText = vi.fn().mockResolvedValue(undefined); - vi.stubGlobal('navigator', { clipboard: { writeText } }); - - render(); + it('calls copyHex with the clicked swatch hex and shows copied ✓ only on copiedHex', () => { + const copyHex = vi.fn(); + const { rerender } = render( + + ); const brandButton = screen.getByRole('button', { name: 'Copy #009E73 (brand green) to clipboard', }); expect(within(brandButton).getByText('#009E73')).toBeInTheDocument(); - await act(async () => { - fireEvent.click(brandButton); - }); + fireEvent.click(brandButton); + expect(copyHex).toHaveBeenCalledWith('#009E73'); + + rerender( + + ); - expect(writeText).toHaveBeenCalledWith('#009E73'); expect(within(brandButton).getByText('copied ✓')).toBeInTheDocument(); - // Unclicked swatches keep showing their hex + expect(screen.getAllByText('copied ✓')).toHaveLength(1); expect(screen.getByText('#C475FD')).toBeInTheDocument(); - - act(() => { - vi.advanceTimersByTime(1500); - }); - - expect(within(brandButton).queryByText('copied ✓')).not.toBeInTheDocument(); - expect(within(brandButton).getByText('#009E73')).toBeInTheDocument(); }); }); diff --git a/app/src/sections/palette/SemanticAnchorsSection.test.tsx b/app/src/sections/palette/SemanticAnchorsSection.test.tsx index b7bdd0c8252..2961c7dcbf5 100644 --- a/app/src/sections/palette/SemanticAnchorsSection.test.tsx +++ b/app/src/sections/palette/SemanticAnchorsSection.test.tsx @@ -1,34 +1,11 @@ -import { useState } from 'react'; - -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { SemanticAnchorsSection } from 'src/sections/palette/SemanticAnchorsSection'; -import { act, fireEvent, render, screen } from 'src/test-utils'; - -function StatefulAnchors() { - const [copiedHex, setCopiedHex] = useState(null); - const copyHex = (hex: string) => { - void navigator.clipboard - .writeText(hex) - .then(() => { - setCopiedHex(hex); - setTimeout(() => setCopiedHex(c => (c === hex ? null : c)), 1500); - }) - .catch(() => {}); - }; - - return ; -} +import { fireEvent, render, screen } from 'src/test-utils'; describe('SemanticAnchorsSection', () => { - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - it('renders amber, adaptive neutral, and adaptive muted anchors with their swatch buttons', () => { - render(); + render(); expect(screen.getByRole('heading', { name: /semantic anchors/i })).toBeInTheDocument(); expect(screen.getByText('palette.amber')).toBeInTheDocument(); @@ -52,29 +29,23 @@ describe('SemanticAnchorsSection', () => { ).toBeInTheDocument(); }); - it('copies an anchor swatch and resets the copied feedback after 1500 ms', async () => { - vi.useFakeTimers(); - const writeText = vi.fn().mockResolvedValue(undefined); - vi.stubGlobal('navigator', { clipboard: { writeText } }); - - render(); + it('calls copyHex with the clicked anchor hex and shows copied ✓ only on the matching swatch', () => { + const copyHex = vi.fn(); + const { rerender } = render(); const amberSwatch = screen.getByRole('button', { name: 'Copy #DDCC77 (amber) to clipboard', }); expect(amberSwatch).toHaveTextContent('#DDCC77'); - await act(async () => { - fireEvent.click(amberSwatch); - }); + fireEvent.click(amberSwatch); + expect(copyHex).toHaveBeenCalledWith('#DDCC77'); - expect(writeText).toHaveBeenCalledWith('#DDCC77'); + rerender(); expect(amberSwatch).toHaveTextContent('copied ✓'); - - act(() => { - vi.advanceTimersByTime(1500); - }); - - expect(amberSwatch).toHaveTextContent('#DDCC77'); + expect(screen.getAllByText('copied ✓')).toHaveLength(1); + expect( + screen.getByRole('button', { name: 'Copy #1A1A17 (neutral) to clipboard' }) + ).toHaveTextContent('#1A1A17'); }); }); diff --git a/app/src/sections/palette/WcagContrastSection.test.tsx b/app/src/sections/palette/WcagContrastSection.test.tsx index 4a2ed7c93ef..7e8bdaa0987 100644 --- a/app/src/sections/palette/WcagContrastSection.test.tsx +++ b/app/src/sections/palette/WcagContrastSection.test.tsx @@ -18,21 +18,6 @@ describe('WcagContrastSection', () => { expect(screen.getByText('2.92:1 ✗')).toBeInTheDocument(); expect(screen.getByText('2.59:1 ✗')).toBeInTheDocument(); expect(screen.getByText('6.53:1 ✓')).toBeInTheDocument(); - }); - - it('renders both light and dark theme variants across all nine palette and anchor rows', () => { - const { rerender } = render(); - - expect(screen.getByRole('cell', { name: '#C475FD' })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: 'lavender' })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: '#AE3030' })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: 'matte-red' })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: '#DDCC77' })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: 'amber' })).toBeInTheDocument(); - - rerender(); - expect(screen.getByRole('cell', { name: '#C475FD' })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: '#AE3030' })).toBeInTheDocument(); expect(screen.getByText('1.46:1 ✗')).toBeInTheDocument(); expect(screen.getByText('11.59:1 ✓')).toBeInTheDocument(); }); diff --git a/app/src/sections/palette/paletteHelpers.test.ts b/app/src/sections/palette/paletteHelpers.test.ts index 8cefe2d194c..bb44a4d85ad 100644 --- a/app/src/sections/palette/paletteHelpers.test.ts +++ b/app/src/sections/palette/paletteHelpers.test.ts @@ -7,11 +7,8 @@ import { DE_T_WARN, deltaECellBg, oklchToRgb, - proseColumnSx, - sectionSx, starPoints, textOn, - tooltipSlotProps, } from 'src/sections/palette/paletteHelpers'; describe('paletteHelpers', () => { @@ -114,12 +111,4 @@ describe('paletteHelpers', () => { expect(deltaECellBg(DE_T_OK - 0.01)).toBe('rgba(221, 204, 119, 0.18)'); }); }); - - describe('layout and tooltip style constants', () => { - it('defines tooltip slot props and section layout sx objects', () => { - expect(tooltipSlotProps.tooltip.sx.maxWidth).toBe(320); - expect(sectionSx).toEqual({ py: { xs: 2, md: 3 } }); - expect(proseColumnSx).toEqual({ maxWidth: 880, mx: 'auto' }); - }); - }); }); From 8da8a4e6daa8934a2b12185024003e8f31561c4f Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:16:43 +0100 Subject: [PATCH 13/14] test(debug): test real DebugPage sorting/triage and refine section contracts Review round 1 for package 2 (debug): - Add DebugPage integration tests for spec matrix sorting, incomplete and low-score filters, and feedback status PATCH/revert/refetch - Replace StatefulSpecMatrix and StatefulFeedbackHarness with direct prop contract tests in SpecMatrix.test.tsx and FeedbackSection.test.tsx - Use timezone-safe local ISO timestamps in debugFormat.test.ts and SpecMatrix.test.tsx - Restore navigator.clipboard cleanly in CopyClaudePromptButton tests - Strengthen DailyActivityChart tooltip/empty-state assertions and ProblemAreas per-section count assertions Brief: section-tests-2 Co-Authored-By: Antigravity (Gemini 4 Argon) --- app/src/pages/DebugPage.test.tsx | 308 ++++++++++++++++++ .../debug/DebugOverviewSections.test.tsx | 32 +- .../sections/debug/FeedbackSection.test.tsx | 192 ++++------- app/src/sections/debug/SpecMatrix.test.tsx | 256 +++++++-------- app/src/sections/debug/debugFormat.test.ts | 4 +- 5 files changed, 512 insertions(+), 280 deletions(-) diff --git a/app/src/pages/DebugPage.test.tsx b/app/src/pages/DebugPage.test.tsx index 20d3992ec8a..cfe4a9ef620 100644 --- a/app/src/pages/DebugPage.test.tsx +++ b/app/src/pages/DebugPage.test.tsx @@ -568,3 +568,311 @@ describe('DebugPage agent usage', () => { ]); }); }); + +describe('DebugPage spec matrix and feedback triage', () => { + const TRIAGE_SPECS = [ + { + id: 'bar-grouped', + title: 'Zebra Bar Chart', + updated: '2026-02-20T12:00:00', + avg_score: 84.5, + altair: 95, + bokeh: 95, + chartjs: 95, + d3: 95, + echarts: 95, + ggplot2: 95, + highcharts: 95, + letsplot: 95, + makie: 95, + matplotlib: 95, + muix: 95, + plotly: 95, + plotnine: 95, + pygal: 95, + seaborn: 78, + }, + { + id: 'heatmap-annotated', + title: 'Alpha Heatmap', + updated: '2026-03-10T12:00:00', + avg_score: 91.0, + altair: null, + bokeh: null, + chartjs: null, + d3: null, + echarts: null, + ggplot2: null, + highcharts: null, + letsplot: null, + makie: null, + matplotlib: 91, + muix: null, + plotly: null, + plotnine: null, + pygal: null, + seaborn: null, + }, + { + id: 'scatter-basic', + title: 'Middle Scatter', + updated: '2026-01-15T12:00:00', + avg_score: 96.0, + altair: 96, + bokeh: 96, + chartjs: 96, + d3: 96, + echarts: 96, + ggplot2: 96, + highcharts: 96, + letsplot: 96, + makie: 96, + matplotlib: 96, + muix: 96, + plotly: 96, + plotnine: 96, + pygal: 96, + seaborn: 96, + }, + ]; + + const TRIAGE_MESSAGES = [ + { + id: 'fb-1', + message: 'Axis labels overlap on mobile', + reaction: 'bug', + contact: 'alice@example.com', + path: '/scatter-basic', + spec_id: 'scatter-basic', + viewport: '375x812', + status: 'new' as const, + created_at: '2026-05-21T09:00:00', + }, + ]; + + beforeEach(({ onTestFinished }) => { + vi.clearAllMocks(); + sessionStorage.clear(); + onTestFinished(() => { + sessionStorage.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + }); + + function renderedSpecOrder(): string[] { + const knownIds = new Set(TRIAGE_SPECS.map(s => s.id)); + return screen + .getAllByRole('link') + .map(link => link.textContent ?? '') + .filter(text => knownIds.has(text)); + } + + function expectSortState( + activeCol: 'spec id' | 'title' | 'avg' | 'updated', + dir: 'asc' | 'desc', + expectedOrder: string[] + ) { + expect(renderedSpecOrder()).toEqual(expectedOrder); + const arrow = dir === 'asc' ? '▲' : '▼'; + for (const label of ['spec id', 'title', 'avg', 'updated'] as const) { + const btn = screen.getByRole('button', { name: new RegExp(`^${label}`) }); + if (label === activeCol) { + expect(btn).toHaveTextContent(arrow); + } else { + expect(btn).not.toHaveTextContent(/[▲▼]/); + } + } + } + + function stubPageFetch(options?: { patchStatus?: () => number }) { + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (url.includes('/debug/ping')) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + database_connected: true, + response_time_ms: 10, + timestamp: '2026-05-21T12:00:00', + }), + }); + } + if (url.includes('/debug/agent-usage')) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + status: 'not_configured', + days: 7, + sessions: 0, + plots_shipped: 0, + kept_rate: null, + kept_sessions: 0, + shipped_sessions: 0, + repair_rate: null, + repaired_runs: 0, + runs: 0, + top_specs: [], + top_formats: [], + top_refusals: [], + generated_at: '2026-05-21T12:00:00', + }), + }); + } + if (url.includes('/debug/agent/cases')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ items: [], has_more: false }), + }); + } + if (url.includes('/debug/feedback/messages')) { + const parsed = new URL(url, 'https://api.anyplot.ai'); + const status = parsed.searchParams.get('status'); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(status === 'open' ? TRIAGE_MESSAGES : []), + }); + } + if (url.includes('/debug/feedback/top')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + } + if (init?.method === 'PATCH') { + const status = options?.patchStatus?.() ?? 200; + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve({ status: 'ok' }), + }); + } + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + ...mockDebugData, + total_specs: TRIAGE_SPECS.length, + specs: TRIAGE_SPECS, + }), + }); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + } + + it('sorts specs by each sortable header with per-column default direction and toggles on second click', async () => { + stubPageFetch(); + render(); + + await waitFor(() => { + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + }); + + // Initial sort is updated descending + expectSortState('updated', 'desc', ['heatmap-annotated', 'bar-grouped', 'scatter-basic']); + + // Second click on updated toggles to ascending + fireEvent.click(screen.getByRole('button', { name: /^updated/ })); + expectSortState('updated', 'asc', ['scatter-basic', 'bar-grouped', 'heatmap-annotated']); + + // Clicking spec id defaults to ascending, second click toggles to descending + fireEvent.click(screen.getByRole('button', { name: /^spec id/ })); + expectSortState('spec id', 'asc', ['bar-grouped', 'heatmap-annotated', 'scatter-basic']); + + fireEvent.click(screen.getByRole('button', { name: /^spec id/ })); + expectSortState('spec id', 'desc', ['scatter-basic', 'heatmap-annotated', 'bar-grouped']); + + // Clicking title defaults to ascending, second click toggles to descending + fireEvent.click(screen.getByRole('button', { name: /^title/ })); + expectSortState('title', 'asc', ['heatmap-annotated', 'scatter-basic', 'bar-grouped']); + + fireEvent.click(screen.getByRole('button', { name: /^title/ })); + expectSortState('title', 'desc', ['bar-grouped', 'scatter-basic', 'heatmap-annotated']); + + // Clicking avg defaults to descending, second click toggles to ascending + fireEvent.click(screen.getByRole('button', { name: /^avg/ })); + expectSortState('avg', 'desc', ['scatter-basic', 'heatmap-annotated', 'bar-grouped']); + + fireEvent.click(screen.getByRole('button', { name: /^avg/ })); + expectSortState('avg', 'asc', ['bar-grouped', 'heatmap-annotated', 'scatter-basic']); + + // Clicking updated from another column defaults to descending + fireEvent.click(screen.getByRole('button', { name: /^updated/ })); + expectSortState('updated', 'desc', ['heatmap-annotated', 'bar-grouped', 'scatter-basic']); + }); + + it('narrows rows with incomplete and low-score filters and restores them on second click', async () => { + stubPageFetch(); + render(); + + await waitFor(() => { + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + }); + + const incompleteBtn = screen.getByRole('button', { name: /incomplete <15/i }); + fireEvent.click(incompleteBtn); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['heatmap-annotated']); + + fireEvent.click(incompleteBtn); + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'bar-grouped', 'scatter-basic']); + + const lowScoresBtn = screen.getByRole('button', { name: /low scores <90/i }); + fireEvent.click(lowScoresBtn); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['bar-grouped']); + + fireEvent.click(lowScoresBtn); + expect(screen.getByText('3 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'bar-grouped', 'scatter-basic']); + }); + + it('patches feedback status with X-Admin-Token, reverts on 500, and refetches when status filter changes', async () => { + sessionStorage.setItem('anyplot.adminToken', 'admin-secret'); + let nextPatchStatus = 200; + const fetchMock = stubPageFetch({ patchStatus: () => nextPatchStatus }); + + render(); + + const msgText = await screen.findByText('Axis labels overlap on mobile'); + const msgRow = msgText.parentElement?.parentElement; + expect(msgRow).not.toBeNull(); + const statusSelect = within(msgRow as HTMLElement).getByRole('combobox'); + expect(statusSelect).toHaveValue('new'); + + fireEvent.change(statusSelect, { target: { value: 'done' } }); + expect(statusSelect).toHaveValue('done'); + + await waitFor(() => { + const patchCalls = fetchMock.mock.calls.filter(call => call[1]?.method === 'PATCH'); + expect(patchCalls).toHaveLength(1); + }); + const [patchUrl, patchInit] = fetchMock.mock.calls.find(call => call[1]?.method === 'PATCH')!; + expect(patchUrl).toMatch(/\/debug\/feedback\/fb-1$/); + expect(patchInit?.body).toBe(JSON.stringify({ status: 'done' })); + expect((patchInit?.headers as Record)?.['X-Admin-Token']).toBe('admin-secret'); + + // When PATCH answers 500, the select reverts to the previous status + nextPatchStatus = 500; + fireEvent.change(statusSelect, { target: { value: 'wont_solve' } }); + await waitFor(() => { + expect(statusSelect).toHaveValue('done'); + }); + + // Choosing a status filter refetches .../debug/feedback/messages?status=&limit=50 + const messagesHeaderBar = screen.getByRole('heading', { name: 'feedback · messages' }) + .parentElement?.parentElement; + expect(messagesHeaderBar).not.toBeNull(); + const filterSelect = within(messagesHeaderBar as HTMLElement).getByRole('combobox'); + fireEvent.change(filterSelect, { target: { value: 'done' } }); + + await waitFor(() => { + const messageUrls = fetchMock.mock.calls + .map(call => call[0]) + .filter(url => url.includes('/debug/feedback/messages')); + expect(messageUrls).toContainEqual( + expect.stringMatching(/\/debug\/feedback\/messages\?status=done&limit=50$/) + ); + }); + }); +}); diff --git a/app/src/sections/debug/DebugOverviewSections.test.tsx b/app/src/sections/debug/DebugOverviewSections.test.tsx index 997f4ccb948..7169b5f76e8 100644 --- a/app/src/sections/debug/DebugOverviewSections.test.tsx +++ b/app/src/sections/debug/DebugOverviewSections.test.tsx @@ -7,7 +7,7 @@ import { LibraryCoverage } from 'src/sections/debug/LibraryCoverage'; import { ProblemAreas } from 'src/sections/debug/ProblemAreas'; import { RecentActivity } from 'src/sections/debug/RecentActivity'; import { SystemHealthRow } from 'src/sections/debug/SystemHealthRow'; -import { render, screen } from 'src/test-utils'; +import { render, screen, userEvent, within } from 'src/test-utils'; describe('SystemHealthRow', () => { afterEach(() => { @@ -90,7 +90,8 @@ describe('KPIGrid', () => { }); describe('DailyActivityChart', () => { - it('renders activity header, date range endpoints, and handles empty data', () => { + it('renders activity header, date range endpoints, bar tooltip on hover, and handles empty data', async () => { + const user = userEvent.setup(); const { rerender } = render( { expect(screen.getByText('2026-05-01')).toBeInTheDocument(); expect(screen.getByText('2026-05-30')).toBeInTheDocument(); + const bar = screen.getByLabelText('2026-05-02: 12 impls'); + await user.hover(bar); + expect(await screen.findByRole('tooltip')).toHaveTextContent('2026-05-02: 12 impls'); + rerender(); - expect(screen.getByText('daily activity')).toBeInTheDocument(); + expect(screen.queryByLabelText(/impls$/)).not.toBeInTheDocument(); + expect(screen.queryByText('2026-05-01')).not.toBeInTheDocument(); + expect(screen.queryByText('2026-05-30')).not.toBeInTheDocument(); }); }); @@ -251,10 +258,21 @@ describe('ProblemAreas', () => { ); expect(screen.getByText('problem areas')).toBeInTheDocument(); - expect(screen.getByText('low scores (avg < 90)')).toBeInTheDocument(); - expect(screen.getByText('missing previews')).toBeInTheDocument(); - expect(screen.getByText('missing tags')).toBeInTheDocument(); - expect(screen.getByText('oldest specs')).toBeInTheDocument(); + const lowScoresSection = screen.getByText('low scores (avg < 90)').closest('summary'); + expect(lowScoresSection).not.toBeNull(); + expect(within(lowScoresSection as HTMLElement).getByText('2')).toBeInTheDocument(); + + const missingPreviewsSection = screen.getByText('missing previews').closest('summary'); + expect(missingPreviewsSection).not.toBeNull(); + expect(within(missingPreviewsSection as HTMLElement).getByText('0')).toBeInTheDocument(); + + const missingTagsSection = screen.getByText('missing tags').closest('summary'); + expect(missingTagsSection).not.toBeNull(); + expect(within(missingTagsSection as HTMLElement).getByText('1')).toBeInTheDocument(); + + const oldestSpecsSection = screen.getByText('oldest specs').closest('summary'); + expect(oldestSpecsSection).not.toBeNull(); + expect(within(oldestSpecsSection as HTMLElement).getByText('0')).toBeInTheDocument(); const scatterLink = screen.getByRole('link', { name: /scatter-basic/ }); expect(scatterLink).toHaveAttribute('href', '/scatter-basic'); diff --git a/app/src/sections/debug/FeedbackSection.test.tsx b/app/src/sections/debug/FeedbackSection.test.tsx index 8020d089d5c..7397f214c76 100644 --- a/app/src/sections/debug/FeedbackSection.test.tsx +++ b/app/src/sections/debug/FeedbackSection.test.tsx @@ -1,18 +1,10 @@ -import { useEffect, useState } from 'react'; - import { afterEach, describe, expect, it, vi } from 'vitest'; -import { DEBUG_API_URL } from 'src/constants'; -import { fetchWithAuth } from 'src/lib/api'; -import { - FEEDBACK_STATUS_OPTIONS, - type FeedbackMessageFilter, - type FeedbackStatus, -} from 'src/sections/debug/constants'; +import { FEEDBACK_STATUS_OPTIONS } from 'src/sections/debug/constants'; import { CopyClaudePromptButton } from 'src/sections/debug/CopyClaudePromptButton'; import { type FeedbackMessage, FeedbackSection } from 'src/sections/debug/FeedbackSection'; import { FeedbackTopList, type FeedbackTopPage } from 'src/sections/debug/FeedbackTopList'; -import { act, fireEvent, render, screen, userEvent, waitFor } from 'src/test-utils'; +import { act, fireEvent, render, screen, userEvent, within } from 'src/test-utils'; import { colors } from 'src/theme'; const TOP_UP: FeedbackTopPage[] = [ @@ -72,58 +64,6 @@ const INITIAL_MESSAGES: FeedbackMessage[] = [ }, ]; -function StatefulFeedbackHarness({ token = 'test-admin-token' }: { token?: string }) { - const [filter, setFilter] = useState('open'); - const [messages, setMessages] = useState([]); - const [topUp, setTopUp] = useState([]); - const [topDown, setTopDown] = useState([]); - - useEffect(() => { - const qs = filter ? `?status=${filter}&limit=50` : '?limit=50'; - Promise.all([ - fetchWithAuth(`${DEBUG_API_URL}/debug/feedback/top?reaction=thumbs_up&limit=15`, token).then( - r => (r.ok ? (r.json() as Promise) : []) - ), - fetchWithAuth( - `${DEBUG_API_URL}/debug/feedback/top?reaction=thumbs_down&limit=15`, - token - ).then(r => (r.ok ? (r.json() as Promise) : [])), - fetchWithAuth(`${DEBUG_API_URL}/debug/feedback/messages${qs}`, token).then(r => - r.ok ? (r.json() as Promise) : [] - ), - ]).then(([up, down, msgs]) => { - setTopUp(up); - setTopDown(down); - setMessages(msgs); - }); - }, [filter, token]); - - const handleStatusChange = async (id: string, newStatus: string) => { - const prev = messages; - setMessages(prev.map(m => (m.id === id ? { ...m, status: newStatus as FeedbackStatus } : m))); - try { - const r = await fetchWithAuth(`${DEBUG_API_URL}/debug/feedback/${id}`, token, { - method: 'PATCH', - body: JSON.stringify({ status: newStatus }), - }); - if (!r.ok) throw new Error(`${r.status}`); - } catch { - setMessages(prev); - } - }; - - return ( - - ); -} - describe('Feedback constants', () => { it('lists the four feedback triage statuses in order', () => { expect(FEEDBACK_STATUS_OPTIONS).toEqual(['new', 'in_progress', 'done', 'wont_solve']); @@ -159,28 +99,27 @@ describe('FeedbackTopList', () => { describe('CopyClaudePromptButton', () => { const originalLocation = window.location; - const originalClipboard = navigator.clipboard; + const originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); afterEach(() => { Object.defineProperty(window, 'location', { configurable: true, value: originalLocation, }); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: originalClipboard, - }); + if (originalClipboardDescriptor) { + Object.defineProperty(navigator, 'clipboard', originalClipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals(); }); it('copies a formatted Claude Code prompt to the clipboard and resets feedback after timeout', async () => { vi.useFakeTimers(); - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); + userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); Object.defineProperty(window, 'location', { configurable: true, value: { @@ -230,42 +169,23 @@ describe('FeedbackSection', () => { vi.unstubAllGlobals(); }); - it('renders messages with reactions, contact info, status filter changes, and status updates via fetchWithAuth', async () => { + it('renders messages with reactions and contact info and forwards status and filter changes', async () => { const user = userEvent.setup(); - let patchShouldFail = false; + const setFeedbackStatusFilter = vi.fn(); + const handleStatusChange = vi.fn(); - const fetchMock = vi.fn((url: string, init?: RequestInit) => { - if (url.includes('/debug/feedback/top?reaction=thumbs_up')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(TOP_UP) }); - } - if (url.includes('/debug/feedback/top?reaction=thumbs_down')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(TOP_DOWN) }); - } - if (url.includes('/debug/feedback/messages')) { - const parsed = new URL(url, 'https://api.anyplot.ai'); - const status = parsed.searchParams.get('status'); - if (status === 'open') { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(INITIAL_MESSAGES), - }); - } - return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); - } - if (init?.method === 'PATCH') { - if (patchShouldFail) { - return Promise.resolve({ ok: false, status: 500 }); - } - return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'ok' }) }); - } - return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); - }); - vi.stubGlobal('fetch', fetchMock); - - render(); + const { rerender } = render( + + ); - // Wait for initial messages to render - expect(await screen.findByText('Axis labels overlap on mobile')).toBeInTheDocument(); + expect(screen.getByText('Axis labels overlap on mobile')).toBeInTheDocument(); expect(screen.getByText('contact: alice@example.com')).toBeInTheDocument(); expect(screen.getByText('Add dark mode export')).toBeInTheDocument(); expect(screen.getByText('Colors are hard to distinguish')).toBeInTheDocument(); @@ -278,33 +198,45 @@ describe('FeedbackSection', () => { expect(screen.getByText('👎')).toBeInTheDocument(); expect(screen.getByText('—')).toBeInTheDocument(); - // Change the status of the first message (fb-1) from 'new' to 'done' - const selects = screen.getAllByRole('combobox'); - // selects[0] is the section filter; selects[1] is fb-1's status select - const firstMsgSelect = selects[1]; - expect(firstMsgSelect).toHaveValue('new'); - - await user.selectOptions(firstMsgSelect, 'done'); - expect(firstMsgSelect).toHaveValue('done'); - - const patchCall = fetchMock.mock.calls.find(call => call[1]?.method === 'PATCH'); - expect(patchCall?.[0]).toContain('/debug/feedback/fb-1'); - expect(patchCall?.[1]?.body).toBe(JSON.stringify({ status: 'done' })); + // Find fb-1's status select within its message row instead of by index + const fb1Message = screen.getByText('Axis labels overlap on mobile'); + const fb1Row = fb1Message.parentElement?.parentElement; + expect(fb1Row).not.toBeNull(); + const fb1StatusSelect = within(fb1Row as HTMLElement).getByRole('combobox'); + expect(fb1StatusSelect).toHaveValue('new'); - // Failed PATCH reverts optimistic status update - patchShouldFail = true; - await user.selectOptions(firstMsgSelect, 'wont_solve'); - await waitFor(() => { - expect(firstMsgSelect).toHaveValue('done'); - }); + await user.selectOptions(fb1StatusSelect, 'done'); + expect(handleStatusChange).toHaveBeenCalledWith('fb-1', 'done'); - // Filtering by a status with no messages shows the status-specific empty message - const filterSelect = selects[0]; + // Status filter select calls its setter + const filterSelect = screen.getByDisplayValue('open (new + in progress)'); await user.selectOptions(filterSelect, 'done'); - expect(await screen.findByText('no messages with status "done"')).toBeInTheDocument(); - - // Filtering by all statuses ('') with no messages shows generic empty message - await user.selectOptions(filterSelect, ''); - expect(await screen.findByText('no messages')).toBeInTheDocument(); + expect(setFeedbackStatusFilter).toHaveBeenCalledWith('done'); + + // Empty state with specific status filter + rerender( + + ); + expect(screen.getByText('no messages with status "done"')).toBeInTheDocument(); + + // Empty state with all statuses ('') + rerender( + + ); + expect(screen.getByText('no messages')).toBeInTheDocument(); }); }); diff --git a/app/src/sections/debug/SpecMatrix.test.tsx b/app/src/sections/debug/SpecMatrix.test.tsx index fee1901a8dc..b9f17beb5c1 100644 --- a/app/src/sections/debug/SpecMatrix.test.tsx +++ b/app/src/sections/debug/SpecMatrix.test.tsx @@ -1,17 +1,16 @@ -import { useMemo, useState } from 'react'; +import type { ComponentProps } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { LIBRARIES } from 'src/constants'; -import { countImpls } from 'src/sections/debug/debugFormat'; import { HeaderCell } from 'src/sections/debug/HeaderCell'; -import { SortableHeader, type SortDir } from 'src/sections/debug/SortableHeader'; +import { SortableHeader } from 'src/sections/debug/SortableHeader'; import { SpecMatrix, type SpecStatus } from 'src/sections/debug/SpecMatrix'; import { render, screen, userEvent } from 'src/test-utils'; function makeSpec(overrides: Partial & { id: string; title: string }): SpecStatus { return { - updated: '2026-01-15T12:00:00Z', + updated: '2026-01-15T12:00:00', avg_score: null, altair: null, bokeh: null, @@ -40,14 +39,14 @@ const SAMPLE_SPECS: SpecStatus[] = [ makeSpec({ id: 'scatter-basic', title: 'Basic Scatter Plot', - updated: '2026-01-15T12:00:00Z', + updated: '2026-01-15T12:00:00', avg_score: 95.0, ...ALL_LIBRARIES_FULL, }), makeSpec({ id: 'bar-grouped', title: 'Grouped Bar Chart', - updated: '2026-02-20T12:00:00Z', + updated: '2026-02-20T12:00:00', avg_score: 84.5, matplotlib: 91, seaborn: 78, @@ -60,92 +59,25 @@ const SAMPLE_SPECS: SpecStatus[] = [ }), ]; -type SortCol = keyof SpecStatus | 'coverage'; - -function StatefulSpecMatrix({ specs = SAMPLE_SPECS }: { specs?: SpecStatus[] }) { - const [searchText, setSearchText] = useState(''); - const [showIncomplete, setShowIncomplete] = useState(false); - const [showLowScores, setShowLowScores] = useState(false); - const [missingLibrary, setMissingLibrary] = useState(''); - const [sortCol, setSortCol] = useState('updated'); - const [sortDir, setSortDir] = useState('desc'); - - const filteredSpecs = useMemo(() => { - let list = [...specs]; - if (searchText) { - const q = searchText.toLowerCase(); - list = list.filter(s => s.id.toLowerCase().includes(q) || s.title.toLowerCase().includes(q)); - } - if (showIncomplete) { - list = list.filter(s => countImpls(s) < LIBRARIES.length); - } - if (showLowScores) { - list = list.filter(s => - LIBRARIES.some(lib => { - const score = s[lib as keyof SpecStatus] as number | null; - return score !== null && score < 90; - }) - ); - } - if (missingLibrary) { - list = list.filter(s => s[missingLibrary as keyof SpecStatus] === null); - } - list.sort((a, b) => { - let cmp = 0; - switch (sortCol) { - case 'updated': - cmp = (a.updated || '').localeCompare(b.updated || ''); - break; - case 'id': - cmp = a.id.localeCompare(b.id); - break; - case 'title': - cmp = a.title.localeCompare(b.title); - break; - case 'avg_score': - cmp = (a.avg_score ?? 0) - (b.avg_score ?? 0); - break; - } - return sortDir === 'desc' ? -cmp : cmp; - }); - return list; - }, [specs, searchText, showIncomplete, showLowScores, missingLibrary, sortCol, sortDir]); - - const handleSort = (col: SortCol) => { - if (sortCol === col) { - setSortDir(d => (d === 'asc' ? 'desc' : 'asc')); - } else { - setSortCol(col); - setSortDir(col === 'updated' || col === 'avg_score' ? 'desc' : 'asc'); - } +function makeMatrixProps( + overrides: Partial> = {} +): ComponentProps { + return { + searchText: '', + setSearchText: vi.fn(), + showIncomplete: false, + setShowIncomplete: vi.fn(), + showLowScores: false, + setShowLowScores: vi.fn(), + missingLibrary: '', + setMissingLibrary: vi.fn(), + filteredSpecs: SAMPLE_SPECS, + totalSpecs: SAMPLE_SPECS.length, + sortCol: 'updated', + sortDir: 'desc', + handleSort: vi.fn(), + ...overrides, }; - - return ( - - ); -} - -/** Returns the spec IDs in the order they appear in the desktop table links. */ -function renderedSpecOrder(): string[] { - const knownIds = new Set(SAMPLE_SPECS.map(s => s.id)); - return screen - .getAllByRole('link') - .map(link => link.textContent ?? '') - .filter(text => knownIds.has(text)); } describe('HeaderCell', () => { @@ -188,7 +120,7 @@ describe('SpecMatrix', () => { }); it('renders spec rows with implementation counts, scores, links, and fallback dashes', () => { - render(); + render(); expect(screen.getByText('3 / 3')).toBeInTheDocument(); expect(screen.getByText(`${LIBRARIES.length}/${LIBRARIES.length}`)).toBeInTheDocument(); @@ -215,79 +147,121 @@ describe('SpecMatrix', () => { expect(implLinks[0]).toHaveAttribute('href', '/bar-grouped/python/seaborn'); }); - it('sorts specs by each sortable column and toggles direction', async () => { + it('shows the direction arrow only on the active sortable header and calls handleSort for each column', async () => { const user = userEvent.setup(); - render(); + const handleSort = vi.fn(); - // Initial sort is updated desc: bar-grouped (Feb 20), scatter-basic (Jan 15), heatmap-annotated (null) - expect(renderedSpecOrder()).toEqual(['bar-grouped', 'scatter-basic', 'heatmap-annotated']); + const { rerender } = render( + + ); - // Toggle updated to asc - await user.click(screen.getByRole('button', { name: /updated/ })); - expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'scatter-basic', 'bar-grouped']); + const getHeaders = () => ({ + id: screen.getByRole('button', { name: /^spec id/ }), + title: screen.getByRole('button', { name: /^title/ }), + avg_score: screen.getByRole('button', { name: /^avg/ }), + updated: screen.getByRole('button', { name: /^updated/ }), + }); - // Sort by spec id (defaults to asc, then toggle to desc) - await user.click(screen.getByRole('button', { name: /spec id/ })); - expect(renderedSpecOrder()).toEqual(['bar-grouped', 'heatmap-annotated', 'scatter-basic']); + let headers = getHeaders(); + expect(headers.id).toHaveTextContent('▲'); + expect(headers.title).not.toHaveTextContent(/[▲▼]/); + expect(headers.avg_score).not.toHaveTextContent(/[▲▼]/); + expect(headers.updated).not.toHaveTextContent(/[▲▼]/); - await user.click(screen.getByRole('button', { name: /spec id/ })); - expect(renderedSpecOrder()).toEqual(['scatter-basic', 'heatmap-annotated', 'bar-grouped']); + rerender( + + ); + headers = getHeaders(); + expect(headers.title).toHaveTextContent('▼'); + expect(headers.id).not.toHaveTextContent(/[▲▼]/); + expect(headers.avg_score).not.toHaveTextContent(/[▲▼]/); + expect(headers.updated).not.toHaveTextContent(/[▲▼]/); + + rerender( + + ); + headers = getHeaders(); + expect(headers.avg_score).toHaveTextContent('▲'); + expect(headers.id).not.toHaveTextContent(/[▲▼]/); + expect(headers.title).not.toHaveTextContent(/[▲▼]/); + expect(headers.updated).not.toHaveTextContent(/[▲▼]/); + + rerender( + + ); + headers = getHeaders(); + expect(headers.updated).toHaveTextContent('▼'); + expect(headers.id).not.toHaveTextContent(/[▲▼]/); + expect(headers.title).not.toHaveTextContent(/[▲▼]/); + expect(headers.avg_score).not.toHaveTextContent(/[▲▼]/); - // Sort by title (defaults to asc, then toggle to desc) - await user.click(screen.getByRole('button', { name: /title/ })); - expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'scatter-basic', 'bar-grouped']); + await user.click(headers.id); + expect(handleSort).toHaveBeenLastCalledWith('id'); - await user.click(screen.getByRole('button', { name: /title/ })); - expect(renderedSpecOrder()).toEqual(['bar-grouped', 'scatter-basic', 'heatmap-annotated']); + await user.click(headers.title); + expect(handleSort).toHaveBeenLastCalledWith('title'); - // Sort by avg (defaults to desc, then toggle to asc) - await user.click(screen.getByRole('button', { name: /avg/ })); - expect(renderedSpecOrder()).toEqual(['scatter-basic', 'bar-grouped', 'heatmap-annotated']); + await user.click(headers.avg_score); + expect(handleSort).toHaveBeenLastCalledWith('avg_score'); - await user.click(screen.getByRole('button', { name: /avg/ })); - expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'bar-grouped', 'scatter-basic']); + await user.click(headers.updated); + expect(handleSort).toHaveBeenLastCalledWith('updated'); + expect(handleSort).toHaveBeenCalledTimes(4); }); - it('filters specs by search text, incomplete coverage, low scores, and missing library', async () => { + it('calls filter setters when controls are used', async () => { const user = userEvent.setup(); - render(); + const setSearchText = vi.fn(); + const setShowIncomplete = vi.fn(); + const setShowLowScores = vi.fn(); + const setMissingLibrary = vi.fn(); + + const { rerender } = render( + + ); - // Incomplete filter (< 15 libraries): excludes scatter-basic (15/15) const incompleteBtn = screen.getByRole('button', { name: new RegExp(`incomplete <${LIBRARIES.length}`, 'i'), }); await user.click(incompleteBtn); - expect(screen.getByText('2 / 3')).toBeInTheDocument(); - expect(renderedSpecOrder()).toEqual(['bar-grouped', 'heatmap-annotated']); - - // Toggle incomplete off - await user.click(incompleteBtn); - expect(screen.getByText('3 / 3')).toBeInTheDocument(); + expect(setShowIncomplete).toHaveBeenCalledTimes(1); + const incompleteUpdater = setShowIncomplete.mock.calls[0][0] as (prev: boolean) => boolean; + expect(incompleteUpdater(false)).toBe(true); + expect(incompleteUpdater(true)).toBe(false); - // Low scores filter (< 90): keeps only bar-grouped (seaborn = 78) const lowScoresBtn = screen.getByRole('button', { name: /low scores <90/i }); await user.click(lowScoresBtn); - expect(screen.getByText('1 / 3')).toBeInTheDocument(); - expect(renderedSpecOrder()).toEqual(['bar-grouped']); + expect(setShowLowScores).toHaveBeenCalledTimes(1); + const lowScoresUpdater = setShowLowScores.mock.calls[0][0] as (prev: boolean) => boolean; + expect(lowScoresUpdater(false)).toBe(true); + expect(lowScoresUpdater(true)).toBe(false); - // Toggle low scores off - await user.click(lowScoresBtn); - expect(screen.getByText('3 / 3')).toBeInTheDocument(); - - // Missing library filter: matplotlib is present on scatter-basic and bar-grouped, missing on heatmap-annotated const missingSelect = screen.getByRole('combobox'); await user.selectOptions(missingSelect, 'matplotlib'); - expect(screen.getByText('1 / 3')).toBeInTheDocument(); - expect(renderedSpecOrder()).toEqual(['heatmap-annotated']); + expect(setMissingLibrary).toHaveBeenCalledWith('matplotlib'); - // Reset missing library filter - await user.selectOptions(missingSelect, ''); - expect(screen.getByText('3 / 3')).toBeInTheDocument(); - - // Search input filter by title or id const searchInput = screen.getByPlaceholderText('search spec id or title...'); - await user.type(searchInput, 'Grouped'); - expect(screen.getByText('1 / 3')).toBeInTheDocument(); - expect(renderedSpecOrder()).toEqual(['bar-grouped']); + await user.type(searchInput, 'scatter'); + expect(setSearchText).toHaveBeenCalledWith('s'); + + // Render with active filter flags + rerender( + + ); + expect(screen.getByDisplayValue('scatter')).toBeInTheDocument(); }); }); diff --git a/app/src/sections/debug/debugFormat.test.ts b/app/src/sections/debug/debugFormat.test.ts index c202f2312f7..73111dadf12 100644 --- a/app/src/sections/debug/debugFormat.test.ts +++ b/app/src/sections/debug/debugFormat.test.ts @@ -15,7 +15,7 @@ function makeSpec(overrides: Partial = {}): SpecStatus { return { id: 'scatter-basic', title: 'Basic Scatter', - updated: '2026-01-15T12:00:00Z', + updated: '2026-01-15T12:00:00', avg_score: null, altair: null, bokeh: null, @@ -74,7 +74,7 @@ describe('debugFormat', () => { describe('formatDateShort', () => { it('formats valid ISO dates as short US month and day and returns invalid strings unchanged', () => { - expect(formatDateShort('2026-01-15T12:00:00Z')).toBe('Jan 15'); + expect(formatDateShort('2026-01-15T12:00:00')).toBe('Jan 15'); expect(formatDateShort('not-a-date')).toBe('not-a-date'); }); }); From 7c1ce5afc4df3b39d2e5c0145a51b4cdee00543b Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:26:26 +0100 Subject: [PATCH 14/14] test(app): close the review gaps in the section tests - WcagContrastSection: test the per-theme swatch stroke through the declared stylesheet rule (jsdom drops var() borders from the computed style) and restore the hex and name cell checks the round-1 change removed. - DebugPage: give heatmap-annotated one library below 90 with an average of 91, so the low-score filter test fails if the filter looks at the average. - SpecMatrix: assert the missing-library select keeps its value on rerender. - PalettePage: drop the dark-theme ref that no test asserted on. - Changelog fragment: name the page-level tests; add the PR number. Co-Authored-By: Claude Opus 5.5 --- app/src/pages/DebugPage.test.tsx | 8 ++-- app/src/pages/PalettePage.test.tsx | 5 +-- app/src/sections/debug/SpecMatrix.test.tsx | 1 + .../palette/WcagContrastSection.test.tsx | 37 +++++++++++++++++++ changelog.d/section-tests.md | 20 ++++++---- 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/app/src/pages/DebugPage.test.tsx b/app/src/pages/DebugPage.test.tsx index cfe4a9ef620..b5d0dc1aa1a 100644 --- a/app/src/pages/DebugPage.test.tsx +++ b/app/src/pages/DebugPage.test.tsx @@ -606,7 +606,9 @@ describe('DebugPage spec matrix and feedback triage', () => { highcharts: null, letsplot: null, makie: null, - matplotlib: 91, + // One library below 90 while the average stays at 91.0: the low-score + // filter must look at each library, not at the average. + matplotlib: 85, muix: null, plotly: null, plotnine: null, @@ -819,8 +821,8 @@ describe('DebugPage spec matrix and feedback triage', () => { const lowScoresBtn = screen.getByRole('button', { name: /low scores <90/i }); fireEvent.click(lowScoresBtn); - expect(screen.getByText('1 / 3')).toBeInTheDocument(); - expect(renderedSpecOrder()).toEqual(['bar-grouped']); + expect(screen.getByText('2 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['heatmap-annotated', 'bar-grouped']); fireEvent.click(lowScoresBtn); expect(screen.getByText('3 / 3')).toBeInTheDocument(); diff --git a/app/src/pages/PalettePage.test.tsx b/app/src/pages/PalettePage.test.tsx index f57e77bf030..b2aa37f3fb8 100644 --- a/app/src/pages/PalettePage.test.tsx +++ b/app/src/pages/PalettePage.test.tsx @@ -5,14 +5,13 @@ import { paths } from 'src/routes/paths'; import { act, fireEvent, render, screen, within } from 'src/test-utils'; const mockTrackPageview = vi.fn(); -const mockIsDark = { current: false }; vi.mock('src/hooks', () => ({ useAnalytics: () => ({ trackPageview: mockTrackPageview, }), useTheme: () => ({ - isDark: mockIsDark.current, + isDark: false, }), })); @@ -42,7 +41,6 @@ describe('PalettePage', () => { beforeEach(() => { vi.restoreAllMocks(); mockTrackPageview.mockReset(); - mockIsDark.current = false; vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); }); @@ -135,7 +133,6 @@ describe('PalettePage', () => { }); it('wires wheel expansion, comparison palette selection, language tabs, and the OKLCH switch through page state', () => { - mockIsDark.current = true; render(); const expandWheel = screen.getByRole('button', { diff --git a/app/src/sections/debug/SpecMatrix.test.tsx b/app/src/sections/debug/SpecMatrix.test.tsx index b9f17beb5c1..39c481f9532 100644 --- a/app/src/sections/debug/SpecMatrix.test.tsx +++ b/app/src/sections/debug/SpecMatrix.test.tsx @@ -263,5 +263,6 @@ describe('SpecMatrix', () => { /> ); expect(screen.getByDisplayValue('scatter')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveValue('matplotlib'); }); }); diff --git a/app/src/sections/palette/WcagContrastSection.test.tsx b/app/src/sections/palette/WcagContrastSection.test.tsx index 7e8bdaa0987..d94c39dc5ff 100644 --- a/app/src/sections/palette/WcagContrastSection.test.tsx +++ b/app/src/sections/palette/WcagContrastSection.test.tsx @@ -3,6 +3,20 @@ import { describe, expect, it } from 'vitest'; import { WcagContrastSection } from 'src/sections/palette/WcagContrastSection'; import { render, screen } from 'src/test-utils'; +// jsdom drops a `border` that contains `var(...)` from the computed style, so +// read the border the swatch's own stylesheet rule declares instead. +function declaredSwatchBorder(hex: string): string | undefined { + const swatch = screen.getByRole('cell', { name: hex }).firstElementChild; + if (!(swatch instanceof HTMLElement)) return undefined; + const borders = Array.from(document.styleSheets) + .flatMap(sheet => Array.from(sheet.cssRules)) + .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule) + .filter(rule => swatch.matches(rule.selectorText)) + .map(rule => rule.style.getPropertyValue('border')) + .filter(Boolean); + return borders[borders.length - 1]; +} + describe('WcagContrastSection', () => { it('renders the WCAG contrast table with pass (✓) and fail (✗) ratios on cream and on dark', () => { render(); @@ -21,4 +35,27 @@ describe('WcagContrastSection', () => { expect(screen.getByText('1.46:1 ✗')).toBeInTheDocument(); expect(screen.getByText('11.59:1 ✓')).toBeInTheDocument(); }); + + it('names each row by hex and palette name', () => { + render(); + + expect(screen.getByRole('cell', { name: '#C475FD' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'lavender' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '#AE3030' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'matte-red' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '#DDCC77' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'amber' })).toBeInTheDocument(); + }); + + it('strokes the swatches that fail on the current background with --ink', () => { + const { rerender } = render(); + // On cream, lavender fails and matte red passes. + expect(declaredSwatchBorder('#C475FD')).toBe('1px solid var(--ink)'); + expect(declaredSwatchBorder('#AE3030')).toBe('1px solid var(--rule)'); + + rerender(); + // On dark, the two swap. + expect(declaredSwatchBorder('#C475FD')).toBe('1px solid var(--rule)'); + expect(declaredSwatchBorder('#AE3030')).toBe('1px solid var(--ink)'); + }); }); diff --git a/changelog.d/section-tests.md b/changelog.d/section-tests.md index 6a3f7255fe1..86d8aad43f3 100644 --- a/changelog.d/section-tests.md +++ b/changelog.d/section-tests.md @@ -1,10 +1,14 @@ ### Added -- **Render and unit tests for the palette, debug, and map section components.** - Add co-located Vitest suites under `app/src/sections/palette/`, - `app/src/sections/debug/`, and `app/src/sections/map/` covering the section - modules extracted from `PalettePage`, `DebugPage`, and `MapPage`, including - the hue–chroma wheel, comparison palette and sort toggles, swatch copy - timers, snippet tabs, spec matrix sorting and filters, feedback triage - status updates, and the map legend, hover panel, search pill, force weights, - and canvas shell. +- **Render and unit tests for the palette, debug, and map sections.** Vitest + suites under `app/src/sections/palette/`, `app/src/sections/debug/`, and + `app/src/sections/map/` cover the components split out of `PalettePage`, + `DebugPage`, and `MapPage`: the hue–chroma wheel, comparison palettes, + snippet tabs, the WCAG swatch stroke per theme, the spec matrix headers and + filters, feedback triage, and the map legend, hover panel, search pill, + force weights, and canvas shell. The state that lives in the pages is tested + on the real pages: `PalettePage.test.tsx` covers the swatch copy reset and + the CVD sort order, and `DebugPage.test.tsx` the spec sort orders, the + incomplete and low-score filters, and the feedback status PATCH with its + revert. Every behaviour the review named was checked by breaking it on + purpose and watching a test fail. (#12470)