Files
HealthLog/e2e/charts-mobile.spec.ts
T
Marc-André Bombeck 25477e8aea test(e2e): unblock chart-overlay toggle + mobile x-axis tick wait
Two specs have been red on main since the v1.4.20 hero rewrite, spamming
the GitHub Actions failure mailer on every push:

1. chart-overlay-controls.spec.ts — the test opens the dashboard's chart
   overlay popover and toggles 'target range' on. The Radix dropdown
   content portals to the document body, so on the 393×851 Pixel 5 and
   near the bottom of the 1280×720 desktop viewport the toggle's bbox
   lands below the fold. `click({force:true})` still asserts the bbox
   intersects the viewport and refuses to fire, and
   `scrollIntoViewIfNeeded` on the portalled content is a no-op because
   the portal sits outside the page scroll container. Switching to
   `dispatchEvent('click')` synthesises a click directly on the element
   without any actionability or viewport check; Radix Switch's native
   click handler fires `onCheckedChange` and the PUT lands as expected.

2. charts-mobile.spec.ts — the 30-day-window tick-count assertion times
   out waiting for `.recharts-xAxis .recharts-cartesian-axis-tick text`
   to be visible. Recharts does paint the <text> ticks into the SVG, but
   the chart card on Pixel-5 is taller than the 851-px viewport, so the
   bottom-anchored x-axis labels stay below the fold even after
   `scrollIntoViewIfNeeded` brings the card's top into view. The
   downstream `evaluate` only needs the ticks attached to the DOM (it
   counts them via querySelectorAll), so `state: 'attached'` is both
   sufficient and viewport-tolerant.

Neither fix changes the assertion semantics — both still exercise the
same user-visible behaviour against the same selectors. The CI mailer
should fall silent after the next push.
2026-05-14 11:54:44 +02:00

