Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
110 changes: 110 additions & 0 deletions apps/web/__tests__/accessibility/data-regression.a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Accessibility tests for the Data & Regression tab (jest-axe).
*
* Guard: native <table> semantics with caption/headers, labelled inputs and
* an accessibly-named residual toggle — verified with axe, not just biome.
*
* The global vitest.setup ResizeObserver mock is a no-op, so DataPointsOverlay
* (rendered directly inside this tab) never leaves its {width:0,height:0}
* early-return branch under jsdom — the accessible, keyboard-operable
* draggable points (`<g role="button" tabIndex={0} aria-label=...>`) would
* never actually be mounted, so no test in this suite would ever notice a
* regression to that markup. The "fitted state" test below stubs a firing
* ResizeObserver so the points actually mount and asserts on them directly
* (role/tabindex/accessible name); full axe coverage of that exact markup
* lives in DataPointsOverlay.test.tsx (see the note on that test for why
* axe() isn't also run here).
*/

import { fireEvent, render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DataRegressionTab } from '@/components/plots';

vi.mock('@/components/plots/Plot2D', () => ({
Plot2D: () => <canvas data-testid="plot2d" aria-label="plot" role="img" />,
}));

/** A ResizeObserver stub that immediately fires its callback with a fixed,
* non-zero content rect — unlike the global no-op mock in vitest.setup.ts. */
class FiringResizeObserver {
#callback: ResizeObserverCallback;

constructor(callback: ResizeObserverCallback) {
this.#callback = callback;
}

observe(target: Element) {
this.#callback(
[{ target, contentRect: { width: 600, height: 400 } } as unknown as ResizeObserverEntry],
this as unknown as ResizeObserver,
);
}

unobserve() {}
disconnect() {}
}

