mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The shell renders the heading pair twice under distinct ids, one column hidden below the medium breakpoint and one hidden above it, so exactly one is ever on screen. The spec asserted the desktop id alone: green on the wide project, red on the narrow one against a heading that is hidden by design. It now follows whichever of the two the viewport reveals, and asserts that exactly one is visible, so a real duplicate would still fail.
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|