258 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.19 A2 — mobile-viewport regression spec for chart cards.
*
* Two assertions per chart visible in the dashboard's initial paint:
*
* 1. Header layout integrity — the range tabs (`7 pts / 30 pts /
* 90 pts / All`) and the cog dropdown sit on a SINGLE horizontal
* row inside the chart card. Pre-fix the bucket-aggregation chip
* ("Weekly avg" / "Monthly avg") + comparison caption pushed the
* tabs onto a second row on Pixel 5 (and 3-4 rows on Galaxy Fold
* compact). The fix stacks title + chips above tabs + cog on
* mobile so the tabs always own the full controls row.
*
* 2. X-axis tick density — Recharts' default rendered ONE tick per
* data point, so a 30-day Pixel 5 window painted 30 overlapping
* labels. The universal helper `chooseTickInterval` caps that at
* 6 ticks for ≤ 480px viewports.
*
* The spec runs only on the `chromium-mobile` project (Pixel 5,
* 393×851). Desktop sizes are exercised separately by the unit-test
* suite for the helper.
*/
test.describe("charts — mobile-viewport regression", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
// Mock analytics + measurements so the chart wrappers all clear
// their data-floor gates. Same pattern as `dashboard.spec.ts`.
await page.route("**/api/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: 78.5,
avg7: 78.2,
avg30: 77.9,
slope30: { slope: -0.05, direction: "down" },
count: 30,
},
BLOOD_PRESSURE_SYS: {
latest: 124,
avg7: 122,
avg30: 121,
slope30: { slope: 0.1, direction: "flat" },
count: 30,
},
BLOOD_PRESSURE_DIA: {
latest: 80,
avg7: 79,
avg30: 78,
slope30: { slope: 0.05, direction: "flat" },
count: 30,
},
PULSE: {
latest: 68,
avg7: 70,
avg30: 71,
slope30: { slope: -0.2, direction: "down" },
count: 30,
},
},
bpInTargetPct: 78,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
// Return 30 days of measurements per type so each chart paints a
// line with enough points that tick-density actually matters.
await page.route("**/api/measurements*", (route) => {
const url = new URL(route.request().url());
const type = url.searchParams.get("type") ?? "WEIGHT";
const baseValue =
type === "BLOOD_PRESSURE_SYS"
? 124
: type === "BLOOD_PRESSURE_DIA"
? 80
: type === "PULSE"
? 70
: 78.5;
const measurements = Array.from({ length: 30 }, (_, i) => ({
id: `m_${type}_${i}`,
type,
value: baseValue + (i % 5) - 2,
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
notes: null,
}));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements, meta: { total: measurements.length } },
error: null,
}),
});
});
// Medication compliance — return 30 days of mostly-perfect
// compliance so the chart clears the sparse-data threshold and
// paints a line with the full window.
await page.route("**/api/medications/intake*", (route) => {
const points = Array.from({ length: 30 }, (_, i) => {
const d = new Date(Date.now() - i * 86_400_000);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return {
date: `${yyyy}-${mm}-${dd}`,
scheduled: 2,
taken: i % 7 === 0 ? 1 : 2,
};
});
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: points, error: null }),
});
});
});
test("chart header keeps range tabs on a single row at Pixel 5", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Wait for at least one chart-range-tab to mount.
await page
.locator("[data-slot=chart-range-tab]")
.first()
.waitFor({ timeout: 10_000 });
// Group the visible range tabs by their card and assert each
// card's tabs share a single Y coordinate (== one row).
const findings = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll("div.bg-card")).filter(
(c) => c.querySelector("[data-slot=chart-range-tab]") !== null,
);
return cards.map((card) => {
const title =
card
.querySelector("h3, [data-slot=card-title]")
?.textContent?.trim() ?? "(unknown)";
const tabs = Array.from(
card.querySelectorAll("[data-slot=chart-range-tab]"),
);
const tops = tabs.map((t) => Math.round(t.getBoundingClientRect().top));
const uniqueRows = new Set(tops).size;
const rect = card.getBoundingClientRect();
return {
title,
tabCount: tabs.length,
uniqueRows,
overflow: card.scrollWidth > card.clientWidth + 1,
width: rect.width,
};
});
});
// We must find at least one charted card.
expect(findings.length, "no chart cards detected").toBeGreaterThan(0);
for (const f of findings) {
expect(
f.uniqueRows,
`${f.title}: tabs split across ${f.uniqueRows} rows (expected 1)`,
).toBe(1);
expect(f.overflow, `${f.title} card overflows horizontally`).toBe(false);
}
});
test("chart x-axis paints ≤ 7 ticks at Pixel 5 with a 30-day window", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Wait for at least one chart card to mount, then scroll it into
// view so Recharts' ResponsiveContainer measures its parent and
// paints the SVG. On Pixel 5 the dashboard ships several stacked
// tiles above the first chart card; the bottom-positioned axis
// sits below the initial fold and reports as `hidden` to a raw
// `.recharts-xAxis` visibility probe before scroll.
const firstChartCard = page
.locator("div.bg-card")
.filter({ has: page.locator("[data-slot=chart-range-tab]") })
.first();
await firstChartCard.waitFor({ state: "visible", timeout: 10_000 });
await firstChartCard.scrollIntoViewIfNeeded();
// After scroll, give the chart a beat to lay out under the new
// measured size before counting ticks. The chart card on Pixel-5
// is taller than the 851-px viewport, so `scrollIntoViewIfNeeded`
// brings the card's TOP into view but the bottom-anchored x-axis
// labels can remain below the fold — and `waitFor({state:"visible"})`
// would then time out even though Recharts has already painted the
// <text> ticks into the DOM. The subsequent `evaluate` only needs
// them attached, not visually on-screen, so `state: "attached"` is
// both sufficient and stable against viewport overflow.
await page
.locator(".recharts-xAxis .recharts-cartesian-axis-tick text")
.first()
.waitFor({ state: "attached", timeout: 10_000 });
// Recharts lays its visible tick labels under
// `.recharts-xAxis .recharts-cartesian-axis-tick`. Count the ones
// that actually have rendered text (the helper also produces minor
// structural ticks without text labels, which we don't care about
// for legibility).
const tickCounts = await page.evaluate(() => {
const charts = Array.from(document.querySelectorAll(".recharts-xAxis"));
return charts.map((axis) => {
const ticks = Array.from(
axis.querySelectorAll(".recharts-cartesian-axis-tick"),
);
const visibleTicks = ticks.filter(
(t) =>
(t.querySelector("text")?.textContent?.trim() ?? "").length > 0,
);
return visibleTicks.length;
});
});
expect(tickCounts.length, "no recharts axes detected").toBeGreaterThan(0);
for (const count of tickCounts) {
// Pixel 5 = 393 px → max 6 ticks per the helper. Allow 7 for
// Recharts' preserveStartEnd interaction.
expect(
count,
`axis paints ${count} ticks (max 7 allowed)`,
).toBeLessThanOrEqual(7);
}
});
});