diff --git a/app/src/pages/DebugPage.test.tsx b/app/src/pages/DebugPage.test.tsx index 20d3992ec8a..b5d0dc1aa1a 100644 --- a/app/src/pages/DebugPage.test.tsx +++ b/app/src/pages/DebugPage.test.tsx @@ -568,3 +568,313 @@ 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, + // 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, + 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('2 / 3')).toBeInTheDocument(); + expect(renderedSpecOrder()).toEqual(['heatmap-annotated', '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/pages/PalettePage.test.tsx b/app/src/pages/PalettePage.test.tsx new file mode 100644 index 00000000000..b2aa37f3fb8 --- /dev/null +++ b/app/src/pages/PalettePage.test.tsx @@ -0,0 +1,159 @@ +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(); + +vi.mock('src/hooks', () => ({ + useAnalytics: () => ({ + trackPageview: mockTrackPageview, + }), + useTheme: () => ({ + isDark: false, + }), +})); + +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(); + 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', () => { + 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/debug/DebugOverviewSections.test.tsx b/app/src/sections/debug/DebugOverviewSections.test.tsx new file mode 100644 index 00000000000..7169b5f76e8 --- /dev/null +++ b/app/src/sections/debug/DebugOverviewSections.test.tsx @@ -0,0 +1,286 @@ +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, userEvent, within } 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, bar tooltip on hover, and handles empty data', async () => { + const user = userEvent.setup(); + 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(); + + 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.queryByLabelText(/impls$/)).not.toBeInTheDocument(); + expect(screen.queryByText('2026-05-01')).not.toBeInTheDocument(); + expect(screen.queryByText('2026-05-30')).not.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(); + 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'); + 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..7397f214c76 --- /dev/null +++ b/app/src/sections/debug/FeedbackSection.test.tsx @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +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, within } 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', + }, +]; + +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 originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + + afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + 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(); + userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + 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 and contact info and forwards status and filter changes', async () => { + const user = userEvent.setup(); + const setFeedbackStatusFilter = vi.fn(); + const handleStatusChange = vi.fn(); + + const { rerender } = render( + + ); + + 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(); + 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(); + + // 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'); + + await user.selectOptions(fb1StatusSelect, 'done'); + expect(handleStatusChange).toHaveBeenCalledWith('fb-1', 'done'); + + // Status filter select calls its setter + const filterSelect = screen.getByDisplayValue('open (new + in progress)'); + await user.selectOptions(filterSelect, 'done'); + 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 new file mode 100644 index 00000000000..39c481f9532 --- /dev/null +++ b/app/src/sections/debug/SpecMatrix.test.tsx @@ -0,0 +1,268 @@ +import type { ComponentProps } from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { LIBRARIES } from 'src/constants'; +import { HeaderCell } from 'src/sections/debug/HeaderCell'; +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:00', + 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:00', + avg_score: 95.0, + ...ALL_LIBRARIES_FULL, + }), + makeSpec({ + id: 'bar-grouped', + title: 'Grouped Bar Chart', + updated: '2026-02-20T12:00:00', + avg_score: 84.5, + matplotlib: 91, + seaborn: 78, + }), + makeSpec({ + id: 'heatmap-annotated', + title: 'Annotated Heatmap', + updated: null, + avg_score: null, + }), +]; + +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, + }; +} + +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('shows the direction arrow only on the active sortable header and calls handleSort for each column', async () => { + const user = userEvent.setup(); + const handleSort = vi.fn(); + + const { rerender } = render( + + ); + + 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/ }), + }); + + let headers = getHeaders(); + expect(headers.id).toHaveTextContent('▲'); + expect(headers.title).not.toHaveTextContent(/[▲▼]/); + expect(headers.avg_score).not.toHaveTextContent(/[▲▼]/); + expect(headers.updated).not.toHaveTextContent(/[▲▼]/); + + 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(/[▲▼]/); + + await user.click(headers.id); + expect(handleSort).toHaveBeenLastCalledWith('id'); + + await user.click(headers.title); + expect(handleSort).toHaveBeenLastCalledWith('title'); + + await user.click(headers.avg_score); + expect(handleSort).toHaveBeenLastCalledWith('avg_score'); + + await user.click(headers.updated); + expect(handleSort).toHaveBeenLastCalledWith('updated'); + expect(handleSort).toHaveBeenCalledTimes(4); + }); + + it('calls filter setters when controls are used', async () => { + const user = userEvent.setup(); + const setSearchText = vi.fn(); + const setShowIncomplete = vi.fn(); + const setShowLowScores = vi.fn(); + const setMissingLibrary = vi.fn(); + + const { rerender } = render( + + ); + + const incompleteBtn = screen.getByRole('button', { + name: new RegExp(`incomplete <${LIBRARIES.length}`, 'i'), + }); + await user.click(incompleteBtn); + 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); + + const lowScoresBtn = screen.getByRole('button', { name: /low scores <90/i }); + await user.click(lowScoresBtn); + 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); + + const missingSelect = screen.getByRole('combobox'); + await user.selectOptions(missingSelect, 'matplotlib'); + expect(setMissingLibrary).toHaveBeenCalledWith('matplotlib'); + + const searchInput = screen.getByPlaceholderText('search spec id or title...'); + await user.type(searchInput, 'scatter'); + expect(setSearchText).toHaveBeenCalledWith('s'); + + // Render with active filter flags + rerender( + + ); + expect(screen.getByDisplayValue('scatter')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveValue('matplotlib'); + }); +}); diff --git a/app/src/sections/debug/debugFormat.test.ts b/app/src/sections/debug/debugFormat.test.ts new file mode 100644 index 00000000000..73111dadf12 --- /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:00', + 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:00')).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); + }); + }); +}); diff --git a/app/src/sections/map/MapCanvas.test.tsx b/app/src/sections/map/MapCanvas.test.tsx new file mode 100644 index 00000000000..6f8c5937e87 --- /dev/null +++ b/app/src/sections/map/MapCanvas.test.tsx @@ -0,0 +1,284 @@ +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, + }; +} + +const collideForceSentinel: ReturnType = { + initialize: () => {}, + radius: () => collideForceSentinel, + iterations: () => collideForceSentinel, + strength: () => collideForceSentinel, + distance: () => collideForceSentinel, +}; +const outlierSquashSentinel = Object.assign(() => {}, { initialize: () => {} }); + +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(() => collideForceSentinel), + createOutlierSquashForce: vi.fn(() => outlierSquashSentinel), + }; +} + +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); + + 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(firstNode)).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(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', collideForceSentinel); + expect(fgInstance.d3Force).toHaveBeenCalledWith('center'); + expect(fgInstance.d3Force).toHaveBeenCalledWith('outlier-squash', outlierSquashSentinel); + 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); + }); +}); diff --git a/app/src/sections/map/MapHoverPanel.test.tsx b/app/src/sections/map/MapHoverPanel.test.tsx new file mode 100644 index 00000000000..6ee74db40e7 --- /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', () => { + 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/MapSearchPill.test.tsx b/app/src/sections/map/MapSearchPill.test.tsx new file mode 100644 index 00000000000..72a6595c0d8 --- /dev/null +++ b/app/src/sections/map/MapSearchPill.test.tsx @@ -0,0 +1,187 @@ +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', () => { + 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', () => { + 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', () => { + vi.useFakeTimers(); + 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); + expect(screen.queryByText('Basic Scatter Plot')).not.toBeInTheDocument(); + }); + + 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(); + }); +}); diff --git a/app/src/sections/map/MapWeights.test.tsx b/app/src/sections/map/MapWeights.test.tsx new file mode 100644 index 00000000000..f7091ab2b5e --- /dev/null +++ b/app/src/sections/map/MapWeights.test.tsx @@ -0,0 +1,91 @@ +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(sliders[0]).toHaveAttribute('aria-valuenow', '3.5'); + 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); + }); +}); 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/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/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'); + }); +}); 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..5b73742d368 --- /dev/null +++ b/app/src/sections/palette/PaletteGridSection.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { PALETTE } from 'src/pages/PalettePage.helpers'; +import { PaletteGridSection } from 'src/sections/palette/PaletteGridSection'; +import { fireEvent, render, screen, within } from 'src/test-utils'; + +describe('PaletteGridSection', () => { + 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(); + + 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'); + + 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(); + }); + + 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(); + + fireEvent.click(brandButton); + expect(copyHex).toHaveBeenCalledWith('#009E73'); + + rerender( + + ); + + expect(within(brandButton).getByText('copied ✓')).toBeInTheDocument(); + expect(screen.getAllByText('copied ✓')).toHaveLength(1); + expect(screen.getByText('#C475FD')).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..2961c7dcbf5 --- /dev/null +++ b/app/src/sections/palette/SemanticAnchorsSection.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SemanticAnchorsSection } from 'src/sections/palette/SemanticAnchorsSection'; +import { fireEvent, render, screen } from 'src/test-utils'; + +describe('SemanticAnchorsSection', () => { + 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('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'); + + fireEvent.click(amberSwatch); + expect(copyHex).toHaveBeenCalledWith('#DDCC77'); + + rerender(); + expect(amberSwatch).toHaveTextContent('copied ✓'); + 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 new file mode 100644 index 00000000000..d94c39dc5ff --- /dev/null +++ b/app/src/sections/palette/WcagContrastSection.test.tsx @@ -0,0 +1,61 @@ +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(); + + 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(); + 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/app/src/sections/palette/paletteHelpers.test.ts b/app/src/sections/palette/paletteHelpers.test.ts new file mode 100644 index 00000000000..bb44a4d85ad --- /dev/null +++ b/app/src/sections/palette/paletteHelpers.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { + COMPARISONS, + DE_T_BAD, + DE_T_OK, + DE_T_WARN, + deltaECellBg, + oklchToRgb, + starPoints, + textOn, +} 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)'); + }); + }); +}); diff --git a/changelog.d/section-tests.md b/changelog.d/section-tests.md new file mode 100644 index 00000000000..86d8aad43f3 --- /dev/null +++ b/changelog.d/section-tests.md @@ -0,0 +1,14 @@ +### Added + +- **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)