From d6e87c3a1222521f31104ec1fc6df8259077ec4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 10:36:04 +0200 Subject: [PATCH] test(charts): coverage for revert + overlay-controls + persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- e2e/chart-overlay-controls.spec.ts | 124 ++++++++++++++++++ .../medication-overlay-toggles.test.tsx | 93 +++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 e2e/chart-overlay-controls.spec.ts create mode 100644 src/components/charts/__tests__/medication-overlay-toggles.test.tsx diff --git a/e2e/chart-overlay-controls.spec.ts b/e2e/chart-overlay-controls.spec.ts new file mode 100644 index 000000000..9ca13e3d3 --- /dev/null +++ b/e2e/chart-overlay-controls.spec.ts @@ -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); + }); +}); diff --git a/src/components/charts/__tests__/medication-overlay-toggles.test.tsx b/src/components/charts/__tests__/medication-overlay-toggles.test.tsx new file mode 100644 index 000000000..c3bb46fcd --- /dev/null +++ b/src/components/charts/__tests__/medication-overlay-toggles.test.tsx @@ -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 `` nodes — but the +// trend-chip + the components that paint conditional on the prefs +// (rendered as `` 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 { + // Re-import to pick up the freshly-set mock value. + const { MedicationComplianceChart } = await import( + "../medication-compliance-chart" + ); + return renderToStaticMarkup( + + + , + ); +} + +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"); + }); +});