mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
434 lines
17 KiB
TypeScript
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/);
|
|
});
|