mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The narrative period, the lab result type, both admin filters, the chart range tabs, the mood chart's own copy of them, the sleep-stage window and the re-authentication method each spelled a segmented control by hand, with their own sizes and fills. They render Segmented now; the range tabs keep their long names as a hover title, and the admin filters gain an accessible group name in every language. A test refuses a new button row whose aria-pressed compares a state to each button's value, and names the rows that remain with why they are not one choice on one track.
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import type { Page, Route } from "@playwright/test";
|
|
|
|
import { mockDay } from "./setup/day-mock";
|
|
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
|
import { SCORE_HISTORY_STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
import { expect, test } from "./setup/test";
|
|
import type { DailyDigest } from "@/lib/daily/digest";
|
|
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
|
|
|
/**
|
|
* v1.42 — the score histories and the dashboard's top card switch.
|
|
*
|
|
* - The health score's page charts its daily course with the range tabs
|
|
* every chart offers; a tab asks the server for its window, the choice
|
|
* survives a reload, and a point opens its day.
|
|
* - Readiness, recovery, the sleep score and strain carry the same four
|
|
* tabs in the same component.
|
|
* - The top card goes away when the layout settings switch it off, and
|
|
* comes back when they switch it on.
|
|
*
|
|
* The score history and the derived scores are route mocks in the
|
|
* contract's shape (the server's answers are pinned by the integration
|
|
* suite); the range preference and the layout are this account's own, saved
|
|
* for real. Stable data attributes only.
|
|
*/
|
|
|
|
test.use({ storageState: SCORE_HISTORY_STORAGE_STATE_PATH });
|
|
// Every test writes this one account's layout blob, a Serializable
|
|
// read-modify-write; run in parallel they would abort each other's write.
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
const RANGES = ["7", "30", "90", "0"];
|
|
|
|
function dayKey(offset: number): string {
|
|
const now = new Date();
|
|
const local = new Date(
|
|
now.toLocaleString("en-US", { timeZone: "Europe/Berlin" }),
|
|
);
|
|
local.setDate(local.getDate() + offset);
|
|
const y = local.getFullYear();
|
|
const m = String(local.getMonth() + 1).padStart(2, "0");
|
|
const d = String(local.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
/** Answer the score history with one point per day of the window, max 40. */
|
|
async function mockScoreHistory(page: Page): Promise<number[]> {
|
|
const asked: number[] = [];
|
|
await page.route(/\/api\/insights\/score-history\?/, (route: Route) => {
|
|
const url = new URL(route.request().url());
|
|
const days = Number(url.searchParams.get("days"));
|
|
asked.push(days);
|
|
const count = Math.min(days, 40);
|
|
const points = Array.from({ length: count }, (_, i) => ({
|
|
day: dayKey(i - count + 1),
|
|
value: 60 + ((i * 7) % 25),
|
|
seamBreak: false,
|
|
}));
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
score: url.searchParams.get("score"),
|
|
days,
|
|
points,
|
|
band: { lo: 64, hi: 76, n: 30 },
|
|
},
|
|
error: null,
|
|
}),
|
|
});
|
|
});
|
|
return asked;
|
|
}
|
|
|
|
/** An `ok` derived score, so the score page draws its history under it. */
|
|
async function mockDerived(page: Page) {
|
|
await page.route(/\/api\/insights\/derived\?/, (route: Route) => {
|
|
const metric = new URL(route.request().url()).searchParams.get("metric");
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
metric,
|
|
status: "ok",
|
|
value: {
|
|
score: 72,
|
|
band: "green",
|
|
components: [],
|
|
subScores: [],
|
|
night: dayKey(0),
|
|
asleepMinutes: 420,
|
|
inBedMinutes: 450,
|
|
windowNights: 20,
|
|
},
|
|
coverage: {
|
|
requiredInputs: 1,
|
|
presentInputs: 1,
|
|
historyDays: 30,
|
|
missing: [],
|
|
},
|
|
confidence: "high",
|
|
provenance: {
|
|
inputs: [],
|
|
source: "rollup",
|
|
windowDays: 30,
|
|
computedAt: new Date().toISOString(),
|
|
},
|
|
reason: null,
|
|
assessment: null,
|
|
},
|
|
error: null,
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
async function resetRange(page: Page, chartKey: string) {
|
|
const res = await page.request.put("/api/dashboard/chart-overlay-prefs", {
|
|
data: {
|
|
chartKey,
|
|
prefs: {
|
|
showTrendIndicator: false,
|
|
showTrendArrow: false,
|
|
showTargetRange: false,
|
|
comparisonBaseline: "none",
|
|
rangePoints: 30,
|
|
},
|
|
},
|
|
});
|
|
expect(res.status()).toBe(200);
|
|
}
|
|
|
|
async function expectSharedTabs(page: Page, chart: string, pressed: string) {
|
|
const tabs = page.locator(`${chart} [data-slot="chart-range-tab"]`);
|
|
await expect(tabs).toHaveCount(4);
|
|
expect(
|
|
await tabs.evaluateAll((nodes) =>
|
|
nodes.map((node) => node.getAttribute("data-value")),
|
|
),
|
|
).toEqual(RANGES);
|
|
await expect(
|
|
page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`),
|
|
).toHaveAttribute("data-value", pressed);
|
|
}
|
|
|
|
test.describe("score histories", () => {
|
|
test.beforeEach(async ({ page, context }, testInfo) => {
|
|
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
|
await context.addCookies([
|
|
{ name: "healthlog-locale", value: "en", url: baseURL },
|
|
]);
|
|
await mockDay(page, { index: "full" });
|
|
});
|
|
|
|
test("the health score's range tabs switch the window and are remembered", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await resetRange(page, "scoreHealth");
|
|
const asked = await mockScoreHistory(page);
|
|
|
|
await page.goto("/insights/health-score");
|
|
const chart = page.locator('[data-slot="score-trend-chart"]');
|
|
await expect(chart).toHaveAttribute("data-score", "HEALTH_SCORE");
|
|
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
|
|
await expect(chart.locator('[data-slot="chart-plot"]')).toBeVisible();
|
|
await expect(chart.locator('[data-slot="score-trend-band"]')).toBeVisible();
|
|
expect(asked).toContain(30);
|
|
|
|
await chart
|
|
.locator('[data-slot="chart-range-tab"][data-value="7"]')
|
|
.click();
|
|
await expect(chart).toHaveAttribute("data-range", "7");
|
|
await expect.poll(() => asked.at(-1)).toBe(7);
|
|
// The table lists exactly the window's days.
|
|
await chart.locator('[data-slot="chart-data-table-toggle"]').click();
|
|
await expect(
|
|
chart.locator('[data-slot="chart-data-table-row"]'),
|
|
).toHaveCount(7);
|
|
|
|
// Remembered: a reload opens on seven days again.
|
|
await page.reload();
|
|
await expect(chart).toHaveAttribute("data-range", "7");
|
|
|
|
await chart
|
|
.locator('[data-slot="chart-range-tab"][data-value="0"]')
|
|
.click();
|
|
await expect(chart).toHaveAttribute("data-range", "0");
|
|
await expect.poll(() => asked.at(-1)).toBe(3650);
|
|
|
|
// A point opens its day beside the page.
|
|
const plot = chart.locator('[data-slot="chart-plot"]');
|
|
await expect(plot).toHaveAttribute("data-day-links", "true");
|
|
const box = (await plot.boundingBox())!;
|
|
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
|
|
await expect(page.locator('[data-slot="day-panel"]')).toHaveAttribute(
|
|
"data-shell",
|
|
"docked",
|
|
);
|
|
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
|
|
|
|
await resetRange(page, "scoreHealth");
|
|
});
|
|
|
|
test("the score panel leads to the health score page and back", async ({
|
|
page,
|
|
}) => {
|
|
await mockPopulatedInsights(page);
|
|
await mockScoreHistory(page);
|
|
await page.goto("/insights");
|
|
const link = page.locator('[data-slot="health-score-history-link"]');
|
|
await expect(link).toHaveAttribute("href", "/insights/health-score");
|
|
await link.click();
|
|
await expect(page).toHaveURL(/\/insights\/health-score$/);
|
|
await expect(
|
|
page.locator('[data-slot="health-score-back"]'),
|
|
).toHaveAttribute("href", "/insights");
|
|
});
|
|
|
|
test("readiness and the sleep score offer the same tabs, and switching asks for the window", async ({
|
|
page,
|
|
}) => {
|
|
await resetRange(page, "scoreReadiness");
|
|
await mockDerived(page);
|
|
const asked = await mockScoreHistory(page);
|
|
|
|
await page.goto("/insights/scores/readiness");
|
|
const chart = page.locator('[data-slot="score-trend-chart"]');
|
|
await expect(chart).toHaveAttribute("data-score", "READINESS");
|
|
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
|
|
await chart
|
|
.locator('[data-slot="chart-range-tab"][data-value="90"]')
|
|
.click();
|
|
await expect(chart).toHaveAttribute("data-range", "90");
|
|
await expect.poll(() => asked.at(-1)).toBe(90);
|
|
await resetRange(page, "scoreReadiness");
|
|
|
|
await page.goto("/insights/scores/sleep");
|
|
await expect(
|
|
page.locator('[data-slot="score-trend-chart"]'),
|
|
).toHaveAttribute("data-score", "SLEEP_SCORE");
|
|
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
|
|
});
|
|
|
|
test("recovery and strain carry the same tabs on their stored-score chart", async ({
|
|
page,
|
|
}) => {
|
|
await resetRange(page, "scoreRecovery");
|
|
await mockDerived(page);
|
|
|
|
await page.goto("/insights/scores/recovery");
|
|
const chart = '[data-slot="score-history-chart"]';
|
|
await expect(page.locator(chart)).toHaveAttribute(
|
|
"data-type",
|
|
"RECOVERY_SCORE",
|
|
);
|
|
await expectSharedTabs(page, chart, "30");
|
|
// A score history remembers its range, and mounts no overlay dropdown.
|
|
await expect(
|
|
page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
|
|
).toHaveCount(0);
|
|
await page
|
|
.locator(`${chart} [data-slot="chart-range-tab"][data-value="7"]`)
|
|
.click();
|
|
await expectSharedTabs(page, chart, "7");
|
|
await page.reload();
|
|
await expectSharedTabs(page, chart, "7");
|
|
await resetRange(page, "scoreRecovery");
|
|
|
|
await page.goto("/insights/scores/strain");
|
|
await expectSharedTabs(page, chart, "30");
|
|
});
|
|
});
|
|
|
|
const DIGEST: DailyDigest = {
|
|
generatedAt: new Date().toISOString(),
|
|
ai: DIGEST_AI_AVAILABLE,
|
|
phase: "final",
|
|
sleepPending: false,
|
|
score: {
|
|
value: 82,
|
|
band: "green",
|
|
delta: null,
|
|
deltaReason: "first_eligibility_window",
|
|
steadyWeeks: 2,
|
|
},
|
|
topSignal: null,
|
|
signalLine: null,
|
|
briefingLead: null,
|
|
lead: null,
|
|
today: [],
|
|
restMode: null,
|
|
line: "Your health score today is 82.",
|
|
worthALook: [],
|
|
justIn: null,
|
|
reactionLine: null,
|
|
};
|
|
|
|
test.describe("the dashboard's top card", () => {
|
|
test.afterEach(async ({ page }) => {
|
|
// Leave the account's layout at its defaults for the next run.
|
|
await page.request.delete("/api/dashboard/widgets");
|
|
});
|
|
|
|
test("switches off and on from the layout settings", async ({ page }) => {
|
|
await page.request.delete("/api/dashboard/widgets");
|
|
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ data: DIGEST, error: null }),
|
|
}),
|
|
);
|
|
|
|
// The dashboard states the switch on its root. What the card shows
|
|
// depends on the day's digest, which the server renders into the page
|
|
// before any route mock can answer, so the switch is asserted there and
|
|
// not on the card's content.
|
|
const dashboard = page.locator("[data-today-card]");
|
|
const hero = page.locator('[data-slot="today-hero"]');
|
|
await page.goto("/");
|
|
await expect(dashboard).toHaveAttribute("data-today-card", "on");
|
|
|
|
async function flip(expected: "checked" | "unchecked") {
|
|
await page.goto("/settings/layout/dashboard");
|
|
const toggle = page.locator('[data-slot="today-card-switch"]');
|
|
await expect(toggle).toBeVisible();
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute("data-state", expected);
|
|
const saved = page.waitForResponse(
|
|
(res) =>
|
|
res.url().includes("/api/dashboard/widgets") &&
|
|
res.request().method() === "PUT",
|
|
);
|
|
await page
|
|
.locator('[data-slot="settings-dashboard-layout-save"]')
|
|
.click();
|
|
const response = await saved;
|
|
expect(response.status()).toBe(200);
|
|
const body = response.request().postDataJSON() as {
|
|
todayCardVisible?: boolean;
|
|
};
|
|
expect(body.todayCardVisible).toBe(expected === "checked");
|
|
}
|
|
|
|
await flip("unchecked");
|
|
await page.goto("/");
|
|
await expect(dashboard).toHaveAttribute("data-today-card", "off");
|
|
await expect(hero).toHaveCount(0);
|
|
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
|
|
0,
|
|
);
|
|
|
|
// A phone width shows no card either.
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.reload();
|
|
await expect(hero).toHaveCount(0);
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
|
|
await flip("checked");
|
|
await page.goto("/");
|
|
await expect(dashboard).toHaveAttribute("data-today-card", "on");
|
|
});
|
|
});
|