Files
HealthLog/e2e/timeline.spec.ts
T

434 lines
17 KiB
TypeScript

/**
* The timeline journey (v1.42, #613), on its own account (`E2E_TIMELINE`).
*
* 1. Off by default: no navigation entry, and a deep link to /timeline
* lands on the module notice, not the page.
* 2. Switching it on in Settings opens the readiness inventory; "Open
* timeline" goes there and the navigation entry appears.
* 3. The timeline hands off to the day: "Open 3 Jan." and Enter on the
* chart both put `?day=` in the URL; an incoming `?day=` selects that
* day.
* 4. A life event is entered through the sheet and posted in the
* contract's shape.
* 5. The value lines: quarterly means over the years, a missing quarter
* left out, a short gap bridged dashed, a thin quarter hollow, and up
* to six lines.
* 6. On a phone the chronicle replaces the chart, says its empty months
* out loud, names each quarter's means once, and a row opens its day.
*
* The timeline's server routes are mocked with the contract's shapes
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
* assertion is addressed to a stable `data-*` attribute. The journey moves
* its account's module switch, so it runs serially and in one project.
*/
import type { Page } from "@playwright/test";
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import { SYS_MISSING, SYS_THIN, mockTimeline } from "./utils/mock-timeline";
test.use({ storageState: TIMELINE_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
/** Today in the zone the browser and the account run in. */
const TODAY = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
async function setTimelineModule(page: Page, on: boolean): Promise<void> {
const status = await page.evaluate(async (on) => {
const res = await fetch("/api/auth/me/modules", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ timeline: on }),
});
return res.status;
}, on);
expect(status).toBe(200);
}
const navEntry = '[data-tour-id="nav-timeline"]';
test("is off by default: no navigation entry, a deep link meets the notice", async ({
page,
}) => {
await page.goto("/settings/modules");
await setTimelineModule(page, false);
await page.goto("/timeline");
await expect(
page.locator(
'[data-slot="module-disabled-notice"][data-module="timeline"]',
),
).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="timeline-page"]')).toHaveCount(0);
await expect(page.locator(navEntry)).toHaveCount(0);
});
test("switching it on opens the readiness inventory, which leads to the timeline", async ({
page,
}) => {
await mockTimeline(page, "ready", TODAY);
await page.goto("/settings/modules");
const toggle = page.locator("#module-toggle-timeline");
await expect(toggle).toHaveAttribute("aria-checked", "false", {
timeout: 20_000,
});
await toggle.click();
const sheet = page.locator('[data-slot="timeline-readiness-sheet"]');
await expect(sheet).toBeVisible();
await expect(
page.locator(
'[data-slot="timeline-readiness-verdict"][data-verdict="carries"]',
),
).toBeVisible();
await expect(
page.locator('[data-slot="timeline-readiness-lane"][data-status="thin"]'),
).toHaveCount(1);
await expect(
page.locator(
'[data-slot="timeline-readiness-gap"][data-gap="lifeEventsEmpty"]',
),
).toHaveAttribute("href", "/timeline?add=lifeEvent");
await page.locator('[data-slot="timeline-readiness-open"]').click();
await expect(page).toHaveURL(/\/timeline$/);
await expect(page.locator('[data-slot="timeline-page"]')).toBeVisible();
await expect(page.locator(navEntry).first()).toBeAttached();
// The card carries the open gaps onto the page until it is closed.
await expect(
page.locator('[data-slot="timeline-readiness-card"]'),
).toBeVisible();
await page.locator('[data-slot="timeline-readiness-card-dismiss"]').click();
await expect(
page.locator('[data-slot="timeline-readiness-card"]'),
).toHaveCount(0);
});
test("the timeline hands the selected day to the day view through ?day=", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const chart = page.locator('[data-slot="timeline-chart"]');
await expect(chart).toBeVisible({ timeout: 20_000 });
// Empty lanes are not drawn: the record has no labs.
await expect(page.locator('svg [data-lane="labs"]')).toHaveCount(0);
await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1);
await expect(
page.locator('[data-slot="timeline-legend-causality"]'),
).toHaveCount(0);
const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? [];
expect(asked.length).toBeGreaterThan(0);
expect(asked.length).toBeLessThanOrEqual(6);
// Before anyone picks a day the bar opens on the newest bucket it has
// something for, says so in its head, and says how to pick another.
const bar = page.locator('[data-slot="timeline-selection-bar"]');
await expect(bar).toHaveAttribute("data-bucket", "quarter");
await expect(bar).toHaveAttribute(
"data-period-from",
/^\d{4}-(01|04|07|10)-01$/,
);
await expect(
page.locator('[data-slot="timeline-selection-empty"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="timeline-selection-hint"]'),
).toBeVisible();
// The keyboard: one step back and Enter opens that day.
const before = await page
.locator('[data-slot="timeline-selection"]')
.getAttribute("data-date");
await chart.focus();
await page.keyboard.press("ArrowLeft");
const after = await page
.locator('[data-slot="timeline-selection"]')
.getAttribute("data-date");
expect(after).not.toBe(before);
// A day is picked now: the hint has done its job.
await expect(
page.locator('[data-slot="timeline-selection-hint"]'),
).toHaveCount(0);
await page.keyboard.press("Enter");
await expect(page).toHaveURL(new RegExp(`[?&]day=${after}`));
// "Open <day>" in the selection bar does the same for the selected day.
await page.goto("/timeline?day=2026-01-03");
const open = page.locator('[data-slot="timeline-open-day"]');
await expect(open).toHaveAttribute("data-date", "2026-01-03", {
timeout: 20_000,
});
await expect(
page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'),
).toHaveAttribute("aria-checked", "true");
await expect(
page.locator('[data-slot="timeline-selection-chip"][data-item="ill-5"]'),
).toBeVisible();
await page.goto("/timeline");
await expect(chart).toBeVisible({ timeout: 20_000 });
await page.locator('[data-slot="timeline-zoom"] [data-value="year"]').click();
await open.click();
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
});
test("value lines: quarterly means, gaps kept, short ones bridged, up to six lines", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]');
await expect(sys).toBeVisible({ timeout: 20_000 });
await expect(
page.locator('[data-slot="timeline-legend-mean"]'),
).toHaveAttribute("data-bucket", "quarter");
const point = (t: string) =>
sys.locator(`[data-slot="timeline-series-point"][data-t="${t}"]`);
// A missing quarter stays missing: no point is drawn for it.
for (const t of SYS_MISSING) await expect(point(t)).toHaveCount(0);
// The quarters around Q2 2023 are joined by a dashed bridge; the three
// missing quarters of 2021 are not (one bridge in the whole line).
await expect(sys.locator('[data-slot="timeline-series-bridge"]')).toHaveCount(
1,
);
await expect(point("2023-01-01")).toHaveCount(1);
await expect(point("2023-07-01")).toHaveCount(1);
// Two readings: drawn hollow; four and more: filled.
await expect(point(SYS_THIN)).toHaveAttribute("data-thin", "true");
await expect(point("2023-01-01")).toHaveAttribute("data-thin", "false");
// The value table keeps the missing quarter as a row of its own.
await expect(
page.locator(
'[data-slot="timeline-series-table"] [data-bucket="2023-04-01"]',
),
).toBeAttached();
// Six value lines can be chosen; a seventh cannot.
await page.locator('[data-slot="timeline-values-trigger"]').click();
const options = page.locator('[data-slot="timeline-values-option"]');
await expect(options.first()).toBeVisible();
const count = await options.count();
for (let i = 0; i < count; i++) {
const checked = page.locator(
'[data-slot="timeline-values-option"][aria-checked="true"]',
);
if ((await checked.count()) >= 6) break;
const option = options.nth(i);
if ((await option.getAttribute("aria-checked")) === "true") continue;
await option.click();
}
await expect(
page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'),
).toHaveCount(6);
await expect(
page.locator(
'[data-slot="timeline-values-option"][aria-checked="false"][data-disabled]',
),
).toHaveCount(count - 6);
await page.keyboard.press("Escape");
await expect
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
.toBe(6);
await expect(page.locator("svg [data-series]")).toHaveCount(6);
// Each line in its own colour, and every mark of a line in that colour.
const colours = await page
.locator("svg [data-series]")
.evaluateAll((groups) =>
groups.map((g) => ({
colour: getComputedStyle(g).color,
marks: [
...g.querySelectorAll(
'[data-slot="timeline-series-line"], [data-slot="timeline-series-swatch"]',
),
].map((m) => getComputedStyle(m).stroke),
})),
);
expect(new Set(colours.map((c) => c.colour)).size).toBe(6);
for (const { colour, marks } of colours) {
expect(marks.length).toBeGreaterThan(0);
for (const stroke of marks) expect(stroke).toBe(colour);
}
// The menu marks each chosen line with the same colour.
const trigger = page.locator('[data-slot="timeline-values-trigger"]');
await expect(async () => {
if ((await trigger.getAttribute("data-state")) !== "open") {
await trigger.click();
}
await expect(trigger).toHaveAttribute("data-state", "open", {
timeout: 1_000,
});
}).toPass();
const chosenDots = page.locator(
'[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]',
);
await expect(chosenDots).toHaveCount(6);
const dots = await chosenDots.evaluateAll((els) =>
els.map((el) => getComputedStyle(el).backgroundColor),
);
expect(new Set(dots)).toEqual(new Set(colours.map((c) => c.colour)));
await page.keyboard.press("Escape");
});
/** `days` calendar days before `key`. */
function daysBefore(key: string, days: number): string {
const [y, m, d] = key.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d - days)).toISOString().slice(0, 10);
}
test("a chosen range lives in the URL: reload keeps it, Back returns to it", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const chart = page.locator('[data-slot="timeline-chart"]');
await expect(chart).toBeVisible({ timeout: 20_000 });
const desktop = page.locator('[data-slot="timeline-desktop"]');
const zoom = (value: string) =>
page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`);
// Choosing "Range" starts from what is on screen and writes it down.
await zoom("range").click();
await expect(page).toHaveURL(
/[?&]zoom=range&from=\d{4}-\d{2}-\d{2}&to=\d{4}-\d{2}-\d{2}/,
);
const fields = desktop.locator('[data-slot="timeline-range"]');
await expect(fields).toBeVisible();
// Three weeks: daily means, the bar headed by one day.
const from = daysBefore(TODAY, 20);
const start = desktop.locator('[data-testid="timeline-range-from"]');
await page.goto(`/timeline?zoom=range&from=${from}&to=${TODAY}`);
await expect(fields).toHaveAttribute("data-from", from, { timeout: 20_000 });
await expect(fields).toHaveAttribute("data-to", TODAY);
await expect(zoom("range")).toHaveAttribute("aria-checked", "true");
await expect(
page.locator('[data-slot="timeline-legend-mean"]'),
).toHaveAttribute("data-bucket", "day");
await expect(
page.locator('[data-slot="timeline-selection-bar"]'),
).toHaveAttribute("data-bucket", "day");
const asked = log.timelineQueries.at(-1)!;
expect(asked.get("zoom")).toBe("range");
expect(asked.get("from")).toBe(from);
expect(asked.get("to")).toBe(TODAY);
// A reload keeps it.
await page.reload();
await expect(fields).toHaveAttribute("data-from", from, { timeout: 20_000 });
// A fixed zoom replaces it; Back brings it back.
await zoom("year").click();
await expect(page).toHaveURL(/[?&]zoom=year$/);
await expect(fields).toHaveCount(0);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`zoom=range&from=${from}`));
await expect(fields).toHaveAttribute("data-from", from);
// Typing a start moves the range and the URL with it.
const later = daysBefore(TODAY, 10);
await start.fill(later);
await start.press("Enter");
await expect(page).toHaveURL(new RegExp(`from=${later}&to=${TODAY}`));
// A parameter it cannot read opens the whole record.
await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01");
await expect(zoom("all")).toHaveAttribute("aria-checked", "true", {
timeout: 20_000,
});
await expect(fields).toHaveCount(0);
});
async function setTimelineModuleOn(page: Page) {
await page.goto("/settings/modules");
await setTimelineModule(page, true);
}
test("a life event is entered through the sheet, in the contract's shape", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline?add=lifeEvent");
const form = page.locator('[data-slot="life-event-form"]');
await expect(form).toBeVisible({ timeout: 20_000 });
// Saving an empty form says what is missing and sends nothing.
await page.locator('[data-slot="life-event-save"]').click();
await expect(form.locator('[aria-invalid="true"]').first()).toBeVisible();
expect(log.lifeEventPosts).toHaveLength(0);
await page
.locator('[data-slot="life-event-title"]')
.fill("Umzug nach Bochum");
await page
.locator('[data-slot="life-event-category"] [data-value="HOME"]')
.click();
await page
.locator('[data-slot="life-event-precision"] [data-value="MONTH"]')
.click();
await expect(page.locator('[data-slot="life-event-privacy"]')).toBeVisible();
await page.locator('[data-slot="life-event-save"]').click();
await expect(form).toHaveCount(0);
expect(log.lifeEventPosts).toHaveLength(1);
const posted = log.lifeEventPosts[0] as Record<string, string | null>;
expect(posted).toMatchObject({
title: "Umzug nach Bochum",
category: "HOME",
precision: "MONTH",
endDate: null,
note: null,
});
expect(posted.startDate).toMatch(/^\d{4}-\d{2}-01$/);
// The parameter left the URL with the sheet.
await expect(page).toHaveURL(/\/timeline$/);
});
test("on a phone the chronicle replaces the chart and opens a day per row", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/timeline");
const chronicle = page.locator('[data-slot="timeline-chronicle"]');
await expect(chronicle).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="timeline-chart"]')).toBeHidden();
await expect(page.locator('[data-slot="timeline-standing"]')).toBeVisible();
await expect(
page.locator('[data-slot="timeline-chronicle-gap"]').first(),
).toBeVisible();
// Quarterly means, each quarter named once, at its newest listed month.
const means = page.locator('[data-slot="timeline-chronicle-means"]');
await expect(means.first()).toBeVisible();
const buckets = await means.evaluateAll((els) =>
els.map((el) => el.getAttribute("data-bucket") ?? ""),
);
expect(buckets.length).toBeGreaterThan(0);
expect(new Set(buckets).size).toBe(buckets.length);
for (const b of buckets) expect(b).toMatch(/^\d{4}-(01|04|07|10)-01$/);
// Each mean carries its line's colour dot.
await expect(
means.first().locator('[data-slot="timeline-series-dot"]').first(),
).toBeVisible();
await page
.locator(
'[data-slot="timeline-chronicle-row"][data-date="2026-01-03"] button',
)
.first()
.click();
await expect(page).toHaveURL(/[?&]day=2026-01-03/);
});