From 8d765139d305ddb6256202575e420bee2fa1d60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:41:58 +0100 Subject: [PATCH] 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 --- e2e/timeline.spec.ts | 100 +++++++++++++++++++++++++++++++- e2e/utils/mock-timeline.ts | 116 ++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 8bdb4591a..610a063d8 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -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, }) => { diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index 0d6e4f284..f0c905129 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -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(); + 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 { 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