mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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
173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
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([]);
|
||
});
|
||
}
|
||
}
|
||
});
|