diff --git a/src/components/settings/__tests__/arztbericht-hero-card.test.tsx b/src/components/settings/__tests__/arztbericht-hero-card.test.tsx new file mode 100644 index 000000000..1ed711b9d --- /dev/null +++ b/src/components/settings/__tests__/arztbericht-hero-card.test.tsx @@ -0,0 +1,116 @@ +/** + * v1.4.37 W7a — Arztbericht hero card contract. + * + * The flagship doctor-report PDF surface was promoted from one of five + * grid cards into a hero card at the top of `/settings/export`. This + * suite pins the SSR contract: + * + * 1. The hero renders with the expected `data-testid` slot. + * 2. The value statement is present (Marc-Voice copy, no AI mention). + * 3. The CTA button is present, clears the 44 px touch floor, and + * carries an `aria-describedby` link to the value statement. + * 4. The CTA is wired to a click handler (the prop `data-testid` + * surface is the e2e contract). + * 5. Both DE and EN locales resolve their copy without leaking the + * raw i18n key. + * + * Project convention is SSR-only tests (no `@testing-library/react`). + * The actual click-flow + PDF generation is exercised by the e2e + * Playwright path and the dialog's own integration tests. + */ + +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/export", +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ArztberichtHeroCard } from "../arztbericht-hero-card"; + +function renderSSR(node: React.ReactElement, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" — SSR contract", () => { + it("renders the hero slot with eyebrow + title", () => { + const html = renderSSR(); + expect(html).toContain('data-testid="export-hero-doctor-report"'); + // Title falls back to the existing doctorReport.title key — keeps + // a single source-of-truth for the brand name. + expect(html).toContain("Doctor Report"); + // Eyebrow surfaces the use-case framing. + expect(html).toContain("Doctor visit"); + }); + + it("renders the value statement as a Marc-Voice one-liner", () => { + const html = renderSSR(); + expect(html).toContain('data-testid="export-hero-doctor-report-value"'); + expect(html).toContain("printable PDF report"); + // No AI/Coach name-drop in the user-facing artefact (Marc-Voice). + expect(html.toLowerCase()).not.toContain("coach"); + expect(html.toLowerCase()).not.toContain(" ai "); + }); + + it("renders the CTA with min-h-11 touch target + aria-describedby", () => { + const html = renderSSR(); + expect(html).toContain('data-testid="export-hero-doctor-report-action"'); + // 44 px touch floor (WCAG 2.5.5) — the hero CTA pins `h-11` on + // mobile and falls back to the compact `sm:h-10` on pointer + // devices. + expect(html).toContain("h-11"); + // Aria-describedby links the CTA to the value statement so screen + // readers announce the framing alongside the action label. + expect(html).toMatch(/aria-describedby="[^"]+"/); + }); + + it("does NOT leak raw i18n keys", () => { + const html = renderSSR(); + expect(html).not.toContain("settings.sections.export.hero"); + expect(html).not.toContain("settings.sections.export.cards.doctorReport"); + }); + + it("renders German copy when the DE locale is active", () => { + const html = renderSSR(, "de"); + expect(html).toContain("Arztbericht"); + expect(html).toContain("Arzttermin"); + // Umlauts encoded as their literal UTF-8 character (not their HTML + // entity), matching the rest of the codebase. + expect(html).toContain("druckbarer PDF-Bericht"); + expect(html).not.toContain("settings.sections.export.hero"); + }); + + it("uses the hero-gradient + glow-purple visual treatment", () => { + const html = renderSSR(); + // Same surface utilities as the Insights hero strip — keeps the + // app's hero language consistent. + expect(html).toContain("hero-gradient"); + expect(html).toContain("glow-purple"); + }); + + it("does not paint dialog markup in the closed-state SSR pass", () => { + // Radix Dialog renders its portal content lazily, so the closed + // SSR pass never carries `role="dialog"`. The CTA testid IS + // present so the e2e suite can drive the open from there. Pinning + // this contract guards against an accidental `defaultOpen={true}` + // regression that would briefly paint the dialog above the page on + // every cold mount. + const html = renderSSR(); + expect(html).toContain('data-testid="export-hero-doctor-report-action"'); + expect(html).not.toContain('role="dialog"'); + }); +}); + +describe(" — heading hierarchy", () => { + it("uses an

so it nests cleanly below the page-level

", () => { + const html = renderSSR(); + // Page-level h1 (settings-section-export-title) wraps the section; + // hero owns the h2 slot. The export-section test pins the outer + // h1; this assertion pins the hero's contribution. + expect(html).toMatch(/]*id="export-hero-doctor-report-title"/); + }); +}); diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx index 5dab1fb4e..fe76616ff 100644 --- a/src/components/settings/__tests__/export-section.test.tsx +++ b/src/components/settings/__tests__/export-section.test.tsx @@ -1,15 +1,24 @@ /** * v1.4.16 Phase B7 — Settings → Export section. * - * The acceptance criterion for the consolidated Export menu is "five cards": - * 1. Doctor Report (PDF) + * The acceptance criterion for the consolidated Export menu was "five + * cards": + * 1. Doctor Report (PDF) — promoted to the hero in v1.4.37 W7a; + * now lives at the top of the page, NOT in the grid * 2. Measurements CSV * 3. Medications CSV (with optional intake-history toggle) * 4. Mood CSV * 5. Full JSON Backup * - * Each card must surface a title, a 1-line description, and a download/generate - * button. SSR-only smoke test — interaction is exercised by the e2e suite. + * Each card must surface a title, a 1-line description, and a + * download/generate button. SSR-only smoke test — interaction is + * exercised by the e2e suite. + * + * v1.4.37 W7a — the doctor-report card was extracted into + * `` and rendered above the grid. The remaining + * four cards stay under a "Weitere Export-Optionen" / "Other export + * options" sub-heading. The hero owns its own contract test; this + * suite pins the page-level shape. */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -51,12 +60,27 @@ describe(" — SSR smoke", () => { expect(html).not.toContain("settings.sections.export."); }); - it("renders exactly five export cards", () => { + it("renders the Arztbericht hero card at the top of the page", () => { + const html = render(); + // The hero owns its own contract suite; here we pin that the page + // mounts it, and that the legacy in-grid doctor-report card is + // gone (v1.4.37 W7a moved it into the hero). + expect(html).toContain('data-testid="export-hero-doctor-report"'); + expect(html).not.toContain('data-testid="export-card-doctor-report"'); + }); + + it("renders the 'Other export options' sub-heading above the grid", () => { + const html = render(); + expect(html).toContain("Other export options"); + expect(html).toContain('id="settings-section-export-other-title"'); + }); + + it("renders exactly four secondary export cards in the grid", () => { const html = render(); // Each card carries a stable data-testid so the e2e suite can target - // them without relying on the localised label. + // them without relying on the localised label. The doctor-report + // card is no longer in this grid — it's the hero above. const ids = [ - "export-card-doctor-report", "export-card-measurements-csv", "export-card-medications-csv", "export-card-mood-csv", @@ -75,17 +99,20 @@ describe(" — SSR smoke", () => { expect(html).toContain('data-testid="export-medications-include-intake"'); }); - it("renders a download/generate button per card", () => { + it("renders a download/generate button per secondary card + the hero CTA", () => { const html = render(); - // Buttons are typed `button` and decorated with a stable testid so a - // single regex finds all five — the labels themselves are i18n'd. + // Four secondary cards each carry an `export-action-*` testid; the + // hero owns the `export-hero-doctor-report-action` slot. const buttonMatches = html.match(/data-testid="export-action-/g); - expect(buttonMatches?.length ?? 0).toBe(5); + expect(buttonMatches?.length ?? 0).toBe(4); + expect(html).toContain('data-testid="export-hero-doctor-report-action"'); }); - it("German locale renders the DE heading", () => { + it("German locale renders the DE heading + hero copy", () => { const html = render(, "de"); expect(html).toContain("Export"); + expect(html).toContain("Arztbericht"); + expect(html).toContain("Weitere Export-Optionen"); expect(html).not.toContain("settings.sections.export."); }); });