describe('Data & Regression accessibility (WCAG 2.2)', () => {
it('empty tab has no axe violations', async () => {
const { container } = render(<DataRegressionTab />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

describe('with draggable data points mounted', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', FiringResizeObserver);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('fitted state mounts keyboard-operable draggable points with accessible names', async () => {
render(<DataRegressionTab />);

const table = screen.getByRole('table', { name: 'dataTitle' });
fireEvent.paste(table, {
clipboardData: { getData: () => '1\t3\n2\t5\n3\t7\n4\t9' },
});
fireEvent.click(screen.getByRole('button', { name: 'canned.linear' }));
await screen.findByText('status.converged');

// Residual toggle is a labelled switch.
expect(screen.getByRole('switch', { name: 'residualsToggle' })).toBeInTheDocument();

// The draggable data points actually mounted (regression guard for the
// ResizeObserver-mock blind spot described above) and are keyboard
// operable. (The global next-intl mock returns raw `key:{"args":...}`
// strings, not the formatted `pointLabel` sentence.)
const points = screen.getAllByRole('button', { name: /^pointLabel:/ });
expect(points.length).toBeGreaterThan(0);
for (const point of points) {
expect(point).toHaveAttribute('tabindex', '0');
expect(point).toHaveAccessibleName();
}

// NOTE: axe() is intentionally not run in this scenario. The global
// next-intl mock renders interpolated translations as literal
// `key:{"x":"1",...}` strings, and axe-core's selector generator
// crashes under happy-dom when an aria-label contains embedded double
// quotes (a test-environment interaction bug, not a real accessibility
// defect — real translated point labels never contain quote
// characters). The interactive markup itself IS exercised by axe in
// DataPointsOverlay.test.tsx, which supplies plain-text labels.
});
});

it('data table uses native semantics: caption and column headers', () => {
render(<DataRegressionTab />);

const table = screen.getByRole('table', { name: 'dataTitle' });
expect(table.querySelector('caption')).not.toBeNull();
expect(screen.getAllByRole('columnheader').length).toBeGreaterThanOrEqual(2);
// Every data cell input is labelled.
for (const input of screen.getAllByRole('textbox')) {
expect(input).toHaveAccessibleName();
}
});
});
14 changes: 14 additions & 0 deletions apps/web/app/[locale]/plot/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { VariableSliders } from '@/components/plot/variable-sliders';
import {
type AnalysisFunction,
buildRelationConfig,
DataRegressionTab,
type FunctionDefinition,
FunctionInput,
Plot2D,
Expand Down Expand Up @@ -918,6 +919,12 @@ export default function PlotsExamplesPage() {
>
{t('tab.3dSurface')}
</TabsTrigger>
<TabsTrigger
value="data-regression"
className="data-[state=active]:bg-linear-to-br/oklab data-[state=active]:from-rose-900/50 data-[state=active]:to-orange-900/50 data-[state=active]:text-rose-100 data-[state=active]:border data-[state=active]:border-rose-500/50 data-[state=active]:shadow-[0_0_15px_oklch(0.645_0.246_16.44_/_0.3)] transition-all duration-200"
>
{t('tab.dataRegression')}
</TabsTrigger>
</TabsList>
</m.div>

Expand Down Expand Up @@ -1408,6 +1415,13 @@ const config = {
</pre>
</div>
</TabsContent>

{/* ----------------------------------------------------------------
DATA & REGRESSION TAB
---------------------------------------------------------------- */}
<TabsContent value="data-regression" className="space-y-6">
<DataRegressionTab />
</TabsContent>
</Tabs>

{/* Feature cards */}
Expand Down
30 changes: 30 additions & 0 deletions apps/web/components/plots/Plot2D.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ export interface Plot2DProps {
onCanvasReady?: (canvas: HTMLCanvasElement) => void;
/** When true, renders the annotation toolbar and overlay. Defaults to false. */
enableAnnotations?: boolean;
/**
* Controlled-viewport mode: when true, external changes to `config.viewport`
* are pushed into the interaction controller so the next drag continues from
* the new viewport instead of snapping back to the controller's stale seed.
* Use when the parent feeds `onViewportChange` back into `config.viewport`
* (e.g. the Data & Regression tab's auto-fit). Defaults to false, preserving
* the legacy contract where `config.viewport` is only the initial view.
*/
syncViewportToConfig?: boolean;
}

/**
Expand All @@ -57,6 +66,7 @@ export function Plot2D({
onViewportChange,
onCanvasReady,
enableAnnotations = false,
syncViewportToConfig = false,
}: Plot2DProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -382,6 +392,26 @@ export function Plot2D({
// config re-render) and read config via the stable ref inside the handler.
}, [isReady, enableInteractions, config.type]);

// Controlled-viewport mode: push external config.viewport changes into the
// controller. Value-compared, so the echo from the controller's own events
// (viewport state fed back into config by the parent) terminates immediately.
useEffect(() => {
if (!syncViewportToConfig) return;
if (config.type !== '2d-cartesian' && config.type !== '2d-parametric') return;
const controller = controllerRef.current;
if (!controller) return;
const target = config.viewport;
const current = controller.getViewport();
if (
current.xMin !== target.xMin ||
current.xMax !== target.xMax ||
current.yMin !== target.yMin ||
current.yMax !== target.yMax
) {
controller.setViewport(target);
}
}, [syncViewportToConfig, config]);

// --- Fix 1: Debounced auto-refresh when config changes ---
// Use a ref to hold the pending timer so we can clear it on cleanup.
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down
13 changes: 13 additions & 0 deletions apps/web/components/plots/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ export type { PolarAxisLabelsProps } from './PolarAxisLabels';
export { PolarAxisLabels } from './PolarAxisLabels';
export type { RelationDefinition, RelationInputProps } from './RelationInput';
export { RELATION_COLORS, RelationInput } from './RelationInput';
export type {
DataPointsOverlayProps,
OverlayPoint,
OverlayViewport,
} from './regression/DataPointsOverlay';
export { DataPointsOverlay } from './regression/DataPointsOverlay';
export { DataRegressionTab } from './regression/DataRegressionTab';
export type { DataTableProps, DataTableValue } from './regression/DataTable';
export { DataTable } from './regression/DataTable';
export type { FitStatsPanelProps } from './regression/FitStatsPanel';
export { FitStatsPanel } from './regression/FitStatsPanel';
export type { ModelParseState, RegressionModelInputProps } from './regression/RegressionModelInput';
export { RegressionModelInput } from './regression/RegressionModelInput';
export type { BuildRelationConfigOptions } from './relation-config';
export { buildRelationConfig, buildRelationEntries } from './relation-config';
export type { SurfaceEditor3DProps } from './SurfaceEditor3D';
Expand Down
172 changes: 172 additions & 0 deletions apps/web/components/plots/regression/DataPointsOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Tests for the draggable/keyboard-operable data points overlay.
*
* The global vitest.setup ResizeObserver mock is a no-op (`observe(){}`
* never invokes its callback), so under jsdom/happy-dom the component's
* `size` state never leaves {0,0} and it permanently takes its early-return
* branch — a bare `<svg aria-hidden="true">` with none of the interactive
* `<g role="button">` points. That means the a11y suite and DataRegressionTab
* tests never actually exercise this file's headline accessibility surface
* (keyboard-operable draggable points). This file locally stubs a
* ResizeObserver that fires with a real size so the interactive markup
* mounts, and asserts directly on it.
*/

import { fireEvent, render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DataPointsOverlay, type OverlayPoint } from './DataPointsOverlay';

const VIEWPORT = { xMin: -10, xMax: 10, yMin: -10, yMax: 10 };
const POINTS: OverlayPoint[] = [
{ x: 1, y: 2, row: 0 },
{ x: -3, y: 4, row: 1 },
];

/** A ResizeObserver stub that immediately fires its callback with a fixed,
* non-zero content rect — unlike the global no-op mock in vitest.setup.ts. */
class FiringResizeObserver {
#callback: ResizeObserverCallback;

constructor(callback: ResizeObserverCallback) {
this.#callback = callback;
}

observe(target: Element) {
this.#callback(
[{ target, contentRect: { width: 600, height: 400 } } as unknown as ResizeObserverEntry],
this as unknown as ResizeObserver,
);
}

unobserve() {}
disconnect() {}
}

describe('DataPointsOverlay', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', FiringResizeObserver);
// jsdom/happy-dom never computes real layout; getBoundingClientRect
// returns all zeros unless stubbed. Match the FiringResizeObserver size
// so toMath()'s pixel→math conversion (which reads the live rect, not
// React state) has a real width/height to divide by.
vi.spyOn(SVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: 600,
height: 400,
top: 0,
left: 0,
right: 600,
bottom: 400,
x: 0,
y: 0,
toJSON: () => {},
} as DOMRect);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('renders a focusable, labelled button for each finite point', () => {
render(
<DataPointsOverlay
points={POINTS}
viewport={VIEWPORT}
label="Data & fitted curve"
color="#0891b2"
showResiduals={false}
onPointMove={vi.fn()}
pointLabel={(x, y) => `Data point (${x}, ${y})`}
/>,
);

const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(button).toHaveAttribute('tabindex', '0');
expect(button).toHaveAccessibleName();
}
expect(screen.getByRole('button', { name: 'Data point (1, 2)' })).toBeInTheDocument();
});

it('has no axe violations once the interactive points are mounted', async () => {
const { container } = render(
<DataPointsOverlay
points={POINTS}
viewport={VIEWPORT}
label="Data & fitted curve"
color="#0891b2"
showResiduals={false}
onPointMove={vi.fn()}
pointLabel={(x, y) => `Data point (${x}, ${y})`}
/>,
);

// Sanity check: this is exercising the real interactive markup, not the
// childless aria-hidden early-return svg.
expect(screen.getAllByRole('button')).toHaveLength(2);

const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('moves a point with the arrow keys (keyboard-operable drag)', () => {
const onPointMove = vi.fn();
render(
<DataPointsOverlay
points={POINTS}
viewport={VIEWPORT}
label="Data & fitted curve"
color="#0891b2"
showResiduals={false}
onPointMove={onPointMove}
pointLabel={(x, y) => `Data point (${x}, ${y})`}
/>,
);

const point = screen.getByRole('button', { name: 'Data point (1, 2)' });
fireEvent.keyDown(point, { key: 'ArrowRight' });

expect(onPointMove).toHaveBeenCalledTimes(1);
const [row, x, y] = onPointMove.mock.calls[0] as [number, number, number];
expect(row).toBe(0);
expect(x).toBeGreaterThan(1);
expect(y).toBe(2);
});

it('drags a point by a grab offset instead of snapping to the cursor', () => {
const onPointMove = vi.fn();
render(
<DataPointsOverlay
points={POINTS}
viewport={VIEWPORT}
label="Data & fitted curve"
color="#0891b2"
showResiduals={false}
onPointMove={onPointMove}
pointLabel={(x, y) => `Data point (${x}, ${y})`}
/>,
);

// Point (1, 2) maps to pixel (330, 160) in a 600x400 viewport over
// [-10, 10]. Grab slightly off-center (a sloppy click within the 12px
// hit circle, not dead-center on the point) and move the cursor by a
// tiny amount — a naive "snap to cursor" implementation would move the
// point almost exactly onto the cursor's math position instead of by
// the cursor's actual delta.
const point = screen.getByRole('button', { name: 'Data point (1, 2)' });
fireEvent.pointerDown(point, { clientX: 336, clientY: 166, pointerId: 1 });
fireEvent.pointerMove(point, { clientX: 337, clientY: 167, pointerId: 1 });

expect(onPointMove).toHaveBeenCalledTimes(1);
const [row, x, y] = onPointMove.mock.calls[0] as [number, number, number];
expect(row).toBe(0);
// A 1px cursor move should translate the point by ~1px worth of math
// units (20/600 ≈ 0.033), NOT snap it to the cursor's absolute position
// (which — uncompensated for the initial grab offset — would land far
// from (1, 2)).
expect(x).toBeCloseTo(1 + 20 / 600, 2);
expect(y).toBeCloseTo(2 - 20 / 400, 2);
});
});
Loading