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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 07:07:48 +02:00
1 parent b3e8f43ab8
commit 2a584f6210
2 files changed
+198 -10

No files matched your search

@@ -359,11 +359,15 @@ describe("<Glp1MedicationCard> — 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("<Glp1MedicationCard> — 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");
});
@@ -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
// <MedicationNextLastSlot>. 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(
<MedicationCard
medication={ramiprilBoth}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
);
const mounjaroHtml = render(
<Glp1MedicationCard
medication={mounjaroBoth}
onEdit={() => {}}
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 <p> in the slot.
const slotStart = html.indexOf("min-h-[2.75rem]");
const slot = html.slice(slotStart, slotStart + 800);
const lines = slot.match(/<p class="text-muted-foreground">/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(
<MedicationCard
medication={oral}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
render(
<MedicationCard
medication={prn}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
render(
<MedicationCard
medication={oneShot}
onEdit={() => {}}
onOpenHistory={() => {}}
onOpenAdvanced={() => {}}
/>,
client,
),
render(
<Glp1MedicationCard
medication={glp1}
onEdit={() => {}}
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
});
});