test(timeline): walk a lab marker through the timeline journey

The journey picks a lab marker from the values menu, sees its readings
drawn with the range behind them and the one below it as a triangle,
reads that reading's card by keyboard with its report, checks the page
with axe, reloads to find the choice kept, and on a phone finds the
readings on their lab days without a sideways scroll before unchoosing
the marker again. The mocks answer lab markers only when a test asks for
them, so the other value-line steps keep counting what they counted, and
the two values triggers (the toolbar's and the phone's) are addressed by
their surface.

Refs #1299
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 20:41:58 +01:00
1 parent b8a983bdec
commit 8d765139d3
2 files changed
+213 -3

No files matched your search

+99 -1
View File
@@ -15,12 +15,16 @@
* 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.
* 7. A lab marker (#1299) is picked from the values menu, drawn as its
* single readings with the range behind them, read on its own, kept
* across a reload, and listed on its lab day on a phone.
*
* 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 AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -298,7 +302,9 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th
// The picker is one button that counts the lines; the chosen ones are
// not listed beside it.
const trigger = page.locator('[data-slot="timeline-values-trigger"]');
const trigger = page.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
);
await expect(trigger).toHaveAttribute("data-count", "3");
await expect(trigger).toHaveText(/\(3\)/);
const toolbarHeight = await trigger.evaluate(
@@ -811,6 +817,98 @@ test("a value point reads on its own, by pointer and by keyboard", async ({
await expect(tip).toHaveAttribute("data-series", series!);
});
test("a lab marker is picked, drawn as its readings, read on its own and kept", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY, { labs: true });
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/timeline?zoom=all");
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
timeout: 20_000,
});
await page
.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
)
.click();
const menu = page.locator('[data-slot="timeline-values-menu"]');
await expect(
menu.locator('[data-slot="timeline-values-labs"]'),
).toBeVisible();
await expect(menu.getByText("Eisen")).toBeVisible();
await menu
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
// The menu hands focus back to its trigger once its exit animation ends.
await expect(menu).toBeHidden();
await expect(
page.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
),
).toBeFocused();
const row = page.locator('[data-lab-series="lab:bm-ferritin"]');
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4);
// Below its range: a triangle, never only a colour.
await expect(row.locator('path[data-status="below"]')).toHaveCount(1);
await expect(row.locator('[data-slot="timeline-lab-band-fill"]')).toHaveCount(
1,
);
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
"lab:bm-ferritin",
);
// The reading below its range, by keyboard: day, value, verdict, report.
const target = page.locator(
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
);
await target.focus();
const tip = page.locator('[data-slot="timeline-point-tip"]');
await expect(tip).toHaveAttribute("data-t", "fe-1");
await expect(tip).toContainText("22 ng/mL");
await expect(tip).toContainText("Laborbefund März");
const axe = await new AxeBuilder({ page })
.include('[data-slot="timeline-page"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
// The choice is kept: a reload asks for the marker again.
await page.reload();
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4, {
timeout: 20_000,
});
// On a phone: the same line, listed on its lab days, no sideways scroll.
await page.setViewportSize({ width: 390, height: 844 });
const labs = page.locator('[data-slot="timeline-chronicle-labs"]');
await expect(labs.first()).toBeVisible();
await expect(labs).toHaveCount(4);
await expect(labs.last()).toContainText("22 ng/mL");
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
// Unchosen, it is gone from the request and the page.
await page
.locator(
'[data-slot="timeline-mobile"] [data-slot="timeline-values-trigger"]',
)
.click();
await page
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
await expect(labs).toHaveCount(0);
});
test("the timeline page ends where its content does, at every desktop width", async ({
page,
}) => {
+114 -2
View File
@@ -518,6 +518,98 @@ export function fullTimeline(today: string) {
};
}
/**
* Two lab markers of the full record (#1299): ferritin read a few times over
* the years against a 30 to 400 range, one reading below it and one read
* from a report, and HbA1c read once. Only what `values` names is sent as a
* line; the menu offers both.
*/
export const LAB_MARKERS = [
{
key: "lab:bm-ferritin",
biomarkerId: "bm-ferritin",
name: "Ferritin",
panel: "Eisen",
unit: "ng/mL",
},
{
key: "lab:bm-hba1c",
biomarkerId: "bm-hba1c",
name: "HbA1c",
panel: "Zucker",
unit: "%",
},
];
function labPoint(
id: string,
date: string,
value: number,
rangeStatus: "below" | "in-range" | "above" | "unknown",
report: { id: string; title: string } | null = null,
) {
const ranged = rangeStatus !== "unknown";
return {
id,
date,
value,
sourceValue: null,
rangeStatus,
referenceLow: ranged ? 30 : null,
referenceHigh: ranged ? 400 : null,
report: report ? { ...report, href: `/documents?doc=${report.id}` } : null,
};
}
export const LAB_LINES = [
{
...LAB_MARKERS[0],
band: { low: 30, high: 400 },
points: [
labPoint("fe-1", "2021-03-15", 22, "below", {
id: "doc-lab-1",
title: "Laborbefund März",
}),
labPoint("fe-2", "2022-09-20", 64, "in-range"),
labPoint("fe-3", "2024-02-12", 118, "in-range"),
labPoint("fe-4", "2025-11-04", 96, "in-range"),
],
excluded: 0,
},
{
...LAB_MARKERS[1],
band: null,
points: [labPoint("a1c-1", "2025-11-04", 5.4, "unknown")],
excluded: 0,
},
];
/** The lab days behind the lab lines, as the labs lane names them. */
function labsLane(keys: readonly string[]) {
const days = new Map<string, string[]>();
for (const line of LAB_LINES) {
if (!keys.includes(line.key)) continue;
for (const p of line.points) {
days.set(p.date, [...(days.get(p.date) ?? []), line.name]);
}
}
return {
key: "labs",
items: [...days]
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([date, names]) =>
item({
id: `lab-day:${date}`,
kind: "labDay",
start: date,
label: names.join(", "),
sub: String(names.length),
href: "/labs",
}),
),
};
}
export function readyTimeline(today: string) {
return {
zoom: "all",
@@ -838,7 +930,12 @@ export async function mockTimeline(
page: Page,
state: "full" | "ready",
today: string,
options: { onlyOld?: boolean; dataFrom?: string } = {},
options: {
onlyOld?: boolean;
dataFrom?: string;
/** The record has lab markers to offer and draw (#1299). */
labs?: boolean;
} = {},
): Promise<TimelineMockLog> {
const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] };
await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) =>
@@ -848,9 +945,23 @@ export async function mockTimeline(
const url = new URL(route.request().url());
log.timelineQueries.push(url.searchParams);
const body = state === "full" ? fullTimeline(today) : readyTimeline(today);
const values = (url.searchParams.get("values") ?? "")
const named = (url.searchParams.get("values") ?? "")
.split(",")
.filter(Boolean);
// A lab marker is a line of its own (`labSeries`), never a mean.
const labKeys = named.filter((key) => key.startsWith("lab:"));
const values = named.filter((key) => !key.startsWith("lab:"));
const labs =
state === "full" && options.labs
? {
labSeries: LAB_LINES.filter((l) => labKeys.includes(l.key)),
availableLabs: LAB_MARKERS,
lanes:
labKeys.length > 0
? [...body.lanes, labsLane(labKeys)]
: body.lanes,
}
: {};
const zoom = url.searchParams.get("zoom") ?? "all";
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
@@ -876,6 +987,7 @@ export async function mockTimeline(
: series(chosen ? from : dataFrom, chosen ? to : today, bucket);
return json(route, 200, {
...body,
...labs,
zoom,
bucket,
...(chosen