import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { settleBeforeMeasure } from "./utils/settle"; /** * Settings → Health Score: the surface that decides which pillars count. * * Everything asserted here goes through stable `data-slot` / `data-*` * attributes rather than viewport-dependent text, so the spec keeps working * at any width and in any locale (only the copy assertions read English, and * the suite pins the English locale through the storage state). * * The refusal case is driven through the REAL write. Deselecting down to * activity and wellbeing genuinely fails the server's breadth rule, and a * refused write persists nothing, so the case leaves the shared account * exactly as it found it. Mocking the 422 would have proved that the page * can render a message, not that the rule is enforced or that the page reads * the reason the server actually sends. * * The analytics read IS mocked, because it is the page's secondary read and * the states it drives — waiting for data, safety signposting — depend on * what the seeded account happens to hold. Fixing it makes those two rows * deterministic without touching the write path under test. */ /** A slim Health Score report: only the fields the settings page reads. */ function scoreReport() { const coverage = { requiredInputs: 3, presentInputs: 2, historyDays: 14, missing: [], }; const provenance = { inputs: [], source: "live", windowDays: 90, computedAt: new Date().toISOString(), }; const insufficient = (reason: string) => ({ status: "insufficient", reason, coverage, provenance, }); return { composite: insufficient("three_domains_required"), pillars: [ { id: "LIPIDS", domain: "cardiometabolic", result: insufficient("not_tracked"), }, { id: "WELLBEING", domain: "wellbeing", result: insufficient("crisis_signposting"), }, { id: "BLOOD_PRESSURE", domain: "cardiometabolic", result: insufficient("read_failed"), }, ], delta: null, deltaReason: "no_current_score", scoreVersion: 2, weightGoal: insufficient("no_goal"), // Raised and never dismissed, which is the state every account is in: // nothing has ever rendered this notice. algorithmNotice: { itemKey: "health_score_algorithm:2", dismissed: false }, }; } async function mockAnalytics(page: import("@playwright/test").Page) { await page.route(/\/api\/analytics(\?|$)/, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { summaries: {}, healthScore: scoreReport() }, error: null, }), }); }); } test.describe("Settings → Health Score", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { await mockAnalytics(page); }); test("groups the pillars by area and makes the three axes legible", async ({ page, }) => { await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); const card = page.locator('[data-slot="score-config-card"]'); await settleBeforeMeasure(page, card); // The heading + subheading pair the settings IA requires, owned by the // shell and not re-declared inside the card. The shell renders the pair // twice under distinct ids — `…-title` inside a `hidden md:block` column // and `…-title-mobile` inside an `md:hidden` one — so exactly one of them // is on screen at any width. Asserting the desktop id alone passed on the // wide project and failed on the narrow one against a heading that is // hidden by design. const heading = page .locator( "#settings-section-score-title, #settings-section-score-title-mobile", ) .filter({ has: page.locator(":scope:visible") }); await expect(heading).toHaveCount(1); await expect(heading.first()).toBeVisible(); await expect( heading.first().locator("xpath=following-sibling::p[1]"), ).toBeVisible(); // One concept, one card. A second card beside it would be the // top/bottom split of one decision the rules forbid; the notice is a // different concept and is allowed its own. await expect(page.locator('[data-slot="score-config-card"]')).toHaveCount( 1, ); // The cardiometabolic triple, visibly under one heading. const cardio = page.locator( '[data-slot="score-config-group"][data-domain="cardiometabolic"]', ); await expect(cardio).toHaveCount(1); await expect(cardio.locator('[data-slot="score-pillar-row"]')).toHaveCount( 3, ); await expect( cardio.locator('[data-slot="score-config-group-note"]'), ).toBeVisible(); // Every visible row names recording and showing beside its switch, and // the card says in words that this page leaves those two alone. const rows = page.locator('[data-slot="score-pillar-row"]'); const rowCount = await rows.count(); expect(rowCount).toBeGreaterThan(0); await expect( page.locator('[data-slot="score-pillar-axis-recorded"]'), ).toHaveCount(rowCount); await expect( page.locator('[data-slot="score-pillar-axis-shown"]'), ).toHaveCount(rowCount); await expect( page.locator('[data-slot="score-config-three-axes"]'), ).toContainText("keeps being recorded"); }); test("shows the pillar it cannot score as present, disabled and honest", async ({ page, }) => { await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); const fitness = page.locator( '[data-slot="score-pillar-row"][data-pillar="FITNESS"]', ); await settleBeforeMeasure(page, fitness); await expect(fitness).toHaveAttribute("data-selectable", "false"); await expect(fitness).toHaveAttribute("data-eligibility", "unavailable"); // No switch at all, rather than one that no-ops behind `disabled`. await expect( fitness.locator('[data-slot="score-pillar-switch"]'), ).toHaveCount(0); await expect( fitness.locator('[data-slot="score-pillar-unavailable"]'), ).toHaveAttribute("aria-disabled", "true"); }); test("tells waiting for data apart from safety guidance and a failed read", async ({ page, }) => { await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); const card = page.locator('[data-slot="score-config-card"]'); await settleBeforeMeasure(page, card); const state = (pillar: string) => page.locator( `[data-slot="score-pillar-row"][data-pillar="${pillar}"] [data-slot="score-pillar-state"]`, ); await expect(state("LIPIDS")).toHaveAttribute("data-state", "waiting"); // Safety signposting is its own state and never a configuration error. await expect(state("WELLBEING")).toHaveAttribute("data-state", "crisis"); // A failed read is a failure, not absence. await expect(state("BLOOD_PRESSURE")).toHaveAttribute( "data-state", "read_failed", ); }); test("renders the score notice and lets it be dismissed", async ({ page, }) => { await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); const notice = page.locator('[data-slot="score-change-notice"]'); await settleBeforeMeasure(page, notice); await expect(notice).toBeVisible(); let dismissed: string | null = null; await page.route(/\/api\/daily\/digest\/dismiss$/, async (route) => { dismissed = String(route.request().postDataJSON()?.itemKey ?? ""); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { dismissed: true }, error: null }), }); }); // The refetch after the dismissal has to come back already dismissed, // or the notice would simply be raised again and the test would be // asserting a race rather than the behaviour. await page.route(/\/api\/analytics(\?|$)/, async (route) => { const report = scoreReport(); report.algorithmNotice.dismissed = true; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { summaries: {}, healthScore: report }, error: null, }), }); }); await notice.locator('[data-slot="score-change-notice-dismiss"]').click(); await expect(notice).toHaveCount(0); expect(dismissed).toBe("health_score_algorithm:2"); }); test("refuses a selection too narrow to produce a score, in plain language", async ({ page, }) => { await page.goto("/settings/score", { waitUntil: "domcontentloaded" }); const card = page.locator('[data-slot="score-config-card"]'); await settleBeforeMeasure(page, card); // Start from everything, then take out every pillar except activity and // wellbeing: two areas of health and no physical measurement, which the // server refuses. await page.locator('[data-slot="score-config-preset-all"]').click(); const keep = new Set(["ACTIVITY", "WELLBEING"]); const rows = page.locator( '[data-slot="score-pillar-row"][data-selectable="true"]', ); for (let index = 0; index < (await rows.count()); index += 1) { const row = rows.nth(index); const pillar = await row.getAttribute("data-pillar"); if (!pillar || keep.has(pillar)) continue; if ((await row.getAttribute("data-counts")) === "false") continue; await row.locator('[data-slot="score-pillar-switch"]').click(); } await page.locator('[data-slot="score-config-save"]').click(); const refusal = page.locator('[data-slot="score-config-refusal"]'); await expect(refusal).toBeVisible(); await expect(refusal).toHaveAttribute("role", "alert"); // The server's machine reason, rendered as a sentence a person can act // on, not as an error code and not as the server's English fallback. await expect(refusal).toContainText("physical measurement"); await expect(refusal).not.toContainText("_required"); }); test("keeps the content column the same width as its neighbours", async ({ page, }, testInfo) => { test.skip( testInfo.project.name !== "chromium-desktop", "viewport-driven assertion; desktop project only", ); await page.setViewportSize({ width: 1440, height: 900 }); async function columnWidth(route: string, sentinel: string) { await page.goto(route, { waitUntil: "domcontentloaded" }); await settleBeforeMeasure(page, page.locator(sentinel)); return page.evaluate(() => { const column = document.querySelector("section[aria-labelledby]"); const grid = column?.closest("div.grid") ?? null; const cell = grid?.lastElementChild ?? null; return cell ? Math.round(cell.getBoundingClientRect().width) : -1; }); } const modules = await columnWidth( "/settings/modules", '[data-slot="card"]', ); const score = await columnWidth( "/settings/score", '[data-slot="score-config-card"]', ); expect(modules).toBeGreaterThan(0); expect(Math.abs(score - modules)).toBeLessThanOrEqual(1); }); });