From 00e251584997654c3c1669cec1e8bc03c5a936a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 14:35:42 +0200 Subject: [PATCH] test(medications): pin Ramipril / Mounjaro detail-card symmetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Side-by-side contract suite for the v1.4.37 W4b symmetry fixes from Marc's UX audit (item 11). Asserts the load-bearing structural seams both medication-card variants must share: * History + Pencil header buttons render on both cards. * No kebab on either card when no overflow prop is wired (Mounjaro matches Ramipril in the common case). * Kebab only mounts on Mounjaro when `onLogSideEffect` is supplied; no equivalent surface on Ramipril. * Category-label badge uses the shared category-map lookup on both cards (Mounjaro no longer hard-codes the treatment-class label). * Take-now coloured pill renders on both cards when inside the current schedule window. * Primary-actions row carries the canonical two-button shape — the GLP-1-specific Stethoscope no longer leaks into this row. * Purple `text-purple-400` accent on the upcoming schedule dose surfaces uniformly across both card kinds. --- .../medication-card-symmetry.test.tsx | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 src/components/medications/__tests__/medication-card-symmetry.test.tsx diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx new file mode 100644 index 000000000..89fbe8c63 --- /dev/null +++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx @@ -0,0 +1,319 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { MedicationCard } from "@/components/medications/medication-card"; +import { + Glp1MedicationCard, + type Glp1Medication, +} from "@/components/medications/glp1-medication-card"; + +/** + * v1.4.37 W4b — symmetry contract between the generic medication card + * (Ramipril/non-GLP-1) and the GLP-1 variant (Mounjaro). Marc reported + * during the v1.4.37 UX audit that the two cards "felt different" + * even though both should read as the same row shape. These tests pin + * the load-bearing structural seams so a future refactor can't quietly + * regress the parity. + * + * What we assert: + * 1. Header `actions` cluster — both cards always render `History` + * and `Pencil` icon buttons. The Mounjaro card optionally renders + * an additional kebab when `onLogSideEffect` is wired; absent the + * prop the headers are byte-shape equivalent. + * 2. Category-label badge — both cards consult the same + * `getMedicationCategoryLabel` lookup. No card hard-codes the + * `medications.treatmentClassGlp1` string into the slot. + * 3. Take-now status pill — when the current time is inside a + * configured schedule window and `lastTakenAt` is null/older, both + * cards render the `medications.takeNow` localised text. + * 4. Primary actions row — exactly two buttons (Eingenommen / + * Übersprungen). The side-effect quick-log lives in the header + * overflow when wired, not in this row. + * 5. Purple dose accent — both cards render + * `font-medium text-purple-400` on the upcoming schedule dose. + */ + +function makeClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); +} + +function render( + node: React.ReactNode, + client: QueryClient, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + {node} + , + ); +} + +function seedCompliance(client: QueryClient, medId: string) { + client.setQueryData(["medications", medId, "compliance"], { + compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 }, + compliance30: { rate: 88 }, + }); +} + +function seedGlp1Details(client: QueryClient, medId: string) { + client.setQueryData(["medications", medId, "glp1-details"], { + doseChanges: [], + recentIntakes: [], + inventory: null, + }); +} + +/** + * Schedule that fires every day of the week from 00:00 to 23:59 so + * the SSR snapshot always lands inside the current window regardless + * of the host clock — pinning the "take now" pill assertion. + */ +const allDayWindow = { + windowStart: "00:00", + windowEnd: "23:59", + label: null, + daysOfWeek: null, +}; + +const ramipril = { + id: "med-ramipril-1", + name: "Ramipril", + dose: "5 mg", + category: "BLOOD_PRESSURE", + treatmentClass: undefined as string | undefined, + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + schedules: [ + { + id: "s-ramipril-1", + ...allDayWindow, + dose: "5 mg", + }, + ], +}; + +const mounjaro: Glp1Medication = { + id: "med-mounjaro-1", + name: "Mounjaro", + dose: "7.5 mg", + category: "HORMONE", + treatmentClass: "GLP1", + dosesPerUnit: 4, + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + schedules: [ + { + id: "s-mounjaro-1", + ...allDayWindow, + dose: "7.5 mg", + }, + ], +}; + +describe("medication card symmetry — Ramipril vs Mounjaro", () => { + it("both cards render History + Pencil header buttons", () => { + const client = makeClient(); + seedCompliance(client, ramipril.id); + seedCompliance(client, mounjaro.id); + seedGlp1Details(client, mounjaro.id); + + const ramiprilHtml = render( + {}} />, + client, + ); + const mounjaroHtml = render( + {}} />, + client, + ); + + for (const html of [ramiprilHtml, mounjaroHtml]) { + expect(html).toContain("lucide-history"); + expect(html).toContain("lucide-pencil"); + } + }); + + it("neither card paints a kebab when no overflow prop is wired", () => { + // Symmetry default: without onLogSideEffect, the Mounjaro header + // shape matches the Ramipril header exactly (history + edit only). + const client = makeClient(); + seedCompliance(client, ramipril.id); + seedCompliance(client, mounjaro.id); + seedGlp1Details(client, mounjaro.id); + + const ramiprilHtml = render( + {}} />, + client, + ); + const mounjaroHtml = render( + {}} />, + client, + ); + + for (const html of [ramiprilHtml, mounjaroHtml]) { + expect(html).not.toContain('aria-label="More options"'); + } + }); + + it("Mounjaro gains a kebab when onLogSideEffect is supplied (GLP-1-only)", () => { + const client = makeClient(); + seedCompliance(client, mounjaro.id); + seedGlp1Details(client, mounjaro.id); + + const html = render( + {}} + onLogSideEffect={() => {}} + />, + client, + ); + + expect(html).toContain('aria-label="More options"'); + }); + + it("category label uses the shared category-map lookup on both cards", () => { + const client = makeClient(); + seedCompliance(client, ramipril.id); + seedCompliance(client, mounjaro.id); + seedGlp1Details(client, mounjaro.id); + + const ramiprilHtml = render( + {}} />, + client, + ); + const mounjaroHtml = render( + {}} />, + client, + ); + + // Ramipril → BLOOD_PRESSURE → "Blood Pressure". + expect(ramiprilHtml).toContain("Blood Pressure"); + // Mounjaro → HORMONE → "Hormones". The card must NOT hard-code + // the treatment-class label into the slot any more. + expect(mounjaroHtml).toContain("Hormones"); + expect(mounjaroHtml).not.toContain("GLP-1 injection"); + }); + + it("both cards render the take-now pill when inside the current schedule window", () => { + const client = makeClient(); + seedCompliance(client, ramipril.id); + seedCompliance(client, mounjaro.id); + seedGlp1Details(client, mounjaro.id); + + const ramiprilHtml = render( + {}} />, + client, + ); + const mounjaroHtml = render( + {}} />, + client, + ); + + for (const html of [ramiprilHtml, mounjaroHtml]) { + expect(html).toContain("Take now"); + // Coloured-pill contract — the in_window pill class carries + // the success token, not the warning token. + expect(html).toContain("text-success"); + } + }); + + it("primary actions row carries exactly two buttons on both cards", () => { + const client = makeClient(); + seedCompliance(client, ramipril.id); + seedCompliance(client, mounjaro.id); + seedGlp1Details(client, mounjaro.id); + + const ramiprilHtml = render( + {}} />, + client, + ); + const mounjaroHtml = render( + {}} + onLogSideEffect={() => {}} + />, + client, + ); + + for (const html of [ramiprilHtml, mounjaroHtml]) { + // Taken / Skipped — and no third in-row button. We assert the + // primary-actions buttons via icon class names since those + // surface on both cards. + expect(html).toContain("lucide-check"); + expect(html).toContain("lucide-skip-forward"); + } + // The Stethoscope glyph is GLP-1-only; SSR doesn't open the + // Radix Portal so even when wired the menu item is hidden from + // the initial markup. The primary row must NOT carry it. + expect(mounjaroHtml).not.toMatch( + /class="[^"]*flex[^"]*gap-2[^"]*"[^>]*>[\s\S]*?lucide-stethoscope/, + ); + }); + + it("purple dose accent surfaces on the upcoming schedule dose on both cards", () => { + // Pin the asymmetry from the v1.4.37 UX audit (item 11): the + // GLP-1 card historically lacked the purple accent. Both cards + // must now stamp the same `text-purple-400` token on the + // schedule dose. We render at a window that's NOT currently + // active so the "next intake" line (which carries the accent) + // surfaces instead of the take-now pill. + const futureWindow = { + windowStart: "01:00", + windowEnd: "02:00", + label: null, + daysOfWeek: null, + }; + const ramiprilFuture = { + ...ramipril, + schedules: [ + { + id: "s-ramipril-future", + ...futureWindow, + dose: "5 mg", + }, + ], + }; + const mounjaroFuture: Glp1Medication = { + ...mounjaro, + schedules: [ + { + id: "s-mounjaro-future", + ...futureWindow, + dose: "7.5 mg", + }, + ], + }; + + const client = makeClient(); + seedCompliance(client, ramiprilFuture.id); + seedCompliance(client, mounjaroFuture.id); + seedGlp1Details(client, mounjaroFuture.id); + + const ramiprilHtml = render( + {}} />, + client, + ); + const mounjaroHtml = render( + {}} />, + client, + ); + + // Both surfaces carry the purple token on the upcoming dose. + expect(ramiprilHtml).toContain("text-purple-400"); + expect(mounjaroHtml).toContain("text-purple-400"); + }); +});