Files
HealthLog/e2e/mobile-density.spec.ts
T
Marc-André Bombeck 7db97eabca fix(insights): keep mood correlation captions inside their tiles on phone widths
The correlation-card header's right slot kept its icon-era shrink-0
after v1.22 replaced the info glyph with an inline caption — the
unshrinkable badge+caption row escaped the card's right edge by
14–39 px at 390/360 across all five cards and squeezed the title to a
2-line 36–42 % sliver. The caption now stacks under the title below sm
(only the compact strength badge stays pinned right); on >=sm the
inline layout is unchanged.

Same surface: the discovered-relations stat caption ('{n} gepaarte
Tage · r · q') stacks below its finding sentence on phone widths
instead of wrapping to a 4-line sliver at ~25 % of the row, and the
subsection caveat caption stacks under the subheading instead of
wrapping 9 lines beside it.

The phone-width density guard now sweeps /insights/mood too, with
mocked correlation + discovery payloads pinning the populated worst
case (five cards, three mood pairs) so the guard cannot pass vacuously.

H6
2026-07-07 12:06:36 +02:00

173 lines
6.1 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 "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
LONG_HEADLINE_BRIEFING,
MOCK_SCORE_RINGS,
mockDashboardSnapshot,
POPULATED_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
import { mockMoodInsights } from "./utils/mock-mood-insights";
/**
* Phone-width density guard (390×844 iPhone class, 360×780 small
* Android class).
*
* Two invariants, per the report against the dashboard hero (a long
* briefing-signal headline squeezed into a one-word-per-line column
* beside a wide delta, the delta spilling past the tile edge at 360 px):
*
* 1. No horizontal page overflow — `scrollWidth` never exceeds the
* viewport width (1 px sub-pixel tolerance).
* 2. Nothing escapes its tile — for every card / hero band / list
* row, no descendant's visible box crosses the tile's left or
* right edge (clip-aware: content genuinely hidden by an inner
* `overflow-hidden` wrapper below the tile is fine; content cut
* off at the tile's own edge is the bug).
*
* The snapshot mock pins the worst case: hero band on, three score
* rings, and a fresh briefing whose signal rows pair wrapping German
* headlines with wide delta strings.
*
* Desktop project only — the assertions are viewport-driven via
* `setViewportSize` (the `ipad-viewport.spec.ts` pattern).
*/
const VIEWPORTS = [
{ width: 390, height: 844 },
{ width: 360, height: 780 },
] as const;
const ROUTES = ["/", "/insights", "/insights/mood"] as const;
interface TileEscape {
tile: string;
child: string;
overRight: number;
overLeft: number;
text: string;
}
/** Serialised into the page: measure children escaping their tile. */
function collectTileEscapes(): TileEscape[] {
const escapes: TileEscape[] = [];
const tiles = document.querySelectorAll(
'[data-slot="card"], [data-slot="dashboard-hero"], [data-slot="list-row"]',
);
const TOLERANCE = 1;
for (const tile of tiles) {
const tileRect = tile.getBoundingClientRect();
if (tileRect.width === 0) continue;
for (const child of tile.querySelectorAll("*")) {
const rect = child.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
if (getComputedStyle(child).position === "fixed") continue;
// Clamp by clipping ancestors BELOW the tile only — the tile's
// own overflow-hidden cutting content off is exactly the bug.
let left = rect.left;
let right = rect.right;
let ancestor = child.parentElement;
while (ancestor && ancestor !== tile) {
if (getComputedStyle(ancestor).overflowX !== "visible") {
const ancestorRect = ancestor.getBoundingClientRect();
left = Math.max(left, ancestorRect.left);
right = Math.min(right, ancestorRect.right);
}
ancestor = ancestor.parentElement;
}
if (right <= left) continue;
const overRight = right - tileRect.right;
const overLeft = tileRect.left - left;
if (overRight > TOLERANCE || overLeft > TOLERANCE) {
escapes.push({
tile: tile.getAttribute("data-slot") ?? tile.tagName,
child:
child.tagName.toLowerCase() +
(child.getAttribute("data-slot")
? `[${child.getAttribute("data-slot")}]`
: ""),
overRight: Math.round(overRight * 10) / 10,
overLeft: Math.round(overLeft * 10) / 10,
text: (child.textContent ?? "").trim().slice(0, 60),
});
}
}
}
return escapes;
}
test.describe("phone-width density guard", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-driven spec; desktop project only",
);
});
for (const viewport of VIEWPORTS) {
for (const route of ROUTES) {
test(`no overflow and no tile escape on ${route} at ${viewport.width}x${viewport.height}`, async ({
page,
}) => {
await page.setViewportSize(viewport);
await mockDashboardSnapshot(page, {
summaries: POPULATED_SUMMARIES,
heroVisible: true,
briefing: LONG_HEADLINE_BRIEFING,
scoreRings: MOCK_SCORE_RINGS,
});
if (route === "/insights/mood") {
await mockMoodInsights(page);
}
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
if (route === "/") {
// The guard must not pass vacuously — the hero band and its
// long-headline spotlight rows have to actually render.
await expect(
page.locator('[data-slot="dashboard-hero"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-briefing-spotlight-row"]'),
).toHaveCount(3);
}
if (route === "/insights/mood") {
// Non-vacuous: the five populated correlation cards and the
// discovered-relations list — the audit's only user-facing
// tile-escape cluster — must actually render before the guard
// sweeps them.
await expect(
page.locator('[data-slot="mood-correlation-card"]'),
).toHaveCount(5);
await expect(
page.locator('[data-slot="mood-discovered-relations"]'),
).toBeVisible();
}
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
innerWidth: window.innerWidth,
}));
expect(
dims.scrollWidth,
`page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
expect(
dims.bodyScrollWidth,
`body scrollWidth=${dims.bodyScrollWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
const escapes = await page.evaluate(collectTileEscapes);
expect(
escapes,
`tile escapes: ${JSON.stringify(escapes, null, 2)}`,
).toEqual([]);
});
}
}
});