mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
The post-deploy CI for v1.4.39.2 surfaced eight e2e failures across chromium-desktop and chromium-mobile (chart-overlay-controls, dashboard, charts-mobile, measurement-flow) that all routed through two narrow regressions and one durable test-fixture gap. ### Fixed - Dashboard slim/thick merge no longer blanks the tile strip when the slim slice resolves with an empty summaries record. The v1.4.39.2 inline `slim?.summaries ?? thick?.summaries` short-circuited on JavaScript's truthy-empty-object even when thick carried the full payload. The merge moved to a pure `mergeSlimAndThickAnalytics` helper that uses object emptiness as the discriminator: populated slim still wins on overlapping fields (progressive-paint contract preserved), empty slim falls through to thick, both empty leaves the dashboard's data-floor gates to render the appropriate empty state. Eight new behaviour-level unit tests cover the edge cases. - `MeasurementList` non-grouped rows now preserve decimal precision on both the desktop table and the mobile card grid. The v1.4.37 W7c collapsed-list view passed every value through `fmt.integer`, which kept the per-day step / activity aggregates correct but silently truncated single weight / body-fat / temperature readings — 78.4 kg rendered as "78 kg". Grouped rows (cumulative HK type day aggregates) stay on `fmt.integer` because their readings are integer-only; non-grouped rows now use `fmt.number` which honours up to three fraction digits without forcing a minimum. ### Changed - Playwright route mocks for `/api/analytics` migrate from the literal `**/api/analytics` string glob to the regex form `/\/api\/analytics(\?|$)/` across every authenticated spec. The string glob does not match the sliced URL form `/api/analytics?slice=summaries` the v1.4.39.2 dashboard split fires alongside the thick request, so the slim request fell through to the real route on the seeded test user that returns empty summaries. Ten specs migrate: dashboard, chart-overlay-controls, measurement-flow, charts-mobile, a11y, v1427-responsive-sheet, v1427-coach-launch, v1427-measurements-add-param, v1427-insights-empty-state, onboarding-tour-passthrough. The regex form is the durable alignment with the v1.4.37 W-CI lesson that already swept `onboarding-flicker.spec.ts` and `mobile-viewport.spec.ts`. ### Notes - No schema migration. Runtime + test-fixture-only changes. - `pnpm test --run` green at 4662 / 4663 (1 long-standing skip). - Full Playwright suite green at 116 passing / 36 skipped / 152 total on both `chromium-desktop` and `chromium-mobile` profiles, against the production Next.js build. Closes every failure from CI run 26213226723.
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
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`.
|
||
//
|
||
// 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);
|
||
}
|
||
});
|
||
});
|