From a7cc5de9b5af42a639d603c7fc0fae39a821e078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 18:34:40 +0200 Subject: [PATCH] test(dashboard): regression for tile baseline alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the load-bearing CSS class names introduced in W20a so a future refactor that drops the heading-row height lock or the value-row baseline alignment fails CI before reaching the dashboard. - Heading-row contract: `h-5` + `whitespace-nowrap` + `truncate` on the label slot. Defensive — the live label is locale-abbreviated upstream, but a missing short-key fallback or an unexpectedly long fallback string should still not wrap to a second line. - Value-row contract: `items-baseline` + `text-3xl` + `leading-none` across all five top-row tiles (Weight, BP, Pulse, Body Fat, Mood) so the digits land at the same y-coordinate inside the grid. - Arrow-slot contract: always rendered, end-anchored via `ml-auto`, fixed 16×16 frame; renders a muted "—" placeholder when slope30 is null so the slot width is deterministic across tiles. - i18n integrity: every `dashboard.*Short` key resolves to a non-empty string in en / de / fr / es / it / pl. Complements the broader i18n-locale-integrity test with a precise diagnostic for the dashboard tile namespace. --- .../trend-card-baseline-alignment.test.tsx | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx diff --git a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx new file mode 100644 index 000000000..20831365a --- /dev/null +++ b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx @@ -0,0 +1,221 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Activity, Heart, Smile, Percent, TrendingUp } from "lucide-react"; + +import { TrendCard } from "../trend-card"; +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.4.25 W20a — regression coverage for the dashboard top-tile polish. + * + * Marc-direktive 2026-05-14 named three issues with the 5-tile top row: + * + * 1. Headings wrapped to two lines at narrow viewports (e.g. "Blood + * Pressure" on Pixel 5 / 393 px / 2-col grid). Locked the heading + * row to a deterministic `h-5` with `whitespace-nowrap` so even an + * unexpectedly long string never breaks the baseline of the value + * row below. + * 2. Trend arrow rendered as a wrapping flex child after the unit — + * sometimes wrapped to its own line, sometimes missing entirely + * when slope30 was null. Now sits in a fixed `h-4 w-4` slot with + * `items-baseline` alignment; when there's no slope yet a muted + * "—" placeholder keeps the slot reserved. + * 3. Value rows of two adjacent tiles (e.g. "Weight 80 kg" vs. + * "Blood Pressure 122 mmHg") landed at different y-coordinates + * because heading-row height was variable. The fixed heading + * height plus `text-3xl leading-none` on the value forces every + * tile in the strip to share the same baseline. + * + * These tests pin the load-bearing class names so a future refactor + * that drops `h-5` / `items-baseline` / `whitespace-nowrap` triggers a + * red CI before it reaches the dashboard. + */ + +const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 }; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" W20a baseline alignment", () => { + it("locks the heading row to a deterministic height", () => { + const html = render( + , + ); + + // h-5 is the load-bearing class — keeps the value row below at the + // same y-coordinate regardless of the label string's actual width. + expect(html).toContain("h-5"); + expect(html).toContain('data-slot="trend-card-label"'); + }); + + it("forbids wrapping on the heading row even with a long label", () => { + const html = render( + , + ); + + expect(html).toContain("whitespace-nowrap"); + expect(html).toContain("truncate"); + }); + + it("renders the value row with baseline alignment", () => { + const html = render( + , + ); + + expect(html).toContain('data-slot="trend-card-value-row"'); + expect(html).toContain("items-baseline"); + // Headline font-size + zero line-height — across-tile baseline. + expect(html).toContain("text-3xl"); + expect(html).toContain("leading-none"); + }); + + it("places the trend arrow inline at the end of the value row", () => { + const html = render( + , + ); + + expect(html).toContain('data-slot="trend-card-arrow"'); + // The arrow slot sits inside the value row, so the value-row data + // slot precedes it in the rendered HTML. + const valueRowIdx = html.indexOf('data-slot="trend-card-value-row"'); + const arrowIdx = html.indexOf('data-slot="trend-card-arrow"'); + expect(valueRowIdx).toBeGreaterThan(-1); + expect(arrowIdx).toBeGreaterThan(valueRowIdx); + }); + + it("keeps the arrow slot reserved when slope is missing", () => { + const html = render( + , + ); + + // Slot still rendered (deterministic width across the strip). + expect(html).toContain('data-slot="trend-card-arrow"'); + // Placeholder dash + muted opacity so the slot reads as "no data". + expect(html).toMatch(/opacity-30[^"]*"[^>]*>—/); + }); + + it("renders consistent layout across the five top-row tile metrics", () => { + // Snapshot-style smoke check — every tile in the top row should + // share the same heading-row height + value-row baseline class + // contract so the strip aligns visually on Pixel 5. + const tiles = [ + { label: "Weight", value: 80, unit: "kg", icon: Activity }, + { label: "BP (Sys)", value: 122, unit: "mmHg", icon: Heart }, + { label: "Pulse", value: 68, unit: "bpm", icon: TrendingUp }, + { label: "Body Fat", value: 18.5, unit: "%", icon: Percent }, + { label: "Mood", value: 4, unit: "/ 5", icon: Smile }, + ]; + + for (const tile of tiles) { + const html = render( + , + ); + + // Same heading-row height contract — `h-5` + `whitespace-nowrap`. + expect(html).toContain("h-5"); + expect(html).toContain("whitespace-nowrap"); + // Same value-row baseline contract — `items-baseline` + `text-3xl` + // + `leading-none`. Identical font geometry across all five tiles + // means the digits land at the same y-coordinate inside the grid. + expect(html).toContain("items-baseline"); + expect(html).toContain("text-3xl"); + expect(html).toContain("leading-none"); + // Same arrow slot contract — fixed width, end-anchored via `ml-auto`. + expect(html).toContain('data-slot="trend-card-arrow"'); + expect(html).toContain("ml-auto"); + } + }); +}); + +describe("dashboard.*Short keys exist across all six locales", () => { + // Defensive integrity check — the i18n-locale-integrity test guards + // top-level parity across messages/*.json, but a hand-rolled list of + // the exact short keys the dashboard tile strip pulls makes a missing + // key fail with a precise diagnostic rather than a generic "key X is + // missing in locale Y" surface. + const REQUIRED_KEYS = [ + "weightShort", + "bloodPressureSysShort", + "bloodPressureDiaShort", + "pulseShort", + "bodyFatShort", + "moodShort", + "sleepShort", + "stepsShort", + "vo2MaxShort", + "bpInTargetShort", + ]; + + // Resolved at test time so the assertion runs against the current + // bundle, not a stale cached snapshot. + const locales = ["en", "de", "fr", "es", "it", "pl"] as const; + + it.each(locales)("%s carries every dashboard.*Short key", async (locale) => { + const messages = (await import(`../../../../messages/${locale}.json`)) + .default; + const dashboard = messages.dashboard ?? {}; + for (const key of REQUIRED_KEYS) { + expect(dashboard[key], `dashboard.${key} missing in ${locale}.json`) + .toBeTypeOf("string"); + expect( + (dashboard[key] as string).length, + `dashboard.${key} empty in ${locale}.json`, + ).toBeGreaterThan(0); + } + }); +});