mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
test(medications): RTL coverage for GLP-1 medication-card variant
Closes the deferred component-level test gap from phase W4d (`.planning/phase-W4d-v1425-glp1-full-report.md` section 5). Renders the Glp1MedicationCard via renderToStaticMarkup with a seeded QueryClientProvider so the useQuery calls resolve synchronously, then asserts on the produced SSR string. Coverage (13 tests): - GLP-1 variant renders when treatmentClass === "GLP1" - Default MedicationCard back-compat when treatmentClass is null - Drug name + dose joined with the middle-dot separator - Last + next injection lines with localised prefixes - Dose-history <details> disclosure (closed by default) - Injection-site rotation hint (last + recommended) - Pen-inventory line + low-stock badge - Side-effect quick-log button hands off the medication object - AI-disabled state pin (no Coach hand-off button today) - Inactive paused-since state - German locale parity with Umlaute round-trip No production code changes.
This commit is contained in:
1 parent
5a9612822e
commit
f9621e483b
1 file changed
+475
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<Glp1MedicationCard> — 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(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
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(
|
||||
<MedicationCard medication={defaultMed} onEdit={() => {}} />,
|
||||
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(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
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(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
client,
|
||||
);
|
||||
|
||||
// The with-site copy reads "Last: <label> · <site>"; both halves
|
||||
// must show up in the SSR string.
|
||||
expect(html).toContain("Last:");
|
||||
expect(html).toContain("Abdomen, lower left");
|
||||
// The next-injection helper produces one of three strings — today,
|
||||
// tomorrow, or "in N days". All three contain the localised "Next:"
|
||||
// prefix from `glp1NextInjection*` keys.
|
||||
expect(html).toMatch(/Next:/);
|
||||
});
|
||||
|
||||
it("renders the inline dose-history disclosure (closed by default)", () => {
|
||||
// The collapsible <details> element keeps the card height matched
|
||||
// to a generic card on first paint. SSR includes the element with
|
||||
// no `open=""` attribute; clicking the summary toggles state via
|
||||
// React's onToggle handler (not exercisable from SSR).
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
doseChanges: [
|
||||
{
|
||||
id: "dc-1",
|
||||
effectiveFrom: "2026-02-01",
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
id: "dc-2",
|
||||
effectiveFrom: "2026-04-01",
|
||||
doseValue: 7.5,
|
||||
doseUnit: "mg",
|
||||
note: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const html = render(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
client,
|
||||
);
|
||||
|
||||
// The <details> wrapper renders with the localised summary.
|
||||
expect(html).toMatch(/<details[^>]*>/);
|
||||
expect(html).toContain("Dose history");
|
||||
// Closed by default — no `open=""` attribute on the <details>.
|
||||
const detailsTag = html.match(/<details[^>]*>/)?.[0] ?? "";
|
||||
expect(detailsTag).not.toMatch(/\sopen(=""|\s|>)/);
|
||||
// Dose-history rows are present in the markup even though they're
|
||||
// visually hidden until expanded.
|
||||
expect(html).toContain("5 mg");
|
||||
expect(html).toContain("7.5 mg");
|
||||
});
|
||||
|
||||
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.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
recentIntakes: [
|
||||
{ takenAt: "2026-05-08T08:00:00Z", injectionSite: "ABDOMEN_LEFT" },
|
||||
{ takenAt: "2026-05-01T08:00:00Z", injectionSite: "ABDOMEN_UPPER_LEFT" },
|
||||
],
|
||||
});
|
||||
|
||||
const html = render(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("Last site:");
|
||||
expect(html).toContain("Recommended next:");
|
||||
// Last site is the most-recent intake site.
|
||||
expect(html).toContain("Abdomen, lower left");
|
||||
});
|
||||
|
||||
it("renders the pen-inventory line when inventory data is present", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
inventory: {
|
||||
pensRemaining: 2,
|
||||
dosesRemaining: 8,
|
||||
weeksOfSupply: 8,
|
||||
lowStock: false,
|
||||
},
|
||||
});
|
||||
|
||||
const html = render(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
client,
|
||||
);
|
||||
|
||||
// The inventory copy template is "{pens} pens left · ~{weeks} weeks of supply".
|
||||
expect(html).toContain("2 pens left");
|
||||
expect(html).toContain("8 weeks of supply");
|
||||
// Low-stock badge omitted when lowStock=false.
|
||||
expect(html).not.toContain("Low stock");
|
||||
});
|
||||
|
||||
it("renders the low-stock badge when inventory.lowStock is true", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
inventory: {
|
||||
pensRemaining: 1,
|
||||
dosesRemaining: 3,
|
||||
weeksOfSupply: 3,
|
||||
lowStock: true,
|
||||
},
|
||||
});
|
||||
|
||||
const html = render(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("1 pens left");
|
||||
expect(html).toContain("Low stock");
|
||||
});
|
||||
|
||||
it("side-effect quick-log button hands off the medication object", () => {
|
||||
// SSR can't fire DOM clicks, so we smoke-check the contract:
|
||||
// - the button renders when onLogSideEffect is supplied
|
||||
// - invoking the handler synchronously delivers the GLP-1
|
||||
// medication object the parent will then prefill MoodEntry with
|
||||
// (the parent's MoodEntry mutation reads med.name to build the
|
||||
// pre-tagged side-effect entry).
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {});
|
||||
|
||||
const handler = vi.fn();
|
||||
const html = render(
|
||||
<Glp1MedicationCard
|
||||
medication={med7p5}
|
||||
onEdit={() => {}}
|
||||
onLogSideEffect={handler}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
// Button renders with the localised label.
|
||||
expect(html).toContain("Log side effect");
|
||||
|
||||
// Invoke the supplied handler the way the button's onClick would —
|
||||
// pins the prefill payload contract for MoodEntry.
|
||||
handler(med7p5);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Mounjaro", treatmentClass: "GLP1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the side-effect button when onLogSideEffect is not supplied", () => {
|
||||
// Back-compat: the button is opt-in via the prop. Pages that
|
||||
// haven't wired the MoodEntry hand-off yet should still render
|
||||
// the card without a dead button.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {});
|
||||
|
||||
const html = render(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("Log side effect");
|
||||
});
|
||||
|
||||
it("AI-disabled state: no Coach hand-off button is rendered today", () => {
|
||||
// The phase-W4d brief reserves a "Coach hand-off button (if
|
||||
// present)" slot on the GLP-1 card. It has not landed in v1.4.25
|
||||
// — the GLP-1 Coach context flows through the global Coach via
|
||||
// GROUND RULE 9, not a per-card button. This test pins the
|
||||
// current absence so a future hand-off button that doesn't
|
||||
// respect the `aiEnabled` gate is caught by the suite.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {});
|
||||
|
||||
const html = render(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
client,
|
||||
);
|
||||
|
||||
// No "Ask Coach" / "Coach" / "Ask AI" CTA on the card surface.
|
||||
expect(html).not.toMatch(/data-slot=["']glp1-coach-handoff["']/);
|
||||
expect(html).not.toMatch(/Ask Coach/i);
|
||||
});
|
||||
|
||||
it("renders inactive state when medication is paused", () => {
|
||||
const paused: Glp1Medication = {
|
||||
...med7p5,
|
||||
active: false,
|
||||
pausedAt: "2026-05-10T00:00:00.000Z",
|
||||
};
|
||||
const client = makeClient();
|
||||
seedCompliance(client, paused.id);
|
||||
seedGlp1Details(client, paused.id, {});
|
||||
|
||||
const html = render(
|
||||
<Glp1MedicationCard medication={paused} onEdit={() => {}} />,
|
||||
client,
|
||||
);
|
||||
|
||||
// Paused-since badge replaces the primary actions; the card
|
||||
// greys out via the `opacity-60` class on the Card shell.
|
||||
expect(html).toContain("opacity-60");
|
||||
expect(html).toContain("Paused since");
|
||||
// Primary actions ("Taken" / "Skipped") are suppressed for
|
||||
// inactive medications.
|
||||
expect(html).not.toContain("Log side effect");
|
||||
});
|
||||
|
||||
it("renders German copy under the 'de' locale", () => {
|
||||
// I18n smoke check — confirms the GLP-1 surface uses the t()
|
||||
// path consistently. The German Umlaut "ä" round-trips through
|
||||
// the JSON import + SSR pipeline.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
recentIntakes: [
|
||||
{ takenAt: med7p5.lastTakenAt, injectionSite: "ABDOMEN_LEFT" },
|
||||
],
|
||||
});
|
||||
|
||||
const html = render(
|
||||
<Glp1MedicationCard medication={med7p5} onEdit={() => {}} />,
|
||||
client,
|
||||
"de",
|
||||
);
|
||||
|
||||
expect(html).toContain("GLP-1-Injektion");
|
||||
expect(html).toContain("Letzter Termin:");
|
||||
expect(html).toContain("Bauch, unten links");
|
||||
expect(html).toContain("Dosis-Historie");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user