diff --git a/src/components/charts/__tests__/health-chart-heading-level.test.tsx b/src/components/charts/__tests__/health-chart-heading-level.test.tsx new file mode 100644 index 000000000..bcd67184d --- /dev/null +++ b/src/components/charts/__tests__/health-chart-heading-level.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import deMessages from "../../../../messages/de.json"; + +/** + * Chart card heading level. + * + * The icon-less chart header (dashboard cells, /insights/bmi, the sleep + * overview) titles itself at the second level. It has to: the card is a + * sibling of the other cards around it, and those already name themselves + * there — the dashboard's "Recent unlocks" and "Recent workouts" tiles render + * ``, and the metric subpages' target / usual-range + * cards are `

` too. At `

` the card claimed to be nested inside a + * section that does not exist, and the page skipped a level straight from its + * `

`: the dashboard read h1 → h3 → h3 → h3 → h2 → h2 and /insights/bmi + * read h1 → h3 → h2. + * + * The browser scan in `e2e/a11y.spec.ts` catches a regression here through + * axe's `heading-order` rule, but only in the e2e job. This is the cheap + * guard that fails in the unit run. + */ + +function buildData(): unknown[] { + const out: Array<{ date: string; timestamp: number; PULSE: number }> = []; + const base = Date.UTC(2026, 0, 1, 12, 0, 0); + for (let i = 0; i < 10; i++) { + out.push({ + date: `2026-01-${String(i + 1).padStart(2, "0")}`, + timestamp: base + i * 86_400_000, + PULSE: 60 + i, + }); + } + return out; +} + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +describe(" — card heading level", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("titles the icon-less chart card at the second level, never the third", async () => { + const data = buildData(); + + vi.doMock("@tanstack/react-query", () => ({ + useQuery: () => ({ data, isLoading: false }), + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + })); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + + // The title is a real heading carrying the real title text, not a styled + // div — assert both halves so a silent demotion to a `` fails too. + expect(html).toContain('

Pulse

'); + expect(html).not.toContain("`. * When supplied, the chart header renders the canonical icon + title row * (same size + spacing as every other Insights tile) instead of the bare - * `

`. Omitted on the dashboard / mini mounts, which keep the compact + * `

`. Omitted on the dashboard / mini mounts, which keep the compact * heading. Accepts any component that takes a `className` (the contract * `` and the stat strip's `icon` already use), so a page can * thread the same glyph it hands the stat strip. @@ -1691,11 +1691,23 @@ export function HealthChart({ `` (icon + title at `text-base`, foreground colour, h-5 w-5) so it matches the assessment / target / stat tiles. The dashboard / ad-hoc mounts omit the icon and - keep the compact `

`. */} + keep the compact heading. + + That compact heading is an `

`, not an `

`. The card is + a sibling of the other cards in the same grid, and those name + themselves at the second level already — the dashboard's + "Recent unlocks" and "Recent workouts" tiles both render + ``, and the metric subpages' target / + usual-range cards are `

` too. Sitting one level lower made + the chart card the odd one out AND skipped a level under the + page `

`: the dashboard's rendered ladder read h1 → h3 → + h3 → h3 → h2 → h2, and /insights/bmi read h1 → h3 → h2. A + heading level is a claim about nesting, and the chart card is + not nested inside anything. */} {titleIcon ? ( ) : ( -

{title}

+

{title}

)} {activeBucket !== "day" && (