Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
48e3e31
test(palette): add unit tests for paletteHelpers
MarkusNeusinger Oct 30, 2026
1b890ee
test(palette): add render tests for ChromaWheel, CodeBlock, and Delta…
MarkusNeusinger Oct 30, 2026
2ff23e5
test(palette): add render and interaction tests for the eight palette…
MarkusNeusinger Oct 30, 2026
0295035
test(debug): add unit tests for debugFormat helpers
MarkusNeusinger Oct 30, 2026
4816a61
test(debug): add render tests for SpecMatrix, SortableHeader, and Hea…
MarkusNeusinger Oct 30, 2026
2f6121f
test(debug): add render tests for feedback and overview debug sections
MarkusNeusinger Oct 30, 2026
bebc83b
test(map): add render tests for MapLegend, MapHoverPanel, and MapWeights
MarkusNeusinger Oct 30, 2026
1ff545b
test(map): add render and keyboard tests for MapSearchPill
MarkusNeusinger Oct 30, 2026
46458a4
test(map): add render tests for MapCanvas with ForceGraph2D stubbed
MarkusNeusinger Oct 30, 2026
5494415
Merge branch 'gemini/section-tests-1' into gemini/section-tests
MarkusNeusinger Oct 30, 2026
e9b3b72
Merge branch 'gemini/section-tests-2' into gemini/section-tests
MarkusNeusinger Oct 30, 2026
389f48d
Merge branch 'gemini/section-tests-3' into gemini/section-tests
MarkusNeusinger Oct 30, 2026
2fc363c
docs(changelog): add fragment for section-tests
MarkusNeusinger Oct 30, 2026
aa80c44
test(map): tighten assertions and timer handling in map section tests
MarkusNeusinger Oct 30, 2026
83e89d4
test(palette): add PalettePage test and test section contracts directly
MarkusNeusinger Oct 30, 2026
8da8a4e
test(debug): test real DebugPage sorting/triage and refine section co…
MarkusNeusinger Oct 30, 2026
8de98c6
Merge remote-tracking branch 'origin/gemini/section-tests-r1-1' into …
MarkusNeusinger Oct 30, 2026
63ae112
Merge remote-tracking branch 'origin/gemini/section-tests-r1-2' into …
MarkusNeusinger Oct 30, 2026
3d884c3
Merge remote-tracking branch 'origin/gemini/section-tests-r1-3' into …
MarkusNeusinger Oct 30, 2026
7c1ce5a
test(app): close the review gaps in the section tests
MarkusNeusinger Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 310 additions & 0 deletions app/src/pages/DebugPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DebugPage />);

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(<DebugPage />);

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(<DebugPage />);

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<string, string>)?.['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=<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$/)
);
});
});
});
Loading
Loading