test(meds): coverage for compose-mode

Five new payload-helper cases pin the multi-schedule encoder, the
hydration that opens on the list view, the add and remove guards,
and the schedule-id preservation on edit. A new Playwright spec
walks a two-schedule create flow end-to-end and verifies both
summary cards land before save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 19:52:39 +02:00
1 parent 8578924f14
commit dba844235c
2 files changed
+339

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
import { expect, test, type Route } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1Name,
fillStep3Dose,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — compose-mode.
*
* Walks the create flow through to Step 8, then taps
* "Weiteren Zeitplan hinzufügen" to append a second weekly Wednesday
* schedule on the same medication. The list view surfaces both
* summary cards before save, and the POST body carries both
* schedules so the route can persist them in parallel.
*
* Use case the maintainer cares about: short-acting + long-acting
* insulin on a single medication. Today the wizard collapses to one
* schedule on save; compose-mode closes that data-loss gap.
*/
test.describe("medication wizard — compose-mode", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a 2-schedule medication end-to-end", async ({ page }) => {
await stubDashboardAnalytics(page);
const listEntry = {
id: STUB_MEDICATION_ID,
name: "Insulin",
dose: "10 IE",
category: "DIABETES",
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [
{
id: "sch_e2e_1",
windowStart: "08:00",
windowEnd: "09:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
reminderGraceMinutes: null,
},
{
id: "sch_e2e_2",
windowStart: "20:00",
windowEnd: "21:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["20:00"],
rrule: "FREQ=WEEKLY;BYDAY=WE",
rollingIntervalDays: null,
reminderGraceMinutes: null,
},
],
};
const capture: { postBody: unknown } = { postBody: null };
void page.route("**/api/medications**", async (route: Route) => {
const req = route.request();
const url = new URL(req.url());
if (url.pathname !== "/api/medications") {
await route.fallback();
return;
}
if (req.method() === "POST") {
capture.postBody = JSON.parse(req.postData() ?? "null");
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ data: { ...listEntry }, error: null }),
});
return;
}
if (req.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [listEntry], error: null }),
});
return;
}
await route.fallback();
});
await openCreateWizard(page);
// Steps 1-4 — medication-global.
await fillStep1Name(page, { name: "Insulin" });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "diabetes");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "10" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
// Steps 5-7 — first schedule, daily cadence.
await expectStep(page, 5, 8);
await pickCadenceRow(page, "daily");
await expectStep(page, 5, 7);
await clickNext(page);
await expectStep(page, 6, 7);
await clickNext(page);
// Step 8 — list view with the first schedule rendered. Tap
// "Weiteren Zeitplan hinzufügen" to append a second draft.
await expectStep(page, 7, 7);
let cards = page.locator('[data-slot="wizard-schedule-card"]');
await expect(cards).toHaveCount(1);
await page.locator('[data-slot="wizard-schedule-add"]').click();
// Second schedule — weekdays on Wednesday.
await expectStep(page, 5);
await pickCadenceRow(page, "weekdays");
await clickNext(page);
await expectStep(page, 6);
const weChip = page.locator(
'[data-slot="wizard-weekday-chip"][data-token="WE"]',
);
if ((await weChip.getAttribute("data-active")) === "false") {
await weChip.click();
}
// Default MO chip — toggle off so the rrule is WE-only.
const moChip = page.locator(
'[data-slot="wizard-weekday-chip"][data-token="MO"]',
);
if ((await moChip.getAttribute("data-active")) === "true") {
await moChip.click();
}
await clickNext(page);
await expectStep(page, 7);
await clickNext(page);
// Back on Step 8 — both summary cards should land before save.
await expectStep(page, 8);
cards = page.locator('[data-slot="wizard-schedule-card"]');
await expect(cards).toHaveCount(2);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
name: "Insulin",
oneShot: false,
schedules: [
{ rrule: "FREQ=DAILY" },
{ rrule: "FREQ=WEEKLY;BYDAY=WE" },
],
});
await expectMedicationOnList(page, {
name: "Insulin",
withNextDueChip: true,
});
});
});
@@ -1,12 +1,17 @@
import { describe, expect, it } from "vitest";
import {
addSchedule,
buildCreateBody,
commitActiveDraft,
emptyWizardPayload,
hydrateWizardPayload,
landingStepForEdit,
type MedicationPayload,
progressIndices,
removeSchedule,
rowFromTreatment,
setActiveSchedule,
summariseCadence,
validateStep,
type WizardPayload,
@@ -576,3 +581,158 @@ describe("hydrateWizardPayload", () => {
expect(out.subControls.intervalWeeks).toBe(2);
});
});
describe("compose-mode — multi-schedule encoder + hydrator", () => {
it("buildCreateBody emits every schedule for a 2-schedule medication", () => {
// Active draft is a recurring daily schedule (the wizard flat
// mirror). A second draft sits in the list as a weekly Wednesday
// schedule. Both must land in the encoded body.
const base = withCadence("daily");
const second = withCadence("weekdays", { weekdays: ["WE"] });
const payload: WizardPayload = {
...base,
treatmentRow: "diabetes",
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
schedules: [
// First draft mirrors the flat fields — buildCreateBody
// commits the active draft before encoding.
{
mode: base.mode,
cadence: base.cadence,
subControls: base.subControls,
timesOfDay: ["08:00"],
},
{
mode: second.mode,
cadence: second.cadence,
subControls: second.subControls,
timesOfDay: ["20:00"],
},
],
activeScheduleIndex: 0,
};
const body = buildCreateBody(payload);
expect(body.schedules).toHaveLength(2);
expect(body.schedules[0].rrule).toBe("FREQ=DAILY");
expect(body.schedules[0].timesOfDay).toEqual(["08:00"]);
expect(body.schedules[1].rrule).toBe("FREQ=WEEKLY;BYDAY=WE");
expect(body.schedules[1].timesOfDay).toEqual(["20:00"]);
});
it("buildUpdateBody preserves schedule.id on edit", () => {
// Hydrate from a multi-schedule medication, then encode; every
// schedule must carry its persisted `id` back through.
const initial: MedicationPayload = {
id: "med_compose",
name: "Insulin",
dose: "10 IE",
category: "DIABETES",
treatmentClass: "GENERIC",
notificationsEnabled: true,
startsOn: new Date(Date.UTC(2026, 0, 1)),
endsOn: null,
oneShot: false,
schedules: [
{
id: "sch_short",
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00", "12:00", "18:00"],
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
},
{
id: "sch_long",
windowStart: "22:00",
windowEnd: "23:00",
timesOfDay: ["22:00"],
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
},
],
};
const payload = hydrateWizardPayload(initial);
const body = buildCreateBody(payload);
expect(body.schedules).toHaveLength(2);
expect(body.schedules[0].id).toBe("sch_short");
expect(body.schedules[1].id).toBe("sch_long");
});
it("hydrateWizardPayload reads N schedules and lands on Step 8 when N > 1", () => {
const initial: MedicationPayload = {
id: "med_compose",
name: "Insulin",
dose: "10 IE",
category: "DIABETES",
treatmentClass: "GENERIC",
notificationsEnabled: true,
startsOn: new Date(Date.UTC(2026, 0, 1)),
endsOn: null,
oneShot: false,
schedules: [
{
id: "sch_a",
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
},
{
id: "sch_b",
windowStart: "22:00",
windowEnd: "23:00",
timesOfDay: ["22:00"],
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
},
],
};
const payload = hydrateWizardPayload(initial);
expect(payload.schedules).toHaveLength(2);
expect(payload.activeScheduleIndex).toBe(0);
expect(payload.schedules[0].id).toBe("sch_a");
expect(payload.schedules[1].id).toBe("sch_b");
expect(landingStepForEdit(payload)).toBe(8);
// Single-schedule still lands on Step 1.
const single = hydrateWizardPayload({
...initial,
schedules: [initial.schedules[0]],
});
expect(landingStepForEdit(single)).toBe(1);
});
it("removeSchedule refuses when schedules.length === 1", () => {
const payload = emptyWizardPayload();
expect(payload.schedules).toHaveLength(1);
const after = removeSchedule(payload, 0);
// Returns the same payload reference / shape — no mutation.
expect(after.schedules).toHaveLength(1);
expect(after.activeScheduleIndex).toBe(0);
});
it("addSchedule appends and bumps activeScheduleIndex", () => {
const payload = emptyWizardPayload();
const after = addSchedule(payload);
expect(after.schedules).toHaveLength(2);
expect(after.activeScheduleIndex).toBe(1);
// setActiveSchedule routes the flat mirror back to the first
// draft, and a removeSchedule then drops the second.
const back = setActiveSchedule(after, 0);
expect(back.activeScheduleIndex).toBe(0);
const dropped = removeSchedule(back, 1);
expect(dropped.schedules).toHaveLength(1);
// commitActiveDraft writes the flat mirror onto the active slot.
const edited = commitActiveDraft({
...after,
timesOfDay: ["20:00"],
});
expect(edited.schedules[after.activeScheduleIndex].timesOfDay).toEqual([
"20:00",
]);
});
});