Files
HealthLog/e2e/charts-mobile.spec.ts
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

283 lines
10 KiB
TypeScript
Raw Permalink 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 "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
/**
* v1.4.19 — 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 (`7d / 30d /
* 90d / 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");
// v1.7.2 — snapshot flag default-ON; mock the snapshot cell with the
// same populated four-metric set so the charts paint. Legacy mocks
// below stay for the reversible `=false` path.
await mockDashboardSnapshot(page);
// Mock analytics + measurements so the chart wrappers all clear
// their data-floor gates. Same pattern as `dashboard.spec.ts`.
//
// v1.4.39.3 — match `/api/analytics` AND any sliced variant
// (`?slice=summaries`). The v1.4.39.2 dashboard split mounts the
// slim slice in parallel; the literal `**/api/analytics` glob
// misses the query-string form and dropped the request through to
// the real route, blanking the tile strip + chart cards.
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 — `state: "attached"` is
// sufficient and stable against viewport overflow.
//
// Recharts 3.x DOM shift (v1.4.25): tick labels are no longer
// nested inside `.recharts-xAxis`. They render in a separate
// ZIndexLayer container `.recharts-xAxis-tick-labels` that sits
// as a sibling of the axis-line layer. Each label is a
// `.recharts-cartesian-axis-tick-label` <g> wrapping a
// <text class="recharts-text recharts-cartesian-axis-tick-value">.
// Wait on the label container's text node so the assertion is
// class-position-independent.
await page
.locator(".recharts-xAxis-tick-labels text")
.first()
.waitFor({ state: "attached", timeout: 10_000 });
// Count rendered x-axis tick labels per wrapper. We scope to
// `.recharts-wrapper` so each chart is counted once, and read
// labels from `.recharts-xAxis-tick-labels` — the new (3.x)
// sibling container that holds the visible tick text. Empty
// structural ticks live in `.recharts-xAxis-tick-lines` and
// never carry text, so the labels-container is the right axis
// for legibility.
const tickCounts = await page.evaluate(() => {
const wrappers = Array.from(
document.querySelectorAll(".recharts-wrapper"),
);
return wrappers
.map((wrapper) => {
const labels = Array.from(
wrapper.querySelectorAll(
".recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-label",
),
);
const visibleLabels = labels.filter(
(l) => (l.textContent?.trim() ?? "").length > 0,
);
return visibleLabels.length;
})
.filter((n) => n > 0);
});
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);
}
});
});