diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx new file mode 100644 index 000000000..086d422b1 --- /dev/null +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -0,0 +1,475 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + Glp1MedicationCard, + type Glp1Medication, +} from "@/components/medications/glp1-medication-card"; +import { MedicationCard } from "@/components/medications/medication-card"; + +/** + * v1.4.25 W4d-tests — RTL-style component coverage for the GLP-1 + * medication-card variant deferred by phase W4d. + * + * The project never installed `@testing-library/react`; the convention + * is `renderToStaticMarkup` + assertions against the SSR string, with + * react-query data seeded via `QueryClient.setQueryData()` so the + * card's `useQuery` calls resolve synchronously. Interactive behaviour + * (button onClick → callback) is smoke-checked by invoking the + * supplied handler directly — SSR can't fire DOM events. + * + * Test cases (per phase-W4d-v1425-glp1-full-report.md "Tests deferred"): + * 1. Renders GLP-1 variant when treatmentClass === "GLP1" + * 2. Renders default MedicationCard when treatmentClass === null + * 3. Drug name + current dose ("Mounjaro · 7.5 mg") visible + * 4. Last + next injection labels with localised weekday + * 5. Inline dose-history disclosure (closed by default) + * 6. Injection-site rotation marker (last + recommended) + * 7. Pen-inventory line when data present + * 8. Side-effect quick-log button hands off the medication object + * (the parent wires this to MoodEntry with a pre-filled tag) + * 9. AI-disabled state: the GLP-1 Coach hand-off button (deferred — + * not yet implemented; if/when it lands it must respect the + * provider-configured gate). This test pins the current absence. + */ + +const med7p5: Glp1Medication = { + id: "med-glp1-1", + name: "Mounjaro", + dose: "7.5 mg", + category: "OTHER", + treatmentClass: "GLP1", + dosesPerUnit: 4, + active: true, + notificationsEnabled: true, + pausedAt: null, + // ~3 days ago so "last injection" reads as a recent absolute date, + // not "today/yesterday" — keeps the SSR string deterministic-ish + // across local clocks while still exercising the with-site branch. + lastTakenAt: "2026-05-08T08:00:00.000Z", + schedules: [ + { + id: "s1", + // Saturday only — one entry triggers the weekly-preset branch + // in `predictNextWeeklyDate()`. + windowStart: "08:00", + windowEnd: "20:00", + label: null, + dose: "7.5 mg", + daysOfWeek: "6", + }, + ], +}; + +const defaultMed = { + id: "med-bp-1", + name: "Ramipril", + dose: "5 mg", + category: "BLOOD_PRESSURE", + treatmentClass: undefined as string | undefined, + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + schedules: [ + { + id: "s2", + windowStart: "07:30", + windowEnd: "09:30", + label: null, + dose: null, + daysOfWeek: null, + }, + ], +}; + +function makeClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: Infinity } }, + }); +} + +/** + * Seed both queries the card consumes (compliance + glp1 details) so + * the first SSR pass already has data — TanStack Query returns + * cached data synchronously when present, even on the server. + */ +function seedGlp1Details( + client: QueryClient, + medId: string, + details: { + doseChanges?: Array<{ + id: string; + effectiveFrom: string; + doseValue: number; + doseUnit: string; + note: string | null; + }>; + recentIntakes?: Array<{ + takenAt: string | null; + injectionSite: string | null; + }>; + inventory?: { + pensRemaining: number | null; + dosesRemaining: number | null; + weeksOfSupply: number | null; + lowStock: boolean; + } | null; + }, +) { + client.setQueryData(["medications", medId, "glp1-details"], { + doseChanges: details.doseChanges ?? [], + recentIntakes: details.recentIntakes ?? [], + inventory: details.inventory ?? null, + }); +} + +function seedCompliance( + client: QueryClient, + medId: string, + payload: { + rate7?: number; + rate30?: number; + streak?: number; + } = {}, +) { + client.setQueryData(["medications", medId, "compliance"], { + compliance7: { + rate: payload.rate7 ?? 85, + streak: payload.streak ?? 0, + }, + compliance30: { rate: payload.rate30 ?? 82 }, + }); +} + +function render( + node: React.ReactNode, + client: QueryClient, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + {node} + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe(" — GLP-1 variant rendering", () => { + it("renders the GLP-1 variant when treatmentClass === 'GLP1' is active", () => { + // Headline differentiator: the GLP-1 card stamps the + // `medications.treatmentClassGlp1` badge ("GLP-1 injection") + // which the generic card never renders. Presence of the badge is + // the contract pin for "GLP-1 variant is showing". + const client = makeClient(); + seedCompliance(client, med7p5.id); + seedGlp1Details(client, med7p5.id, {}); + + const html = render( + {}} />, + client, + ); + + expect(html).toContain("GLP-1 injection"); + // The Syringe lucide icon renders an SVG with this class. + expect(html).toMatch(/lucide-syringe/i); + }); + + it("renders the default MedicationCard when treatmentClass is null/undefined (back-compat)", () => { + // The page dispatcher renders MedicationCard for everything that + // isn't `"GLP1"`. We verify the default card's output stays free + // of the GLP-1-specific badge so legacy mocks (no treatmentClass + // field) keep producing the v1.4.24 UI. + const client = makeClient(); + seedCompliance(client, defaultMed.id); + + const html = render( + {}} />, + client, + ); + + // Default card shows the localised category label, not the + // GLP-1 badge. + expect(html).toContain("Blood Pressure"); + expect(html).not.toContain("GLP-1 injection"); + expect(html).not.toMatch(/lucide-syringe/i); + // The dose-history disclosure is GLP-1-only; default card omits it. + expect(html).not.toContain("Dose history"); + }); + + it("shows drug name + current dose ('Mounjaro · 7.5 mg')", () => { + const client = makeClient(); + seedCompliance(client, med7p5.id); + seedGlp1Details(client, med7p5.id, {}); + + const html = render( + {}} />, + client, + ); + + expect(html).toContain("Mounjaro"); + expect(html).toContain("7.5 mg"); + // Card title joins the two with the middle-dot separator the + // glp1Headline copy uses. + expect(html).toContain("·"); + }); + + it("shows last + next injection labels with localised weekday", () => { + const client = makeClient(); + seedCompliance(client, med7p5.id); + seedGlp1Details(client, med7p5.id, { + recentIntakes: [ + { takenAt: med7p5.lastTakenAt, injectionSite: "ABDOMEN_LEFT" }, + ], + }); + + const html = render( + {}} />, + client, + ); + + // The with-site copy reads "Last: