diff --git a/e2e/chart-overlay-controls.spec.ts b/e2e/chart-overlay-controls.spec.ts index c3bf48ca6..ed7fa367a 100644 --- a/e2e/chart-overlay-controls.spec.ts +++ b/e2e/chart-overlay-controls.spec.ts @@ -1,6 +1,10 @@ import { expect, test } from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + mockDashboardSnapshot, + WEIGHT_ONLY_SUMMARIES, +} from "./utils/mock-dashboard-snapshot"; /** * v1.4.18 — per-chart overlay-controls popover. @@ -19,6 +23,11 @@ test.describe("chart overlay controls", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { + // v1.7.2 — snapshot flag default-ON; mock the snapshot cell with a + // WEIGHT-only populated summary so the weight chart card paints. + // Legacy mocks below stay for the reversible `=false` path. + await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES }); + // Mock analytics + measurements so the dashboard paints at least // one chart card. Same seed shape the dashboard.spec.ts uses. // diff --git a/e2e/charts-mobile.spec.ts b/e2e/charts-mobile.spec.ts index b21fca257..a5e86cf17 100644 --- a/e2e/charts-mobile.spec.ts +++ b/e2e/charts-mobile.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot"; /** * v1.4.19 A2 — mobile-viewport regression spec for chart cards. @@ -31,6 +32,11 @@ test.describe("charts — mobile-viewport regression", () => { test.beforeEach(async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + // v1.7.2 — snapshot flag default-ON; mock the snapshot cell with the + // same populated four-metric set so the charts paint. Legacy mocks + // below stay for the reversible `=false` path. + await mockDashboardSnapshot(page); + // Mock analytics + measurements so the chart wrappers all clear // their data-floor gates. Same pattern as `dashboard.spec.ts`. // diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 60e54b87c..2a86bc52d 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot"; /** * Authenticated dashboard render — proves the seed user can reach the @@ -20,6 +21,14 @@ test.describe("authenticated dashboard render", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { + // v1.7.2 — the dashboard snapshot flag is default-ON, so the page + // reads every tile from `/api/dashboard/snapshot` and ignores the + // legacy cells below. Mock the snapshot with the same populated + // four-metric set so the tile strip + charts paint. The legacy + // `/api/analytics` + `/api/mood/analytics` mocks stay for the + // reversible `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path. + await mockDashboardSnapshot(page); + // Mock analytics with a populated weight summary so the weight tile // and chart render. `count` must be >0 for the tile to clear the // data-floor gate in `src/app/page.tsx`. diff --git a/e2e/mobile-viewport.spec.ts b/e2e/mobile-viewport.spec.ts index c4e7c8ec0..af4b3d224 100644 --- a/e2e/mobile-viewport.spec.ts +++ b/e2e/mobile-viewport.spec.ts @@ -1,6 +1,10 @@ import { expect, test } from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + mockDashboardSnapshot, + WEIGHT_ONLY_SUMMARIES, +} from "./utils/mock-dashboard-snapshot"; /** * Mobile-viewport smoke (Pixel 5 profile only). Asserts: @@ -31,6 +35,11 @@ test.describe("mobile-viewport smoke", () => { test("dashboard has no horizontal scroll, bottom-nav respects content, all CTAs ≥ 44 px tall", async ({ page, }) => { + // v1.7.2 — snapshot flag default-ON; mock the snapshot cell with a + // WEIGHT-only populated summary so the weight tile paints. Legacy + // mocks below stay for the reversible `=false` path. + await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES }); + // v1.4.37 W-CI — match `/api/analytics` AND any sliced variant // (`?slice=summaries`). The previous string glob `**/api/analytics` // didn't match the query-string form so the IW1 slim-slice fetch diff --git a/e2e/onboarding-flicker.spec.ts b/e2e/onboarding-flicker.spec.ts index d60e82c0b..04d6fd482 100644 --- a/e2e/onboarding-flicker.spec.ts +++ b/e2e/onboarding-flicker.spec.ts @@ -1,6 +1,10 @@ import { expect, test } from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + mockDashboardSnapshot, + WEIGHT_ONLY_SUMMARIES, +} from "./utils/mock-dashboard-snapshot"; /** * v1.4.15 phase-A3 fix #3 — onboarding card flicker on dashboard load. @@ -102,6 +106,17 @@ test.describe("dashboard onboarding card flicker guard", () => { }), }), ); + // v1.7.2 — snapshot flag default-ON. The dashboard tile strip reads + // the snapshot cell; mock it with the populated WEIGHT summary + // (count 30 >= the 5-reading setup gate) and the same ~250 ms delay + // so the page's snapshot-driven tiles resolve on the same race + // window as the slowed analytics query the checklist reads. (The + // `GettingStartedChecklist` itself reads `/api/analytics?slice=...` + // unconditionally, so its flicker gate is unchanged.) + await mockDashboardSnapshot(page, { + summaries: WEIGHT_ONLY_SUMMARIES, + delayMs: 250, + }); // Same `/api/dashboard/widgets` stub as the incomplete-onboarding // test below — keeps the dashboard from error-boundary-bailing // when this query 500s in CI without a real session. @@ -192,6 +207,10 @@ test.describe("dashboard onboarding card flicker guard", () => { }), }), ); + // v1.7.2 — snapshot flag default-ON. Empty snapshot summaries keep + // the tile strip blank (the user is still in setup) so the page + // renders the onboarding card path deterministically. + await mockDashboardSnapshot(page, { summaries: {} }); await page.route("**/api/mood/analytics", (route) => route.fulfill({ status: 200, diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts new file mode 100644 index 000000000..5bc9aa094 --- /dev/null +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -0,0 +1,172 @@ +import type { Page } from "@playwright/test"; + +import { + DASHBOARD_WIDGET_CATALOGUE_IDS, + DEFAULT_DASHBOARD_LAYOUT, +} from "@/lib/dashboard-layout"; +import type { DataSummary } from "@/lib/analytics/trends"; +import type { + DashboardSnapshot, + DashboardLayoutCatalogueEntry, +} from "@/lib/dashboard/snapshot"; + +/** + * v1.7.2 — shared mock for `GET /api/dashboard/snapshot`. + * + * The dashboard snapshot flag flipped default-ON in v1.7.2, so + * `src/app/page.tsx` reads every above-the-fold tile from the single + * `/api/dashboard/snapshot` cell unless `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` + * is `"false"`. The e2e build sets no such var, so the snapshot path is + * live in CI. Specs that previously mocked only the legacy cells + * (`/api/analytics` + `/api/mood/analytics` + `/api/dashboard/widgets`) + * now hit the REAL snapshot route, which returns empty `summaries` for + * the seed user (zero measurements) and blanks the tile strip. + * + * `mockDashboardSnapshot` registers a `page.route` for the snapshot + * endpoint returning a correctly-typed `DashboardSnapshot` so the + * default-ON path paints deterministic tiles. Callers keep their legacy + * mocks alongside it — harmless, and they protect the reversible + * `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path. + */ + +/** Fill a `DataSummary` from a sparse partial, defaulting the rest. */ +function summary(partial: Partial): DataSummary { + return { + count: 0, + latest: null, + min: null, + max: null, + mean: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + ...partial, + }; +} + +/** + * Four-metric populated tile set mirroring the legacy `/api/analytics` + * mock shared by `dashboard.spec.ts` / `charts-mobile.spec.ts` / + * `chart-overlay-controls.spec.ts`. Every summary has `count > 0` so the + * tile clears the data-floor gate in `src/app/page.tsx`. + */ +export const POPULATED_SUMMARIES: Record = { + WEIGHT: summary({ + latest: 78.5, + avg7: 78.2, + avg30: 77.9, + slope30: { slope: -0.05, direction: "down", confidence: 0.8 }, + count: 30, + }), + BLOOD_PRESSURE_SYS: summary({ + latest: 124, + avg7: 122, + avg30: 121, + slope30: { slope: 0.1, direction: "stable", confidence: 0.3 }, + count: 30, + }), + BLOOD_PRESSURE_DIA: summary({ + latest: 80, + avg7: 79, + avg30: 78, + slope30: { slope: 0.05, direction: "stable", confidence: 0.2 }, + count: 30, + }), + PULSE: summary({ + latest: 68, + avg7: 70, + avg30: 71, + slope30: { slope: -0.2, direction: "down", confidence: 0.6 }, + count: 30, + }), +}; + +/** WEIGHT-only populated set for specs that only need one tile + chart. */ +export const WEIGHT_ONLY_SUMMARIES: Record = { + WEIGHT: POPULATED_SUMMARIES.WEIGHT, +}; + +function buildLayoutCatalogue(): DashboardLayoutCatalogueEntry[] { + return DASHBOARD_WIDGET_CATALOGUE_IDS.map((id, order) => ({ + id, + visible: true, + order, + })); +} + +export interface MockSnapshotOptions { + /** + * Tile summaries keyed by `MeasurementType`. Defaults to the + * four-metric populated set. Pass `{}` for an empty strip (e.g. the + * incomplete-onboarding branch). + */ + summaries?: Record; + /** Artificial latency in ms before the route fulfils (race specs). */ + delayMs?: number; +} + +/** Assemble a fully-typed snapshot body from the requested tile data. */ +export function buildMockSnapshot( + options: MockSnapshotOptions = {}, +): DashboardSnapshot { + const summaries = options.summaries ?? POPULATED_SUMMARIES; + const now = new Date().toISOString(); + const lastSeenByType: Record< + string, + { lastSeenAt: string; daysAgo: number } | null + > = {}; + for (const type of Object.keys(summaries)) { + lastSeenByType[type] = { lastSeenAt: now, daysAgo: 0 }; + } + return { + user: { + username: "e2e-tester", + timezone: "Europe/Berlin", + heightCm: 180, + dateOfBirth: "1990-01-01T00:00:00.000Z", + gender: "MALE", + glucoseUnit: "mg/dL", + onboardingTourCompleted: true, + greetingHour: 9, + }, + layout: DEFAULT_DASHBOARD_LAYOUT, + layoutCatalogue: buildLayoutCatalogue(), + metricStates: {}, + tiles: { + summaries, + lastSeenByType, + mood: { summary: null, entries: [] }, + }, + extras: null, + briefing: null, + briefingState: "preparing", + briefingUpdatedAt: null, + generatedAt: now, + }; +} + +/** + * Register a `page.route` interceptor for `GET /api/dashboard/snapshot` + * returning the API envelope `{ data: DashboardSnapshot, error: null }`. + */ +export async function mockDashboardSnapshot( + page: Page, + options: MockSnapshotOptions = {}, +): Promise { + const body = buildMockSnapshot(options); + await page.route(/\/api\/dashboard\/snapshot(\?|$)/, async (route) => { + if (options.delayMs) { + await new Promise((resolve) => setTimeout(resolve, options.delayMs)); + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: body, error: null }), + }); + }); +}