From 2a584f621028175a11394a9847ba5b93e45598be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 07:06:38 +0200 Subject: [PATCH] test(medications): pin card next/last slot symmetry + equal height Extend the symmetry suite to assert both cards render the byte-identical next/last slot wrapper (same reserved min-height, spacing, muted colour, next-then-last order, no em-dash placeholder, no brighter foreground token) and a constant-height bottom-pinned body across oral / injection / GLP-1 / PRN / one-shot, including the compliance-skeleton path. Update the dose-accent assertions onto text-dose-accent and the GLP-1 rotation test onto the folded-into-line nudge. Clock stays pinned. --- .../__tests__/medication-card-glp1.test.tsx | 20 +- .../medication-card-symmetry.test.tsx | 188 +++++++++++++++++- 2 files changed, 198 insertions(+), 10 deletions(-) diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index 6263f478c..2c1c6bc95 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -359,11 +359,15 @@ describe(" — GLP-1 variant rendering", () => { expect(html).not.toContain("Dosis-Historie"); }); - it("renders the injection-site rotation marker (last + recommended)", () => { - // The rotation hint surfaces only when `lastSite` and the - // recommender disagree. We feed a left-cluster history → the - // recommender lands on a right-side or thigh/arm site, so the - // hint card renders. + it("renders the injection-site rotation nudge folded into the upcoming line", () => { + // The rotation nudge surfaces only when `lastSite` and the recommender + // disagree. We feed a left-cluster history → the recommender lands on a + // right-side or thigh/arm site. The nudge is no longer a separate + // bordered block (which made the GLP-1 card taller than the generic card + // in a grid row); it is folded into the upcoming-injection line as a + // "· Recommended next: …" suffix, so the card keeps the same row + // inventory as the generic card. The last site stays visible on the + // last-injection line. const client = makeClient(); seedCompliance(client, med7p5.id); seedGlp1Details(client, med7p5.id, { @@ -386,9 +390,11 @@ describe(" — GLP-1 variant rendering", () => { client, ); - expect(html).toContain("Last site:"); + // The recommended-next nudge renders in the upcoming line. expect(html).toContain("Recommended next:"); - // Last site is the most-recent intake site. + // The standalone bordered rotation block is gone (height-neutral fold). + expect(html).not.toContain("bg-muted/40 rounded-md border"); + // Last site stays visible on the last-injection line. expect(html).toContain("Abdomen, lower left"); }); diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx index d6d2627fe..0dfcee20e 100644 --- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx +++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx @@ -328,8 +328,12 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { label: null, daysOfWeek: null, }; + // A future next-due so the GLP-1 card's now-gated next-injection line + // renders (it no longer prints a "—" placeholder when there is no next). + const nextDueAt = new Date("2026-06-05T01:00:00Z").toISOString(); const ramiprilFuture = { ...ramipril, + nextDueAt, schedules: [ { id: "s-ramipril-future", @@ -340,6 +344,7 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { }; const mounjaroFuture: Glp1Medication = { ...mounjaro, + nextDueAt, schedules: [ { id: "s-mounjaro-future", @@ -373,8 +378,185 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => { client, ); - // Both surfaces carry the purple token on the upcoming dose. - expect(ramiprilHtml).toContain("text-purple-400"); - expect(mounjaroHtml).toContain("text-purple-400"); + // Both surfaces carry the dose-accent token on the upcoming dose. The + // token is theme-aware (`--dose-accent`): pixel-identical to the former + // Tailwind `text-purple-400` in dark mode, AA-safe on the white Alucard + // card in light mode. The hard-coded `text-purple-400` is gone. + expect(ramiprilHtml).toContain("text-dose-accent"); + expect(mounjaroHtml).toContain("text-dose-accent"); + expect(ramiprilHtml).not.toContain("text-purple-400"); + expect(mounjaroHtml).not.toContain("text-purple-400"); + }); + + it("the next/last slot is structurally identical (same wrapper, order, colour, spacing) on both cards", () => { + // The two cards now route the middle slot through the shared + // . The wrapper, line order (next then last), + // colour token and spacing must be byte-identical across types — the + // historical divergence (reversed order, `text-foreground/85` vs + // `text-muted-foreground`, `space-y-1` vs `space-y-3.5`) is gone. + const pastWindow = { + windowStart: "01:00", + windowEnd: "02:00", + label: null, + daysOfWeek: null, + }; + // Render with a last-intake set + an upcoming (non-active) window so + // BOTH the next and last lines are present, exercising the full slot. + const yesterday = new Date("2026-06-01T09:00:00Z").toISOString(); + const ramiprilBoth = { + ...ramipril, + lastTakenAt: yesterday, + nextDueAt: new Date("2026-06-05T01:00:00Z").toISOString(), + schedules: [{ id: "s-r-both", ...pastWindow, dose: "5 mg" }], + }; + const mounjaroBoth: Glp1Medication = { + ...mounjaro, + lastTakenAt: yesterday, + nextDueAt: new Date("2026-06-05T01:00:00Z").toISOString(), + schedules: [{ id: "s-m-both", ...pastWindow, dose: "7.5 mg" }], + }; + + const client = makeClient(); + seedCompliance(client, ramiprilBoth.id); + seedCompliance(client, mounjaroBoth.id); + seedGlp1Details(client, mounjaroBoth.id); + + const ramiprilHtml = render( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ); + const mounjaroHtml = render( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ); + + for (const html of [ramiprilHtml, mounjaroHtml]) { + // Identical reserved-height wrapper with identical spacing. + expect(html).toContain('class="min-h-[2.75rem] space-y-3.5 text-sm"'); + // The slot's lines paint with the muted token on both cards — the + // brighter `text-foreground/85` GLP-1 next-line is gone. + expect(html).not.toContain("text-foreground/85"); + // No literal em-dash placeholder for an absent next-injection. + expect(html).not.toContain(">—<"); + // Both lines present (next + last), so two muted

in the slot. + const slotStart = html.indexOf("min-h-[2.75rem]"); + const slot = html.slice(slotStart, slotStart + 800); + const lines = slot.match(/

/g) ?? []; + expect(lines.length).toBe(2); + } + }); + + it("both cards keep a constant-height card body and bottom-pin the action row across types", () => { + // The body is a flex column (`flex h-full flex-col`) and the action row + // carries `mt-auto`, so unequal content can't misalign the take/skip + // buttons across a grid row. A compliance skeleton reserves the bars' + // footprint while the query is null. Exercise the generic card across + // schedule-less types (PRN / one-shot) plus a scheduled oral and GLP-1. + const base = { + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + }; + const oral = { + ...base, + id: "m-oral", + name: "Metformin", + dose: "500 mg", + category: "DIABETES", + schedules: [{ id: "so", ...allDayWindow, dose: "500 mg" }], + }; + const prn = { + ...base, + id: "m-prn", + name: "Ibuprofen", + dose: "400 mg", + category: "OTHER", + // PRN: no schedule, no next-due → shortest possible body. + schedules: [], + }; + const oneShot = { + ...base, + id: "m-oneshot", + name: "Vaccine", + dose: "1 dose", + category: "OTHER", + schedules: [], + }; + const glp1: Glp1Medication = { + ...mounjaro, + id: "m-glp1", + }; + + const client = makeClient(); + // Seed compliance for some but NOT others, so the skeleton path + // (compliance null) is exercised alongside the loaded path. + seedCompliance(client, oral.id); + seedGlp1Details(client, glp1.id); + // prn + oneShot intentionally left without compliance → skeleton. + + const htmls = [ + render( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ), + render( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ), + render( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ), + render( + {}} + onOpenHistory={() => {}} + onOpenAdvanced={() => {}} + />, + client, + ), + ]; + + for (const html of htmls) { + // Card body is a bottom-pinning flex column on every type. + expect(html).toContain("flex h-full flex-col space-y-3.5"); + // Action row carries the bottom-pin. + expect(html).toContain("mt-auto"); + // Reserved next/last slot is present on every type (constant height). + expect(html).toContain("min-h-[2.75rem] space-y-3.5 text-sm"); + } + + // The two cards left without compliance render the skeleton so their + // body keeps the bars' footprint rather than collapsing ~5rem shorter. + expect(htmls[1]).toContain("aria-hidden"); // prn skeleton + expect(htmls[2]).toContain("aria-hidden"); // one-shot skeleton }); });