test(charts): coverage for revert + overlay-controls + persistence

Adds a Playwright spec that drives the dashboard end-to-end — opens
the chart overlay-controls popover, toggles target-range on, and
verifies the PUT /api/dashboard/chart-overlay-prefs round-trip fires.

Adds a vitest SSR test that pins the medication-compliance chart's
prefs-gated rendering: with the trend toggle off the 7-day trend chip
stays hidden; flipping it on paints the chip in the header. Mocks the
useChartOverlayPrefs hook so each test can dial the persisted state
directly without round-tripping the layout API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 10:36:08 +02:00
1 parent 75c74f1c49
commit d6e87c3a12
2 files changed
+217

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc reverted v1.4.16's always-on chart overlays (gradient fill,
* personal-baseline line, target-zone shading) and asked for per-chart
* switches that the user can flip on or off. This spec exercises the
* end-to-end behaviour against the seeded test user:
*
* 1. Dashboard renders with a chart.
* 2. The chart card has a settings cog in its header.
* 3. Clicking the cog opens a popover with three switches.
* 4. Toggling "Target range" on triggers a PUT /api/dashboard/chart-overlay-prefs.
*/
test.describe("chart overlay controls", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Mock analytics + measurements so the dashboard paints at least
// one chart card. Same seed shape the dashboard.spec.ts uses.
await page.route("**/api/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: 78.5,
avg7: 78.2,
avg30: 77.9,
slope30: { slope: -0.05, direction: "down" },
count: 30,
},
},
bpInTargetPct: 0,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
await page.route("**/api/measurements*", (route) => {
const measurements = Array.from({ length: 10 }, (_, i) => ({
id: `m_${i}`,
type: "WEIGHT",
value: 78 + (i % 3) - 1,
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
notes: null,
}));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements, meta: { total: measurements.length } },
error: null,
}),
});
});
});
test("opens the overlay-controls popover and saves a toggle change", async ({
page,
}) => {
let putRequestCount = 0;
await page.route(
"**/api/dashboard/chart-overlay-prefs",
async (route) => {
if (route.request().method() === "PUT") {
putRequestCount += 1;
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { saved: true }, error: null }),
});
},
);
await page.goto("/", { waitUntil: "domcontentloaded" });
// Wait for the dashboard tile-strip to settle so we know the user
// is past initial auth + layout-fetch.
await expect(
page.locator('[data-slot="dashboard-tile-strip"]'),
).toBeVisible({ timeout: 10_000 });
// The weight chart is one of the always-visible default charts and
// it ships an overlay-controls trigger because the dashboard plumbs
// a chartKey into it.
const trigger = page
.locator('[data-slot="chart-overlay-controls-trigger"]')
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
// The popover content paints three switches.
await expect(
page.locator('[data-slot="chart-overlay-toggle-target-range"]'),
).toBeVisible({ timeout: 5_000 });
// Toggle target-range on. Radix Switch is a button[role=switch].
await page
.locator('[data-slot="chart-overlay-toggle-target-range"]')
.click();
// The PUT fires once for the toggle change.
await expect.poll(() => putRequestCount).toBeGreaterThan(0);
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.18 — overlay-toggle integration on the medication chart.
*
* Asserts the contract that the persisted overlay prefs gate the
* threshold / goal reference lines AND the 7-day trend chip:
*
* - Default state (every flag false): no goal/threshold lines, no
* trend chip.
* - showTargetRange=true: threshold + goal reference lines paint.
* - showTrendIndicator=true: 7-day trend chip paints in the header.
*/
const sampleData = vi.hoisted(() =>
Array.from({ length: 14 }, (_, i) => ({
date: `2026-05-${String(i + 1).padStart(2, "0")}`,
scheduled: 4,
taken: 3 + (i % 2),
})),
);
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: sampleData, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }),
}));
// Hoisted mutable prefs so each test pre-sets the values the hook
// returns. Recharts runs client-side, so the threshold/goal lines
// don't appear in the SSR output as actual `<line>` nodes — but the
// trend-chip + the components that paint conditional on the prefs
// (rendered as `<span>` and friends) DO show up in the SSR markup.
const mockPrefs = vi.hoisted(() => ({
current: {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
},
}));
vi.mock("@/hooks/use-chart-overlay-prefs", () => ({
useChartOverlayPrefs: () => ({
prefs: mockPrefs.current,
setPrefs: () => undefined,
isSaving: false,
}),
}));
async function renderChart(): Promise<string> {
// Re-import to pick up the freshly-set mock value.
const { MedicationComplianceChart } = await import(
"../medication-compliance-chart"
);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MedicationComplianceChart />
</I18nProvider>,
);
}
describe("MedicationComplianceChart overlay toggles", () => {
it("does NOT paint the trend chip by default", async () => {
mockPrefs.current = {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
};
const html = await renderChart();
expect(html).not.toContain("medication-trend-chip");
});
it("paints the trend chip when showTrendIndicator is true", async () => {
mockPrefs.current = {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: false,
};
const html = await renderChart();
expect(html).toContain("medication-trend-chip");
});
});