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.");
});
});