mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
b8a983bdec
commit
8d765139d3
2 files changed
+213
-3
No files matched your search
+99
-1
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user