# Files changed · PR \#130 · flambo/atlantis

[View on GitCafe](https://git.cafe/flambo/atlantis/pull/130/diffs)

Repository: [flambo/atlantis](https://git.cafe/flambo/atlantis)

Visibility: public

[test\(web\): establish renderer-independent dashboard e2e baseline](https://git.cafe/flambo/atlantis/pull/130.md)

Version: 0

Head commit: 62e2a6dfdf66e82e298b9d919010683c3ad198cf

Base commit: 95a60659bbc2c419467a7611f7e29cacbf8d06de

Comparison base: 95a60659bbc2c419467a7611f7e29cacbf8d06de

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/playwright.config.js

Status: modified; +1; −0

```diff
@@ -22,6 +22,7 @@
 		timeout: 5_000
 	},
 	fullyParallel: true,
+	workers: 4,
 	forbidOnly: Boolean(process.env.CI),
 	retries: process.env.CI ? 2 : 0,
 	reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'list',
```

## apps/web/src/lib/components/charts/ChartCard.svelte

Status: modified; +16; −1

```diff
@@ -60,7 +60,22 @@
 	} = $props();
 </script>
 
+<Card.Root
+	size="sm"
+	class="gap-0 py-0"
+	data-testid="chart-card-state"
+	data-state={unavailableCopy || selectionUnavailableCopy
+		? 'unavailable'
+		: loading
+			? 'loading'
+			: error
-<Card.Root size="sm" class="gap-0 py-0">
+				? 'error'
+				: noMetrics
+					? 'no-metrics'
+					: empty
+						? 'empty'
+						: 'ready'}
+>
 	<Card.Header
 		class="border-border relative cursor-grab border-b py-4 select-none active:cursor-grabbing"
 		draggable="true"
```

## apps/web/src/lib/components/charts/ChartContainer.svelte

Status: modified; +2; −1

```diff
@@ -767,7 +767,8 @@
 	onmouseup={finishRangeSelection}
 	onmouseleave={finishRangeSelection}
 >
+	<canvas bind:this={chartCanvas} class="h-full w-full" aria-label="Traffic overview chart"
+	></canvas>
-	<canvas bind:this={chartCanvas} class="h-full w-full"></canvas>
 	{#if rangeDrag.isDraggingRange && selectionWidth >= MIN_DRAG_PIXELS}
 		<div
 			class="border-muted-foreground/70 bg-muted/20 pointer-events-none absolute border"
```

## apps/web/src/lib/components/charts/CoverageStrip.svelte

Status: modified; +16; −1

```diff
@@ -333,6 +333,11 @@
 		const datasets = visibleTimelines.map((timeline, sourceIndex) => ({
 			label: timeline.sourceId,
 			data: timeline.buckets.map(() => sourceIndex),
+			chartContractValues: timeline.buckets.map((bucket) =>
+				bucket.coverage.state === 'unknown'
+					? null
+					: bucket.coverage.observedUnits / bucket.coverage.expectedUnits
+			),
 			borderColor: 'transparent',
 			backgroundColor: 'transparent',
 			borderWidth: 0,
@@ -493,7 +498,17 @@
 	});
 </script>
 
+<div
+	class="bg-card rounded-lg border shadow-sm"
+	data-testid="coverage-strip-card"
+	data-state={loading
-<div class="bg-card rounded-lg border shadow-sm" data-testid="coverage-strip-card">
+		? 'loading'
+		: error
+			? 'error'
+			: visibleTimelines.length === 0
+				? 'empty'
+				: 'ready'}
+>
 	<div
 		class="relative cursor-grab border-b p-3 select-none active:cursor-grabbing"
 		draggable="true"
```

## apps/web/src/lib/components/charts/StructureFunctionChart.svelte

Status: modified; +1; −1

```diff
@@ -238,6 +238,6 @@
 		{/if}
 	</div>
 	<div class="relative h-72 w-full min-w-0 sm:h-96">
-		<canvas bind:this={chartCanvas}></canvas>
+		<canvas bind:this={chartCanvas} aria-label="Structure function chart"></canvas>
 	</div>
 </div>
```

## apps/web/src/lib/components/charts/chart-contract.ts

Status: added; +80; −0

```diff
@@ -1,0 +1,80 @@
+export type RenderedSeries = {
+	label: string;
+	visible: boolean;
+	count: number;
+	min: number | null;
+	max: number | null;
+	total: number;
+	point?: { x: number; y: number };
+	legend?: { x: number; y: number };
+};
+
+export type RenderedChart = {
+	name: string;
+	kind: string;
+	axes: string[];
+	series: RenderedSeries[];
+};
+
+export function createChartContract(surface: HTMLElement | SVGElement) {
+	const summary = document.createElement('div');
+	summary.className = 'sr-only';
+	summary.dataset.testid = 'chart-render-state';
+	summary.setAttribute('role', 'group');
+	const tooltip = document.createElement('span');
+	tooltip.dataset.testid = 'chart-tooltip';
+	tooltip.hidden = true;
+	surface.dataset.testid = 'chart-surface';
+	surface.setAttribute('role', 'img');
+	surface.after(summary);
+
+	return {
+		render(snapshot: RenderedChart) {
+			const visible = snapshot.series.filter((series) => series.visible);
+			const marks = visible.reduce((count, series) => count + series.count, 0);
+			summary.dataset.state = marks > 0 ? 'ready' : 'empty';
+			surface.dataset.chartRendered = 'true';
+			summary.dataset.kind = snapshot.kind;
+			summary.dataset.seriesCount = String(visible.length);
+			summary.dataset.markCount = String(marks);
+			const label = `${snapshot.name}: ${visible.length} series, ${marks} data points. ${snapshot.axes.join(', ')}.`;
+			summary.setAttribute('aria-label', 'Rendered chart summary');
+			surface.setAttribute('aria-description', label);
+			const entries = snapshot.series.map((series) => {
+				const entry = document.createElement('span');
+				entry.dataset.testid = 'chart-series';
+				entry.dataset.visible = String(series.visible);
+				entry.dataset.count = String(series.count);
+				entry.dataset.min = String(series.min);
+				entry.dataset.max = String(series.max);
+				entry.dataset.total = String(series.total);
+				if (series.point) {
+					entry.dataset.pointX = String(series.point.x);
+					entry.dataset.pointY = String(series.point.y);
+				}
+				if (series.legend) {
+					entry.dataset.legendX = String(series.legend.x);
+					entry.dataset.legendY = String(series.legend.y);
+				}
+				entry.textContent = series.label;
+				return entry;
+			});
+			const axes = snapshot.axes.map((label) => {
+				const axis = document.createElement('span');
+				axis.dataset.testid = 'chart-axis';
+				axis.textContent = label;
+				return axis;
+			});
+			summary.replaceChildren(...axes, ...entries, tooltip);
+		},
+		tooltip(text: string) {
+			if (tooltip.textContent !== text) tooltip.textContent = text;
+			tooltip.hidden = text.length === 0;
+		},
+		destroy() {
+			summary.remove();
+			delete surface.dataset.chartRendered;
+			surface.removeAttribute('aria-description');
+		}
+	};
+}
```

## apps/web/src/lib/components/charts/chart-registry.ts

Status: modified; +3; −1

```diff
@@ -11,6 +11,7 @@
 	Tooltip
 } from 'chart.js';
 import { verticalCrosshairPlugin } from './crosshair-plugin';
+import { chartContractPlugin } from './chartjs-contract';
 
 let registered = false;
 
@@ -29,7 +30,8 @@
 		PointElement,
 		ScatterController,
 		Tooltip,
-		verticalCrosshairPlugin
+		verticalCrosshairPlugin,
+		chartContractPlugin
 	);
 	registered = true;
 }
```

## apps/web/src/lib/components/charts/chartjs-contract.ts

Status: added; +118; −0

```diff
@@ -1,0 +1,118 @@
+import type { Chart, Plugin } from 'chart.js';
+import { createChartContract, type RenderedChart } from './chart-contract';
+
+function isHitBox(
+	value: unknown
+): value is { left: number; top: number; width: number; height: number } {
+	return (
+		typeof value === 'object' &&
+		value !== null &&
+		['left', 'top', 'width', 'height'].every(
+			(key) => key in value && typeof Reflect.get(value, key) === 'number'
+		)
+	);
+}
+
+const contracts = new WeakMap<Chart, ReturnType<typeof createChartContract>>();
+
+export const chartContractPlugin: Plugin = {
+	id: 'chartContract',
+	afterRender(chart) {
+		const series = chart.data.datasets.map((dataset, index) => {
+			const meta = chart.getDatasetMeta(index);
+			let count = 0;
+			let min = Infinity;
+			let max = -Infinity;
+			let total = 0;
+			let target: { x: number; y: number } | undefined;
+			for (let point = 0; point < dataset.data.length; point += 1) {
+				const parsed = meta._parsed[point];
+				if (
+					!parsed ||
+					typeof parsed !== 'object' ||
+					!('x' in parsed) ||
+					!('y' in parsed) ||
+					typeof parsed.x !== 'number' ||
+					typeof parsed.y !== 'number' ||
+					!Number.isFinite(parsed.y)
+				)
+					continue;
+				if (meta.xScale && (parsed.x < meta.xScale.min || parsed.x > meta.xScale.max)) continue;
+				const element = meta.data[point];
+				if (!element || !Number.isFinite(element.x) || !Number.isFinite(element.y)) continue;
+				const values = 'chartContractValues' in dataset ? dataset.chartContractValues : null;
+				const value = Array.isArray(values) ? values[point] : parsed.y;
+				if (typeof value !== 'number' || !Number.isFinite(value)) continue;
+				if (!target) target = { x: element.x / chart.width, y: element.y / chart.height };
+				count += 1;
+				min = Math.min(min, value);
+				max = Math.max(max, value);
+				total += value;
+			}
+			const legendIndex =
+				chart.legend?.legendItems?.findIndex((item) => item.datasetIndex === index) ?? -1;
+			const hitBoxes =
+				chart.legend && 'legendHitBoxes' in chart.legend ? chart.legend.legendHitBoxes : null;
+			const candidate =
+				chart.options.plugins?.legend?.display && Array.isArray(hitBoxes)
+					? hitBoxes[legendIndex]
+					: null;
+			const box = isHitBox(candidate) ? candidate : null;
+			return {
+				point: chart.isDatasetVisible(index) ? target : undefined,
+				legend: box
+					? {
+							x: (box.left + box.width / 2) / chart.width,
+							y: (box.top + box.height / 2) / chart.height
+						}
+					: undefined,
+				label: dataset.label ?? 'Spectrum',
+				visible: chart.isDatasetVisible(index),
+				count,
+				min: count ? min : null,
+				max: count ? max : null,
+				total
+			};
+		});
+		const snapshot: RenderedChart = {
+			name: chart.canvas.getAttribute('aria-label') ?? 'Chart',
+			kind: chart.data.datasets.some((dataset) => 'fill' in dataset && dataset.fill)
+				? 'stacked'
+				: (chart.getDatasetMeta(0)?.type ?? 'line'),
+			axes: Object.values(chart.scales).flatMap((scale) => {
+				const title = 'title' in scale.options ? scale.options.title : null;
+				return title &&
+					typeof title === 'object' &&
+					'display' in title &&
+					title.display &&
+					'text' in title
+					? [String(title.text)]
+					: [];
+			}),
+			series
+		};
+		let contract = contracts.get(chart);
+		if (!contract) {
+			contract = createChartContract(chart.canvas);
+			contracts.set(chart, contract);
+		}
+		contract.render(snapshot);
+	},
+	afterDraw(chart) {
+		const tooltip = chart.tooltip;
+		const text = tooltip?.opacity
+			? [
+					...tooltip.title,
+					...tooltip.beforeBody,
+					...tooltip.body.flatMap((body) => [...body.before, ...body.lines, ...body.after]),
+					...tooltip.afterBody,
+					...tooltip.footer
+				].join('\n')
+			: '';
+		contracts.get(chart)?.tooltip(text);
+	},
+	afterDestroy(chart) {
+		contracts.get(chart)?.destroy();
+		contracts.delete(chart);
+	}
+};
```

## apps/web/src/lib/components/netflow/NetflowDashboard.svelte

Status: modified; +6; −1

```diff
@@ -313,7 +313,12 @@
 	});
 </script>
 
+<Card.Root
+	size="sm"
+	class="gap-0 py-0"
+	data-testid="chart-card-state"
+	data-state={loading ? 'loading' : error ? 'error' : results.length === 0 ? 'empty' : 'ready'}
+>
-<Card.Root size="sm" class="gap-0 py-0">
 	<Card.Header
 		class="border-border relative cursor-grab border-b py-4 select-none active:cursor-grabbing"
 		draggable="true"
```

## apps/web/tests/e2e/README.md

Status: added; +38; −0

```diff
@@ -1,0 +1,38 @@
+# Dashboard e2e contract
+
+Run `bun run test:e2e` at the repository root. Playwright builds and starts the normal web server at port 4173 and creates disposable SQLite products in the system temporary directory. Four workers limit browser and SQLite contention. The fixture contains both IP families, five granularities, directional traffic, characteristic averages, port cardinalities, IP/protocol counts, MAAD dimensions and spectra, a second populated day, and a separate alerts database. The external-side MAAD dataset also exercises unavailable selections.
+
+The suite uses user-facing controls and rendered chart summaries. Request assertions supplement rendered-data checks for deferred fetching, cancellation, and drilldown. There are no pixel snapshots, canvas pixel reads, or intercepted drawing calls.
+
+## Hooks to preserve during a renderer migration
+
+Keep `data-chart-id`, `data-chart-activated`, `data-chart-sentinel`, deferred-card test IDs, existing canvas accessible names on the replacement graphics, and the named filter groups. The graphics element can become SVG while retaining the following contract:
+
+| Hook                                   | Meaning                                                                                                                                                                                                                                                                                                    |
+| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `data-testid="chart-surface"`          | Visible graphic with `role="img"`, a chart-specific accessible name, and `aria-description` describing series, finite points, and displayed axes.                                                                                                                                                          |
+| `data-chart-rendered="true"`           | A completed renderer pass, including a pass with zero finite points. Also used by the performance harness.                                                                                                                                                                                                 |
+| `data-testid="chart-render-state"`     | Sibling summary in the same graphics parent. Published after rendering, removed on destruction.                                                                                                                                                                                                            |
+| `data-state="ready\|empty"`            | `ready` requires at least one finite point in a visible series inside the displayed x bounds. All-hidden or zero-point charts are `empty`.                                                                                                                                                                 |
+| `data-kind`                            | `line`, `stacked`, or `scatter` for the current chart choice.                                                                                                                                                                                                                                              |
+| `data-series-count`, `data-mark-count` | Visible series count and sum of finite visible-series vertices or scatter points. Null, unknown coverage, nonfinite and unpositioned observations are excluded. Coverage summaries report observed/expected ratios rather than lane coordinates. A line vertex counts even when its point glyph is hidden. |
+| `data-testid="chart-axis"`             | Text of each displayed axis title. Compact layouts may omit titles.                                                                                                                                                                                                                                        |
+| `data-testid="chart-series"`           | Legend or series label with `data-visible`, `data-count`, `data-min`, `data-max`, and `data-total`. Summaries cover finite values inside displayed x bounds, including zero, and remain available for hidden legend entries. No finite values means count 0, min/max `null`, total 0.                      |
+| `data-point-x/y`, `data-legend-x/y`    | Optional interaction targets on a series summary, normalized to the graphics bounds. Point target is a finite plotted vertex, legend target is the center of the clickable legend entry. Used to drive real pointer events; tests do not assert coordinate values. Omit unavailable targets.               |
+| `data-testid="chart-tooltip"`          | Current rendered tooltip text in the sibling summary, hidden and cleared when no tooltip is shown. Preserve the existing spectrum DOM `role="tooltip"` contract too.                                                                                                                                       |
+| `data-testid="chart-card-state"`       | Card lifecycle: `loading`, `error`, `no-metrics`, `empty`, `unavailable`, or `ready`. Card `ready` means its content branch is active; the renderer summary proves finite data actually rendered.                                                                                                          |
+| `data-testid="coverage-strip-card"`    | Coverage card retains its test ID and publishes `loading`, `error`, `empty`, or `ready`.                                                                                                                                                                                                                   |
+
+`chart-contract.ts` publishes a renderer-independent DOM summary with memory proportional to series and axes. `chartjs-contract.ts` is the temporary Chart.js adapter. Replace that adapter in the migration and call the publisher from the new renderer after marks have been drawn. Do not synthesize readiness solely from an API response or from a mounted graphics element. The existing spectrum tooltip and loading/empty/unavailable copy remain user-facing contracts.
+
+## Numerical and interaction assertions
+
+The tests verify TCP flows of 100 across both families, 80 for IPv4, 20 for IPv6, and 50 for ingress. Duration averages are 1750 ms combined, 2000 ms for IPv4, and 1000 ms for IPv6. Port counts change from 8 to 2 with family selection. TCP packets and bytes are 1000 and 102400. Daily TCP flow totals change from 100 to 300 as the second populated day enters the range, then to 200 when the first day is excluded. MAAD side/order/measure checks assert fixture values with float tolerance.
+
+Preserve date/grouping/direction/MAAD URL state, history navigation, file context on drilldown and next-file links, brush drilldown, legend visibility, chart order persistence, deferred activation, cached-state recovery, and cancellation of obsolete requests. Missing and unknown coverage must retain the data contract; do not replace null observations with zero in the chart adapter.
+
+Some baseline behavior needs later product work: sources and local metric selections reset on reload, some unknown windows produce blank plot areas, and charts currently lack keyboard equivalents for pointer drilldown and legends. The suite verifies useful behavior without requiring those rough edges to remain. Improve them with new assertions when their fixes land.
+
+## Coverage limits
+
+The fixture suite covers the dataset index, dashboard, alerts, file lookup, file detail, and error pages at narrow and wide viewports. Alert failure and clipboard denial tests inject failures; populated alerts use the real temporary alerts database. Browser coverage is Chromium. A separate manual audit of the large read-only performance product supplies the visual reference and exercises multiple real sources. These tests do not measure performance or certify accessibility compliance.
```

## apps/web/tests/e2e/alerts-baseline.spec.ts

Status: added; +80; −0

```diff
@@ -1,0 +1,80 @@
+import { expect, test } from '@playwright/test';
+
+test('alerts tail, horizon, sort and pagination change the rendered table', async ({ page }) => {
+	await page.goto('/');
+	await page.getByRole('button', { name: /Playwright Fixture/ }).click();
+	await page.getByRole('link', { name: 'Alerts', exact: true }).click();
+	await expect(page.getByText(/Live · last window/)).toBeVisible();
+	const table = page.getByRole('region', { name: 'Anomalous addresses' });
+	await expect(table.getByRole('row')).toHaveCount(101);
+	await expect(table.getByRole('row').nth(1)).toContainText('198.51.100.105');
+	await expect(table.getByRole('row').nth(1)).toContainText('2.105');
+	await expect(page.getByText('showing 100 of 105')).toBeVisible();
+	await page.getByRole('button', { name: 'Show more', exact: true }).click();
+	await expect(table.getByRole('row')).toHaveCount(106);
+	await expect(page.getByText('showing 105 of 105')).toBeVisible();
+	await expect(page.getByRole('button', { name: 'Show more' })).not.toBeAttached();
+	await page.getByRole('button', { name: 'Recent', exact: true }).click();
+	await expect(table.getByRole('row')).toHaveCount(101);
+	await expect(table.getByRole('row').nth(1)).toContainText('198.51.100.104');
+	for (const horizon of ['1h', '6h', '24h', '7d']) {
+		await page.getByRole('button', { name: horizon, exact: true }).click();
+		await expect(page.getByRole('button', { name: horizon, exact: true })).toHaveAttribute(
+			'aria-pressed',
+			'true'
+		);
+		await expect(
+			page.getByText(horizon === '1h' ? 'showing 100 of 104' : 'showing 100 of 105')
+		).toBeVisible();
+	}
+	await page.getByRole('button', { name: 'Low α', exact: true }).click();
+	await expect(table.getByRole('row')).toHaveCount(2);
+	await expect(table.getByRole('row').nth(1)).toContainText('203.0.113.7');
+	await page.getByRole('button', { name: '1h', exact: true }).click();
+	await expect(page.getByText('No anomalous addresses in the last 1h.')).toBeVisible();
+	await page.getByRole('button', { name: 'High α', exact: true }).click();
+	await expect(table.getByRole('row')).toHaveCount(101);
+});
+
+test('alerts show loading and retain the last table on a failed refresh', async ({ page }) => {
+	await page.goto('/');
+	await page.getByRole('button', { name: /Playwright Fixture/ }).click();
+	await page.getByRole('link', { name: 'Alerts', exact: true }).click();
+	const table = page.getByRole('region', { name: 'Anomalous addresses' });
+	await expect(table.getByRole('row')).toHaveCount(101);
+	let release = () => {};
+	const pending = new Promise<void>((resolve) => {
+		release = resolve;
+	});
+	await page.route('**/api/alerts?**', async (route) => {
+		await pending;
+		await route.fulfill({ status: 503, json: { data: null, error: 'QA feed unavailable' } });
+	});
+	await page.getByRole('button', { name: 'Recent', exact: true }).click();
+	await expect(table).toHaveAttribute('aria-busy', 'true');
+	release();
+	await expect(page.getByRole('alert')).toHaveText('QA feed unavailable');
+	await expect(table.getByRole('row')).toHaveCount(101);
+	await page.unrouteAll({ behavior: 'wait' });
+	await page.getByRole('button', { name: 'Most extreme', exact: true }).click();
+	await expect(page.getByRole('alert')).not.toBeAttached();
+	await expect(table).toHaveAttribute('aria-busy', 'false');
+});
+
+test('absent feed command copies and reports clipboard failures', async ({ page, context }) => {
+	await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+	await page.goto('/datasets/playwright-external-maad/alerts');
+	await page.getByRole('button', { name: 'Copy', exact: true }).click();
+	await expect(page.getByRole('button', { name: 'Copied', exact: true })).toBeVisible();
+	await expect
+		.poll(() => page.evaluate(() => navigator.clipboard.readText()))
+		.toBe('netflow-db feed playwright-external-maad');
+	await page.evaluate(() => {
+		Object.defineProperty(navigator.clipboard, 'writeText', {
+			configurable: true,
+			value: () => Promise.reject(new Error('denied'))
+		});
+	});
+	await page.getByRole('button', { name: 'Copied', exact: true }).click();
+	await expect(page.getByRole('alert')).toHaveText('Could not copy the command');
+});
```

## apps/web/tests/e2e/chart-helpers.ts

Status: added; +81; −0

```diff
@@ -1,0 +1,81 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+export const FIXTURE_DASHBOARD =
+	'/datasets/playwright?startDate=2025-03-01&endDate=2025-03-01&groupBy=5min';
+
+export function chartCard(page: Page, id: string) {
+	return page.locator(`[data-chart-id="${id}"]`);
+}
+
+export async function activateChart(page: Page, id: string) {
+	const card = chartCard(page, id);
+	await card.scrollIntoViewIfNeeded();
+	await expect(card).toHaveAttribute('data-chart-activated', 'true');
+	return card;
+}
+
+export async function expectRendered(scope: Locator, count = 1) {
+	const states = scope.getByTestId('chart-render-state');
+	await expect(states).toHaveCount(count);
+	for (const state of await states.all()) {
+		await expect(state).toHaveAttribute('data-state', 'ready');
+		await expect
+			.poll(async () => Number(await state.getAttribute('data-mark-count')))
+			.toBeGreaterThan(0);
+	}
+	return states;
+}
+
+export async function hoverPlot(surface: Locator) {
+	await surface.scrollIntoViewIfNeeded();
+	const target = await chartTarget(surface);
+	const box = await surface.boundingBox();
+	if (!box) throw new Error('Missing chart bounds');
+	await surface.hover({ position: { x: target.x - box.x, y: target.y - box.y } });
+}
+
+export async function setDates(page: Page, start: string, end: string) {
+	for (const [label, date] of [
+		['End Date', end],
+		['Start Date', start]
+	]) {
+		await page.getByLabel(label, { exact: true }).fill(date);
+		await page.getByLabel(label, { exact: true }).press('Tab');
+		const key = label === 'End Date' ? 'endDate' : 'startDate';
+		await expect
+			.poll(
+				() =>
+					new URL(page.url()).searchParams.get(key) ??
+					(key === 'startDate' ? '2025-03-01' : new Date().toISOString().slice(0, 10))
+			)
+			.toBe(date);
+	}
+	await expect(page.getByLabel('Start Date', { exact: true })).toHaveValue(start);
+	await expect(page.getByLabel('End Date', { exact: true })).toHaveValue(end);
+}
+
+export async function rendered(surface: Locator) {
+	await expect(surface).toBeVisible();
+	await expect(surface).toHaveAttribute('data-chart-rendered', 'true');
+	const states = await expectRendered(surface.locator('..'));
+	return states.first();
+}
+
+export async function chartTarget(surface: Locator, kind: 'point' | 'legend' = 'point') {
+	await surface.scrollIntoViewIfNeeded();
+	const target = surface
+		.locator('..')
+		.locator(`[data-testid="chart-series"][data-${kind}-x]`)
+		.first();
+	await expect(target).toBeAttached();
+	const x = Number(await target.getAttribute(`data-${kind}-x`));
+	const y = Number(await target.getAttribute(`data-${kind}-y`));
+	const bounds = await surface.boundingBox();
+	if (!bounds) throw new Error('Missing chart surface bounds');
+	return { x: bounds.x + bounds.width * x, y: bounds.y + bounds.height * y };
+}
+
+export async function hoverChart(page: Page, surface: Locator) {
+	const point = await chartTarget(surface);
+	await page.mouse.move(point.x, point.y);
+}
```

## apps/web/tests/e2e/coverage-strip.spec.ts

Status: modified; +2; −2

```diff
@@ -11,12 +11,12 @@
 	const strip = page.getByTestId('coverage-strip');
 	await expect(card).toBeVisible();
 	await expect(strip).toBeVisible();
-	await expect(strip.locator('canvas')).toBeVisible();
+	await expect(strip.getByTestId('chart-surface')).toBeVisible();
 
 	const heightBeforeHover = await card.evaluate(
 		(element) => element.getBoundingClientRect().height
 	);
-	await strip.locator('canvas').hover({ position: { x: 150, y: 10 } });
+	await strip.getByTestId('chart-surface').hover({ position: { x: 150, y: 10 } });
 	const heightAfterHover = await card.evaluate((element) => element.getBoundingClientRect().height);
 
 	expect(heightAfterHover).toBe(heightBeforeHover);
```

## apps/web/tests/e2e/dashboard-baseline.spec.ts

Status: added; +293; −0

```diff
@@ -1,0 +1,293 @@
+import { expect, test } from '@playwright/test';
+import {
+	activateChart,
+	chartCard,
+	expectRendered,
+	FIXTURE_DASHBOARD,
+	hoverPlot,
+	hoverChart,
+	chartTarget,
+	setDates
+} from './chart-helpers';
+
+test('renders every dashboard chart with series, finite marks and axis summaries', async ({
+	page
+}) => {
+	await page.goto(FIXTURE_DASHBOARD);
+	for (const [id, count, axis, legend] of [
+		['dashboard', 1, 'Value', 'Flows TCP'],
+		['characteristics', 2, 'Duration', 'Average duration'],
+		['ports', 1, 'Unique ports', 'Source ports 0-1023'],
+		['ip', 1, 'Unique IPs', 'fixture-router · Src IPv4'],
+		['protocol', 1, 'Unique Protocols', 'fixture-router · IPv4'],
+		['dimensions', 1, 'Source D1', 'fixture-router'],
+		['spectrum', 1, 'alpha', 'Spectrum'],
+		['coverage', 1, '', 'fixture-router']
+	] as const) {
+		const card = await activateChart(page, id);
+		await expectRendered(card, count);
+		if (axis)
+			await expect(card.getByTestId('chart-axis').filter({ hasText: axis }).first()).toBeAttached();
+		await expect(
+			card.getByTestId('chart-series').filter({ hasText: legend }).first()
+		).toHaveAttribute('data-visible', 'true');
+	}
+});
+
+test('traffic metrics and family controls change the rendered values and series', async ({
+	page
+}) => {
+	await page.goto(FIXTURE_DASHBOARD);
+	const card = chartCard(page, 'dashboard');
+	const state = (await expectRendered(card)).first();
+	const tcp = () => card.getByTestId('chart-series').filter({ hasText: /^Flows TCP$/ });
+	await expect(tcp()).toHaveAttribute('data-total', '100');
+	await card.getByRole('button', { name: 'IPv4', exact: true }).click();
+	await expect(tcp()).toHaveAttribute('data-total', '80');
+	await card.getByRole('button', { name: 'IPv6', exact: true }).click();
+	await expect(tcp()).toHaveAttribute('data-total', '20');
+	await card.getByRole('button', { name: 'All', exact: true }).click();
+	await expect(tcp()).toHaveAttribute('data-total', '100');
+	for (const [label, kind] of [
+		['Line Chart', 'line'],
+		['Stacked Area', 'stacked']
+	]) {
+		await card.getByRole('button', { name: label, exact: true }).click();
+		await expect(state).toHaveAttribute('data-kind', kind);
+	}
+	for (const [label, prefix, total] of [
+		['Packets', 'Packets TCP', '1000'],
+		['Bytes', 'Bytes TCP', '102400']
+	]) {
+		await card.getByRole('button', { name: label, exact: true }).click();
+		await expect(
+			card.getByTestId('chart-series').filter({ hasText: new RegExp(`^${prefix}$`) })
+		).toHaveAttribute('data-total', total);
+		await expect(state).toHaveAttribute('data-series-count', '4');
+	}
+	await card.getByRole('button', { name: 'Select All', exact: true }).click();
+	await expect(state).toHaveAttribute('data-series-count', '12');
+	for (const checkbox of await card.getByRole('checkbox').all()) {
+		await checkbox.uncheck();
+	}
+	await expect(state).toHaveAttribute('data-series-count', '0');
+	await expect(state).toHaveAttribute('data-state', 'empty');
+	await card.getByRole('button', { name: 'Flows', exact: true }).click();
+	await expect(state).toHaveAttribute('data-series-count', '4');
+	await hoverPlot(card.getByTestId('chart-surface'));
+	await expect(card.getByTestId('chart-tooltip')).toContainText('Flows TCP: 100');
+});
+
+test('flow characteristics and port families retain their numerical meaning', async ({ page }) => {
+	await page.goto(FIXTURE_DASHBOARD);
+	const characteristics = await activateChart(page, 'characteristics');
+	await expectRendered(characteristics, 2);
+	const duration = characteristics
+		.getByTestId('chart-series')
+		.filter({ hasText: /^Average duration$/ });
+	await expect(duration).toHaveAttribute('data-max', '1750');
+	for (const [family, value] of [
+		['IPv4', '2000'],
+		['IPv6', '1000'],
+		['All', '1750']
+	]) {
+		await characteristics.getByRole('radio', { name: family, exact: true }).check();
+		await expect(duration).toHaveAttribute('data-max', value);
+	}
+	await hoverChart(page, characteristics.getByTestId('chart-surface').first());
+	await expect(characteristics.getByTestId('chart-tooltip').first()).toContainText('1.75 s');
+	const ports = await activateChart(page, 'ports');
+	const state = (await expectRendered(ports)).first();
+	const series = ports.getByTestId('chart-series').filter({ hasText: /^Source ports 0-1023$/ });
+	await expect(series).toHaveAttribute('data-max', '8');
+	await ports.getByRole('radio', { name: 'IPV6', exact: true }).check();
+	await expect(series).toHaveAttribute('data-max', '2');
+	for (const cb of await ports.getByRole('checkbox').all()) await cb.uncheck();
+	await expect(ports.getByTestId('chart-card-state')).toHaveAttribute('data-state', 'no-metrics');
+	await expect(ports).toContainText('Select at least one port range');
+	await ports.getByRole('checkbox', { name: 'Source ports 0-1023', exact: true }).check();
+	await expectRendered(ports);
+	await expect(state).toHaveAttribute('data-series-count', '1');
+});
+
+test('IP and protocol toggles alter the visible series and recover from no metrics', async ({
+	page
+}) => {
+	await page.goto(FIXTURE_DASHBOARD);
+	for (const id of ['ip', 'protocol']) {
+		const card = await activateChart(page, id);
+		await expectRendered(card);
+		const checkboxes = card.getByRole('checkbox');
+		for (const cb of await checkboxes.all()) await cb.uncheck();
+		await expect(card.getByTestId('chart-card-state')).toHaveAttribute('data-state', 'no-metrics');
+		await expect(card).toContainText('Select at least one metric');
+		await checkboxes.first().check();
+		const state = (await expectRendered(card)).first();
+		await expect(state).toHaveAttribute('data-series-count', '1');
+		await expect(card.getByTestId('chart-series').first()).toHaveAttribute(
+			'data-max',
+			id === 'ip' ? '5' : '3'
+		);
+		await hoverPlot(card.getByTestId('chart-surface'));
+		await expect(card.getByTestId('chart-tooltip')).toContainText('fixture-router');
+	}
+});
+
+test('all granularities render and long windows guard expensive groupings', async ({ page }) => {
+	await page.goto(FIXTURE_DASHBOARD);
+	const card = chartCard(page, 'dashboard');
+	await expectRendered(card);
+	for (const [label, groupBy] of [
+		['Day', 'date'],
+		['Hour', 'hour'],
+		['30 min', '30min'],
+		['10 min', '10min'],
+		['5 min', '5min']
+	]) {
+		await page
+			.getByRole('group', { name: 'Granularity' })
+			.getByRole('button', { name: label, exact: true })
+			.click();
+		await expect
+			.poll(() => new URL(page.url()).searchParams.get('groupBy') ?? 'date')
+			.toBe(groupBy);
+		await expectRendered(card);
+		await expect(
+			card.getByTestId('chart-series').filter({ hasText: /^Flows TCP$/ })
+		).toHaveAttribute('data-total', '100');
+	}
+	await setDates(page, '2025-01-01', '2025-12-31');
+	await expect(page.getByRole('button', { name: '5 min', exact: true })).toBeDisabled();
+	await expect(page.getByRole('button', { name: '10 min', exact: true })).toBeDisabled();
+	await expect(page.getByRole('button', { name: '30 min', exact: true })).toBeDisabled();
+	await expect(page.getByRole('button', { name: 'Day', exact: true })).toBeEnabled();
+});
+
+test('empty windows clear rendered data and source defaults recover after reload', async ({
+	page
+}) => {
+	await page.goto(FIXTURE_DASHBOARD);
+	const ip = await activateChart(page, 'ip');
+	await expectRendered(ip);
+	await setDates(page, '2020-01-01', '2020-01-02');
+	await expect(ip.getByTestId('chart-card-state')).not.toHaveAttribute('data-state', 'loading');
+	await expect(ip.getByTestId('chart-render-state')).not.toBeAttached();
+	await setDates(page, '2025-03-01', '2025-03-01');
+	await expectRendered(ip);
+	await page.getByRole('checkbox', { name: 'fixture-router', exact: true }).uncheck();
+	await expect(ip).toContainText('Select at least one source');
+	await page.reload();
+	await expect(page.getByRole('checkbox', { name: 'fixture-router', exact: true })).toBeChecked();
+	await page.getByRole('checkbox', { name: 'fixture-router', exact: true }).check();
+	await activateChart(page, 'ip');
+	await expectRendered(ip);
+	await expect(ip.getByTestId('chart-series').first()).toHaveAttribute('data-max', '5');
+});
+
+test('direction changes values and URL filters survive reload and history', async ({ page }) => {
+	await page.goto(FIXTURE_DASHBOARD);
+	const tcp = chartCard(page, 'dashboard')
+		.getByTestId('chart-series')
+		.filter({ hasText: /^Flows TCP$/ });
+	await expect(tcp).toHaveAttribute('data-total', '100');
+	const direction = page.getByRole('group', { name: 'Traffic direction' });
+	await direction.getByRole('button', { name: 'Ingress', exact: true }).click();
+	await expect(tcp).toHaveAttribute('data-total', '50');
+	await page
+		.getByRole('group', { name: 'MAAD measure' })
+		.getByRole('button', { name: 'Bytes', exact: true })
+		.click();
+	await page
+		.getByRole('group', { name: 'MAAD address family' })
+		.getByRole('button', { name: 'IPv6 (/23–/64)' })
+		.click();
+	await page.reload();
+	await expect(direction.getByRole('button', { name: 'Ingress', exact: true })).toHaveAttribute(
+		'aria-pressed',
+		'true'
+	);
+	await expect(
+		page
+			.getByRole('group', { name: 'MAAD measure' })
+			.getByRole('button', { name: 'Bytes', exact: true })
+	).toHaveAttribute('aria-pressed', 'true');
+	await expect(tcp).toHaveAttribute('data-total', '50');
+	await direction.getByRole('button', { name: 'All', exact: true }).click();
+	await expect(tcp).toHaveAttribute('data-total', '100');
+	await page.goBack();
+	await expect(tcp).toHaveAttribute('data-total', '50');
+	await page.goForward();
+	await expect(tcp).toHaveAttribute('data-total', '100');
+});
+
+test('chart loading, request errors and recovery are visible', async ({ page }) => {
+	let release = () => {};
+	const blocked = new Promise<void>((resolve) => {
+		release = resolve;
+	});
+	await page.route('**/api/ip/stats?**', async (route) => {
+		await blocked;
+		await route.fulfill({ status: 503, body: 'QA unavailable' });
+	});
+	await page.goto(FIXTURE_DASHBOARD);
+	const ip = await activateChart(page, 'ip');
+	await expect(ip.getByTestId('chart-card-state')).toHaveAttribute('data-state', 'loading');
+	await expect(ip).toContainText('Loading IP data');
+	release();
+	await expect(ip.getByTestId('chart-card-state')).toHaveAttribute('data-state', 'error');
+	await expect(ip).toContainText('QA unavailable');
+	await page.unrouteAll({ behavior: 'wait' });
+	await page.getByRole('button', { name: 'Hour', exact: true }).click();
+	await expectRendered(ip);
+});
+
+test('brush selects a narrower daily range and reset restores defaults', async ({ page }) => {
+	await page.goto('/datasets/playwright?startDate=2025-01-01&endDate=2025-03-31&groupBy=date');
+	const card = chartCard(page, 'dashboard');
+	await expectRendered(card);
+	const surface = card.getByTestId('chart-surface');
+	await surface.scrollIntoViewIfNeeded();
+	const box = await surface.boundingBox();
+	if (!box) throw new Error('Missing traffic plot');
+	await page.mouse.move(box.x + box.width * 0.35, box.y + box.height * 0.5);
+	await page.mouse.down();
+	await page.mouse.move(box.x + box.width * 0.65, box.y + box.height * 0.5, { steps: 10 });
+	await page.mouse.up();
+	await expect.poll(() => new URL(page.url()).searchParams.get('groupBy')).not.toBe('date');
+	await expect(page.getByLabel('Start Date', { exact: true })).not.toHaveValue('2025-01-01');
+	await page.getByRole('button', { name: 'Reset View' }).click();
+	await expect(page.getByLabel('Start Date', { exact: true })).toHaveValue('2025-03-01');
+	await expectRendered(card);
+});
+
+for (const id of ['characteristics', 'ports', 'ip', 'protocol', 'dimensions']) {
+	test(`${id} legend hides and restores the rendered series`, async ({ page }) => {
+		await page.goto(FIXTURE_DASHBOARD);
+		const card = await activateChart(page, id);
+		await expectRendered(card, id === 'characteristics' ? 2 : 1);
+		const surface = card.getByTestId('chart-surface').first();
+		const series = surface.locator('..').getByTestId('chart-series').first();
+		const target = await chartTarget(surface, 'legend');
+		await page.mouse.click(target.x, target.y);
+		await expect(series).toHaveAttribute('data-visible', 'false');
+		await page.mouse.click(target.x, target.y);
+		await expect(series).toHaveAttribute('data-visible', 'true');
+	});
+}
+
+test('theme and navigation tip remain available by keyboard and after reload', async ({ page }) => {
+	await page.goto(FIXTURE_DASHBOARD);
+	await expectRendered(chartCard(page, 'dashboard'));
+	const tip = page.getByRole('button', { name: 'Show navigation tip' });
+	await tip.focus();
+	await expect(tip).toBeFocused();
+	await expect(tip).toHaveAttribute('title', /Drag across chart/);
+	const theme = page.getByRole('button', { name: 'Switch to dark mode' });
+	await theme.focus();
+	await page.keyboard.press('Enter');
+	await expect(page.getByRole('button', { name: 'Switch to light mode' })).toBeVisible();
+	await expectRendered(chartCard(page, 'dashboard'));
+	await page.reload();
+	await expect(page.getByRole('button', { name: 'Switch to light mode' })).toBeVisible();
+	await expectRendered(chartCard(page, 'dashboard'));
+});
```

## apps/web/tests/e2e/dashboard-contract.spec.ts

Status: added; +45; −0

```diff
@@ -1,0 +1,45 @@
+import { expect, test } from '@playwright/test';
+import { chartTarget, rendered } from './chart-helpers';
+
+const DASHBOARD = '/datasets/playwright?startDate=2025-03-01&endDate=2025-03-01&groupBy=5min';
+
+test('legend clicks hide and restore a rendered traffic series', async ({ page }) => {
+	await page.goto(DASHBOARD);
+	const surface = page.locator('[data-chart-id="dashboard"]').getByTestId('chart-surface');
+	const state = await rendered(surface);
+	const tcp = state.getByTestId('chart-series').filter({ hasText: /^Flows TCP$/ });
+	const target = await chartTarget(surface, 'legend');
+	await page.mouse.click(target.x, target.y);
+	await expect(tcp).toHaveAttribute('data-visible', 'false');
+	await expect(state).toHaveAttribute('data-series-count', '3');
+	await page.mouse.click(target.x, target.y);
+	await expect(tcp).toHaveAttribute('data-visible', 'true');
+	await expect(state).toHaveAttribute('data-series-count', '4');
+});
+
+test('range updates change values, empty bounds recover, and source defaults recover on reload', async ({
+	page
+}) => {
+	await page.goto(DASHBOARD);
+	const card = page.locator('[data-chart-id="dashboard"]');
+	await rendered(card.getByTestId('chart-surface'));
+	await page.getByRole('button', { name: 'Day', exact: true }).click();
+	const tcp = card.getByTestId('chart-series').filter({ hasText: /^Flows TCP$/ });
+	await expect(tcp).toHaveAttribute('data-total', '100');
+	await page.getByLabel('End Date', { exact: true }).fill('2025-03-03');
+	await page.getByLabel('End Date', { exact: true }).press('Tab');
+	await expect(tcp).toHaveAttribute('data-total', '300');
+	await page.getByLabel('Start Date', { exact: true }).fill('2025-03-03');
+	await page.getByLabel('Start Date', { exact: true }).press('Tab');
+	await expect(tcp).toHaveAttribute('data-total', '200');
+	await page.getByRole('checkbox', { name: 'fixture-router', exact: true }).uncheck();
+	await page.reload();
+	await expect(page.getByRole('checkbox', { name: 'fixture-router', exact: true })).toBeChecked();
+	await page.getByRole('checkbox', { name: 'fixture-router', exact: true }).check();
+	await expect(tcp).toHaveAttribute('data-total', '200');
+	await page.goto('/datasets/playwright?startDate=2020-01-01&endDate=2020-01-02');
+	await expect(card.getByTestId('chart-render-state')).toHaveAttribute('data-state', 'empty');
+	await expect(card.getByTestId('chart-render-state')).toHaveAttribute('data-mark-count', '0');
+	await page.goto(DASHBOARD);
+	await expect(card.getByTestId('chart-series').first()).toHaveAttribute('data-total', '100');
+});
```

## apps/web/tests/e2e/dashboard-drilldown.spec.ts

Status: modified; +7; −5

```diff
@@ -4,11 +4,13 @@
 	'/datasets/playwright?startDate=2025-01-01&endDate=2025-03-31&groupBy=date';
 
 async function clickChartCenter(page: Page, chartId: string) {
-	const canvas = page.locator(`[data-chart-id="${chartId}"] canvas`).first();
-	await expect(canvas).toBeVisible();
-	await canvas.scrollIntoViewIfNeeded();
-	const box = await canvas.boundingBox();
+	const surface = page
-	if (!box) throw new Error(`Missing ${chartId} canvas bounds`);
+		.locator(`[data-chart-id="${chartId}"] [data-testid="chart-surface"]`)
+		.first();
+	await expect(surface).toBeVisible();
+	await surface.scrollIntoViewIfNeeded();
+	const box = await surface.boundingBox();
+	if (!box) throw new Error(`Missing ${chartId} surface bounds`);
 	await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
 }
 
```

## apps/web/tests/e2e/dashboard-maad-measure.spec.ts

Status: modified; +3; −3

```diff
@@ -126,13 +126,13 @@
 test('keeps the weighted measure when drilling down to a file', async ({ page }) => {
 	await page.goto(`${DASHBOARD}&measure=bytes`);
 	await activateCard(page, 'dimensions');
-	const canvas = page.locator('[data-chart-id="dimensions"]').getByLabel('MAAD dimensions chart');
+	const surface = page.locator('[data-chart-id="dimensions"]').getByLabel('MAAD dimensions chart');
-	await expect(canvas).toBeVisible();
+	await expect(surface).toBeVisible();
 
 	const detailsRequest = page.waitForRequest(
 		(request) => new URL(request.url()).pathname === '/api/netflow/files/202503010200/details'
 	);
+	await surface.click();
-	await canvas.click();
 
 	await page.waitForURL(/\/netflow\/files\/202503010200\?/);
 	const url = new URL(page.url());
```

## apps/web/tests/e2e/maad-contract.spec.ts

Status: added; +44; −0

```diff
@@ -1,0 +1,44 @@
+import { expect, test } from '@playwright/test';
+import { activateChart, expectRendered, FIXTURE_DASHBOARD } from './chart-helpers';
+
+test('MAAD dimensions render the selected side, order and weighted measure', async ({ page }) => {
+	await page.goto(FIXTURE_DASHBOARD);
+	const card = await activateChart(page, 'dimensions');
+	await expectRendered(card);
+	const series = card.getByTestId('chart-series').first();
+	for (const [order, value] of [
+		['D0', 0.92],
+		['D1', 0.88],
+		['D2', 0.85]
+	] as const) {
+		await card
+			.getByRole('group', { name: 'MAAD dimension' })
+			.getByRole('button', { name: order, exact: true })
+			.click();
+		await expect
+			.poll(async () => Number(await series.getAttribute('data-max')))
+			.toBeCloseTo(value, 5);
+		await expect(
+			card.getByTestId('chart-axis').filter({ hasText: `Source ${order}` })
+		).toBeAttached();
+	}
+	await card
+		.getByRole('group', { name: 'MAAD address side' })
+		.getByRole('button', { name: 'Destination', exact: true })
+		.click();
+	await expect.poll(async () => Number(await series.getAttribute('data-max'))).toBeCloseTo(0.82, 5);
+	await card.getByRole('button', { name: 'D1', exact: true }).click();
+	for (const [measure, value] of [
+		['Addresses', 0.86],
+		['Packets', 0.69],
+		['Bytes', 0.61]
+	] as const) {
+		await page
+			.getByRole('group', { name: 'MAAD measure' })
+			.getByRole('button', { name: measure, exact: true })
+			.click();
+		await expect
+			.poll(async () => Number(await series.getAttribute('data-max')))
+			.toBeCloseTo(value, 5);
+	}
+});
```

## apps/web/tests/e2e/maad-internal-side.spec.ts

Status: modified; +1; −1

```diff
@@ -89,5 +89,5 @@
 		'MAAD is not computed for internal addresses. The source addresses are internal for egress traffic.'
 	);
 	await expect(unavailable).toHaveCount(2);
-	await expect(page.locator('canvas').first()).toBeVisible();
+	await expect(page.getByTestId('chart-surface').first()).toBeVisible();
 });
```

## apps/web/tests/e2e/maad-ip-version.spec.ts

Status: modified; +1; −1

```diff
@@ -28,7 +28,7 @@
 
 	const dashboard = page.locator('[data-chart-id="dashboard"]');
 	await dashboard.scrollIntoViewIfNeeded();
-	await dashboard.locator('canvas').first().click();
+	await dashboard.getByTestId('chart-surface').first().click();
 	await expect(page).toHaveURL(/\/netflow\/files\/\d{12}\?/);
 	const fileUrl = new URL(page.url());
 	expect(fileUrl.searchParams.get('dataset')).toBe('playwright');
```

[Next page](https://git.cafe/flambo/atlantis/pull/130/diffs?after=ImFwcHMvd2ViL3Rlc3RzL2UyZS9tYWFkLWlwLXZlcnNpb24uc3BlYy50cyI&expectedVersion=0&format=markdown)
