test(meds): coverage for the modal wizard

Component tests pin the path table, the step encoding, the
treatment-class to category mapping, and the create-vs-edit prop
branching. POST /api/medications gains pin-point coverage for the
new DIABETES and ANTIBIOTIC clinical-category values. Six Playwright
specs walk the new dialog end-to-end via the German label surface,
one per cadence shape; helpers consolidate the dialog-open + step
indicator assertions so each cadence spec only carries its own
sub-control selectors. The OpenAPI yaml refreshes to surface the
two new enum values to the iOS client.

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:33:11 +02:00
1 parent 105fcee242
commit 320970c97d
10 files changed
+944 -296

No files matched your search

+7 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.5.2
version: 1.5.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -1691,6 +1691,8 @@ components:
- HORMONE
- SKIN
- SLEEP_AID
- DIABETES
- ANTIBIOTIC
- OTHER
treatmentClass:
type: string
@@ -1834,6 +1836,8 @@ components:
- HORMONE
- SKIN
- SLEEP_AID
- DIABETES
- ANTIBIOTIC
- OTHER
treatmentClass:
type: string
@@ -2837,6 +2841,8 @@ components:
- HORMONE
- SKIN
- SLEEP_AID
- DIABETES
- ANTIBIOTIC
- OTHER
description: Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.
CreateMedicationResponse:
+33 -33
View File
@@ -2,28 +2,29 @@ import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickCreate,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Medication wizard end-to-end — bi-weekly cadence (every N weeks).
* v1.5.4 — Medication wizard end-to-end — bi-weekly cadence (every N
* weeks).
*
* The "everyNWeeks" picker carries two sub-controls: an integer-week
* input (defaults to 2) and a weekday chip row (defaults to [MO]).
* Keeping both defaults yields `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO`,
* which the step-7 summary phrase pins as "Alle zwei Wochen".
*
* Bi-weekly is the canonical regression case from the design-synthesis
* (the pre-v1.5 reminder worker ignored intervalWeeks). This spec
* pins the wizard's emit path; the worker fix lives in the unit suite.
* Walks the 8-step recurring path with "Alle X Wochen" picked on
* Step 5, then the interval input filled with 2 and Monday toggled
* on the weekday chips of Step 6. Emits
* `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO`.
*/
test.describe("medication wizard — bi-weekly", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -41,45 +42,44 @@ test.describe("medication wizard — bi-weekly", () => {
oneShot: false,
});
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await openCreateWizard(page);
await fillStep1(page, { name: "Methotrexat", doseAmount: "10" });
await fillStep1Name(page, { name: "Methotrexat" });
await clickNext(page);
await expectStep(page, 2);
await page.getByRole("radio", { name: /Wiederkehrend/i }).click();
await pickTreatmentRow(page, "other");
await clickNext(page);
await expectStep(page, 3);
await page
.getByRole("radio", { name: /Alle N Wochen an bestimmten Tagen/i })
.click();
// Sub-controls render inline. The integer input defaults to 2; we
// re-fill it explicitly so the spec is robust to default drift.
const weeksInput = page
.locator('[data-slot="cadence-number-input"]')
.first();
await weeksInput.fill("2");
await fillStep3Dose(page, { amount: "10" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5);
await expectStep(page, 5, 8);
await pickCadenceRow(page, "everyNWeeks");
await clickNext(page);
await expectStep(page, 6);
await expectStep(page, 6, 8);
await page.locator("#wizard-interval-weeks").fill("2");
const moChip = page.locator(
'[data-slot="wizard-weekday-chip"][data-token="MO"]',
);
if ((await moChip.getAttribute("data-active")) === "false") {
await moChip.click();
}
await clickNext(page);
await expectStep(page, 7);
const summary = page.locator('[data-slot="wizard-step7-summary"]');
// n=2 collapses to the "biweekly" summary leaf (intervalWeeks ===
// 2 short-circuits in `summaryKeyForCadence`).
await expect(summary).toContainText(/Alle zwei Wochen/i);
await expectStep(page, 7, 8);
await clickNext(page);
await clickCreate(page);
await expectStep(page, 8, 8);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/zwei Wochen/i);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
+42 -41
View File
@@ -2,26 +2,28 @@ import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickCreate,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Medication wizard end-to-end — daily cadence.
* v1.5.4 — Medication wizard end-to-end — daily cadence.
*
* Walks the seven-step `CreationWizard` from `/medications/new` using
* the DE label surface from `messages/de.json`, picks "Jeden Tag" on
* step 3, accepts the default 08:00 chip on step 5, accepts today as
* startsOn on step 6, then submits. After the POST the spec asserts
* the URL settled on `/medications/<id>` AND the medication appears on
* `/medications` with the "Nächste Einnahme:" chip.
* Walks the 7-step daily path through the modal dialog. The dialog
* mounts when `/medications/new` redirects to `/medications?new=1`.
* The DE label surface (Marc's primary locale) is the assertion
* surface — "Schritt N von 7" tracks the visible counter.
*/
test.describe("medication wizard — daily", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -31,59 +33,58 @@ test.describe("medication wizard — daily", () => {
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
name: "Vitamin D",
dose: "1000 iu",
dose: "1000 I.E.",
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
oneShot: false,
});
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await openCreateWizard(page);
// Step 1 — name + dose.
await fillStep1(page, { name: "Vitamin D", doseAmount: "1000" });
await page.locator("#wizard-dose-unit").click();
await page.getByRole("option", { name: /I\.E\./i }).click();
// Step 1 — name.
await fillStep1Name(page, { name: "Vitamin D" });
await clickNext(page);
// Step 2 — Recurring.
// Step 2 — treatment class.
await expectStep(page, 2);
await page.getByRole("radio", { name: /Wiederkehrend/i }).click();
await pickTreatmentRow(page, "vitamin");
await clickNext(page);
// Step 3 — cadence: "Jeden Tag" (daily is the default but we click
// it explicitly so the spec is robust to defaults drifting).
// Step 3 — dose.
await expectStep(page, 3);
await page.getByRole("radio", { name: /^Jeden Tag$/i }).click();
await fillStep3Dose(page, { amount: "1000" });
await page.locator("#wizard-dose-unit").click();
await page.getByRole("option", { name: /I\.E\./ }).click();
await clickNext(page);
// Step 4 — recap.
// Step 4 — course window (today by default).
await expectStep(page, 4);
await expect(
page.locator('[data-slot="wizard-step4-recap"]'),
).toContainText(/Jeden Tag/i);
await clickNext(page);
// Step 5 — times of day (08:00 default chip).
await expectStep(page, 5);
await expect(
page.locator('[data-slot="times-of-day-chip"][data-time="08:00"]'),
).toBeVisible();
await clickNext(page);
// Step 6 — course window (defaults to today, no end date).
await expectStep(page, 6);
await expect(
page.locator('[data-slot="course-window-starts"]'),
).toHaveValue(/^\d{4}-\d{2}-\d{2}$/);
await clickNext(page);
// Step 7 — summary + create.
await expectStep(page, 7);
const summary = page.locator('[data-slot="wizard-step7-summary"]');
await expect(summary).toContainText(/Jeden Tag/i);
await expect(summary).toContainText(/08:00/);
// Step 5 — cadence: "Täglich".
await expectStep(page, 5, 8);
await pickCadenceRow(page, "daily");
// After picking daily, the path compresses to 7 steps; step 5
// sits at slot 5 of 7.
await expectStep(page, 5, 7);
await clickNext(page);
await clickCreate(page);
// Step 7 — times of day (08:00 default chip).
await expectStep(page, 6, 7);
await expect(
page.locator('[data-slot="times-of-day-chip"][data-time="08:00"]'),
).toBeVisible();
await clickNext(page);
// Step 8 — summary + create.
await expectStep(page, 7, 7);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Jeden Tag/i);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
+90 -74
View File
@@ -1,27 +1,25 @@
import { expect, type Page, type Route } from "@playwright/test";
/**
* Shared helpers for the medication-wizard cadence specs.
* v1.5.4 — shared helpers for the modal medication-wizard specs.
*
* The six specs (daily / weekdays / biweekly / monthly / rolling /
* oneshot) walk the same seven-step skeleton with different sub-step
* inputs. The shared pieces — POST + GET stubs, list-page entry check,
* step-indicator assertions, dashboard analytics stub — live here so
* oneshot) walk the same dialog with different cadence-specific
* inputs. The shared pieces — POST + GET stubs, list-page entry
* check, dialog open + step-indicator assertions — live here so
* the per-cadence specs only carry the cadence-specific selectors.
*
* No abstraction over the seven steps themselves: the cadence-specific
* differences (which radio to pick, which sub-control to fill) are the
* point of the spec, so the per-spec code expresses them directly.
* The path counter follows the user's path:
* one-shot = 5 steps (1, 2, 3, 4, 8)
* daily = 7 steps (1, 2, 3, 4, 5, 7, 8)
* recurring = 8 steps (1, 2, 3, 4, 5, 6, 7, 8)
*
* `expectStep(page, displayIndex, totalSteps)` pins the visible
* "Schritt X von Y" string the dialog header renders.
*/
/** The medication id the POST stub echoes back to the wizard. */
export const STUB_MEDICATION_ID = "med_e2e_wizard";
/**
* The minimum medication shape `/medications` page reads through
* `MedicationCard`. The list reads `data: Medication[]` and the card
* itself is forgiving about missing optional fields.
*/
interface StubbedMedication {
id: string;
name: string;
@@ -31,26 +29,14 @@ interface StubbedMedication {
oneShot: boolean;
}
/**
* Mock `/api/medications` for both the POST (creation) and the GET
* (list refresh after the wizard redirects). Returns the captured
* request body so each spec can assert against it independently.
*
* The list GET echoes the just-posted medication so the `/medications`
* page paints a card the spec can pin against.
*/
export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
const capture: { postBody: unknown } = { postBody: null };
// The list page reads the schedule shape too — surface the new
// v1.5 fields the card consumes (rrule, rollingIntervalDays, oneShot,
// timesOfDay). windowStart/windowEnd stay required by the card's
// legacy fallback even when timesOfDay is populated.
const listEntry = {
id: stubbed.id,
name: stubbed.name,
dose: stubbed.dose,
category: "MEDICATION",
category: "OTHER",
active: true,
notificationsEnabled: true,
pausedAt: null,
@@ -74,15 +60,9 @@ export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
],
};
// The `**/api/medications*` glob matches the bare collection URL the
// wizard posts to AND the list query the medications page issues.
// Routes registered earlier win, so the list page never falls through
// to a real server response.
void page.route("**/api/medications**", async (route: Route) => {
const req = route.request();
const url = new URL(req.url());
// Sub-paths (e.g. /api/medications/<id>/...) — let them through
// unhandled so the spec only stubs the surfaces it owns.
if (url.pathname !== "/api/medications") {
await route.fallback();
return;
@@ -116,12 +96,6 @@ export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
return capture;
}
/**
* Stub the analytics fetches that fire on `/medications` so the page
* paints without depending on the seed user's measurement state. The
* `/api/analytics(?slice=…)` regex matches both the slim and thick
* dashboard variants — same pattern the other specs use.
*/
export async function stubDashboardAnalytics(page: Page): Promise<void> {
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
@@ -146,47 +120,95 @@ export async function stubDashboardAnalytics(page: Page): Promise<void> {
}
/**
* Fill the Step 1 name + dose-amount inputs and confirm the step
* indicator reads "Schritt 1 von 7" before we leave it. The dose unit
* already defaults to "mg" via `emptyWizardPayload()` so we leave it
* alone unless the caller wants something else.
* Open the wizard dialog by hitting `/medications/new` (which
* redirects to `/medications?new=1` and opens the create dialog).
*/
export async function fillStep1(
page: Page,
{ name, doseAmount }: { name: string; doseAmount: string },
): Promise<void> {
await expect(page.getByText(/Schritt 1 von 7/i)).toBeVisible();
await page.locator("#wizard-name").fill(name);
await page.locator("#wizard-dose-amount").fill(doseAmount);
export async function openCreateWizard(page: Page): Promise<void> {
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toBeVisible({ timeout: 10_000 });
}
/**
* Click the wizard's primary Next button. Pinned by data-slot so we do
* not collide with any other "Weiter" / "Next" labelled control the
* page chrome may render.
* Fill the Step 1 name input. The dialog's first step asks only for
* the medication name — the dose moved to Step 3.
*/
export async function fillStep1Name(
page: Page,
{ name }: { name: string },
): Promise<void> {
await expectStep(page, 1);
await page.locator("#wizard-name").fill(name);
}
/** Pick a Step 2 treatment-class row by its row identifier. */
export async function pickTreatmentRow(
page: Page,
row:
| "bloodPressure"
| "diabetes"
| "hormone"
| "glp1"
| "painRelief"
| "allergy"
| "vitamin"
| "supplement"
| "antibiotic"
| "other",
): Promise<void> {
await page
.locator(`[data-slot="wizard-class-row"][data-row="${row}"]`)
.click();
}
/** Fill the Step 3 dose amount input. */
export async function fillStep3Dose(
page: Page,
{ amount }: { amount: string },
): Promise<void> {
await page.locator("#wizard-dose-amount").fill(amount);
}
/** Pick a Step 5 cadence row by its row identifier. */
export async function pickCadenceRow(
page: Page,
row: "daily" | "weekdays" | "everyNWeeks" | "monthly" | "rolling" | "oneShot",
): Promise<void> {
await page
.locator(`[data-slot="wizard-cadence-row"][data-row="${row}"]`)
.click();
}
/** Click Next. Pinned by data-slot. */
export async function clickNext(page: Page): Promise<void> {
await page.locator('[data-slot="wizard-next"]').click();
}
/** Click Save (last step CTA on create + edit). */
export async function clickSave(page: Page): Promise<void> {
await page.locator('[data-slot="wizard-save"]').click();
}
/**
* Click the wizard's primary Create button at step 7.
* Assert the wizard sits on a given visible step counter.
* `displayIndex` is the visible 1-based index; `totalSteps` is the
* path length for the active mode + cadence.
*/
export async function clickCreate(page: Page): Promise<void> {
await page.locator('[data-slot="wizard-create"]').click();
}
/** Wait for the wizard to land on a given step (1-7). */
export async function expectStep(page: Page, step: number): Promise<void> {
await expect(
page.getByText(new RegExp(`Schritt ${step} von 7`, "i")),
).toBeVisible();
export async function expectStep(
page: Page,
displayIndex: number,
totalSteps?: number,
): Promise<void> {
const pattern = totalSteps
? new RegExp(`Schritt ${displayIndex} von ${totalSteps}`, "i")
: new RegExp(`Schritt ${displayIndex} von \\d+`, "i");
await expect(page.getByText(pattern)).toBeVisible({ timeout: 5_000 });
}
/**
* Verify the post-submit URL settles on `/medications/<id>`. Next.js
* 404s for an unknown id which renders the `not-found.tsx` boundary
* cleanly; the URL is the contract we care about, not the page body.
* Verify the post-submit URL settles on `/medications/<id>` (create
* path; the dialog navigates there on success).
*/
export async function expectRedirectToMedication(
page: Page,
@@ -198,11 +220,9 @@ export async function expectRedirectToMedication(
}
/**
* Navigate to the list and assert the just-created medication appears
* with the "Nächste Einnahme:" next-due chip (`medications.nextIntake`
* leaf in `messages/de.json`). The one-shot variant uses a different
* surface — the list still shows the medication name; we only assert
* on the chip for recurring cadences.
* Navigate to the list and assert the just-created medication
* appears. The recurring variants surface a "Nächste Einnahme:" chip;
* the one-shot variant does not.
*/
export async function expectMedicationOnList(
page: Page,
@@ -212,10 +232,6 @@ export async function expectMedicationOnList(
const main = page.locator("main");
await expect(main.getByText(name).first()).toBeVisible({ timeout: 10_000 });
if (withNextDueChip) {
// "Nächste Einnahme:" with the trailing colon is the literal label
// surfaced by `medications.nextIntake`. The card renders the chip
// when the medication is active AND has a next schedule slot, both
// true for the stubbed entry.
await expect(main.getByText(/Nächste Einnahme:/i).first()).toBeVisible();
}
}
+25 -26
View File
@@ -2,23 +2,27 @@ import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickCreate,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Medication wizard end-to-end — monthly cadence.
* v1.5.4 — Medication wizard end-to-end — monthly cadence.
*
* Picks "Monatlich an einem bestimmten Tag" on step 3 and pins
* dayOfMonth = 15. The emitted rrule is `FREQ=MONTHLY;BYMONTHDAY=15`
* and the step-7 summary surfaces the "Monatlich" phrase.
* Walks the 8-step recurring path with "Monatlich" picked on Step 5
* and dayOfMonth = 15 set on Step 6. Emits
* `FREQ=MONTHLY;BYMONTHDAY=15`.
*/
test.describe("medication wizard — monthly", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -30,49 +34,44 @@ test.describe("medication wizard — monthly", () => {
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
name: "Vitamin B12",
dose: "1000 mcg",
dose: "1000 µg",
rrule: "FREQ=MONTHLY;BYMONTHDAY=15",
rollingIntervalDays: null,
oneShot: false,
});
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await openCreateWizard(page);
await fillStep1(page, { name: "Vitamin B12", doseAmount: "1000" });
await fillStep1Name(page, { name: "Vitamin B12" });
await clickNext(page);
await expectStep(page, 2);
await page.getByRole("radio", { name: /Wiederkehrend/i }).click();
await pickTreatmentRow(page, "vitamin");
await clickNext(page);
await expectStep(page, 3);
await page
.getByRole("radio", { name: /Monatlich an einem bestimmten Tag/i })
.click();
// Set day-of-month to 15. The sub-control input is the only number
// input inside the selected `monthly` option block.
const dayInput = page
.locator('[data-slot="cadence-number-input"]')
.first();
await dayInput.fill("15");
await fillStep3Dose(page, { amount: "1000" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5);
await expectStep(page, 5, 8);
await pickCadenceRow(page, "monthly");
await clickNext(page);
await expectStep(page, 6);
await expectStep(page, 6, 8);
await page.locator("#wizard-day-of-month").fill("15");
await clickNext(page);
await expectStep(page, 7);
const summary = page.locator('[data-slot="wizard-step7-summary"]');
await expectStep(page, 7, 8);
await clickNext(page);
await expectStep(page, 8, 8);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Monatlich/i);
await clickCreate(page);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
+40 -40
View File
@@ -2,30 +2,33 @@ import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickCreate,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Medication wizard end-to-end — one-shot cadence.
* v1.5.4 — Medication wizard end-to-end — one-shot cadence.
*
* Picking "Einmaldosis" on step 2 short-circuits the wizard past
* steps 3 + 4 and lands on step 5, where the user picks the single
* dose date alongside the time-of-day chip. Step 6 keeps endsOn
* pinned to startsOn via `lockEndsToStart`, and step 7 surfaces the
* "Einmaldosis" summary phrase.
* Picking "Einmalig" on Step 5 collapses the path onto the 5-step
* one-shot route ([1, 2, 3, 4, 8]). The wizard skips Steps 6 + 7
* because the dose date already lives in Step 4 (course window).
* Step 4's CourseWindowRow locks endsOn to startsOn for one-shot.
*
* The list-page next-due chip is suppressed for one-shot medications
* because the design-synthesis lifecycle deactivates the row the
* moment the single intake is logged. The spec only asserts the row
* name appears on the list.
* The list-page "Nächste Einnahme:" chip is suppressed because the
* design-synthesis lifecycle deactivates one-shot medications once
* the single intake is logged. The spec only asserts the row name
* appears on the list.
*/
test.describe("medication wizard — one-shot", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -41,46 +44,43 @@ test.describe("medication wizard — one-shot", () => {
oneShot: true,
});
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await openCreateWizard(page);
await fillStep1(page, { name: "Grippeimpfung", doseAmount: "0.5" });
await fillStep1Name(page, { name: "Grippeimpfung" });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "other");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "0.5" });
await page.locator("#wizard-dose-unit").click();
await page.getByRole("option", { name: /^ml$/i }).click();
await clickNext(page);
await expectStep(page, 2);
await page.getByRole("radio", { name: /Einmaldosis/i }).click();
// The wizard skips steps 3 + 4 in one-shot mode — clicking Next on
// step 2 jumps directly to step 5 (see `goNext` in CreationWizard).
// Step 4 — course window. Set startsOn to a deterministic date so
// the post body assertion below is stable.
await expectStep(page, 4);
await page.locator('[data-slot="course-window-starts"]').fill("2026-10-15");
await clickNext(page);
await expectStep(page, 5);
// One-shot variant renders the single time picker (maxChips=1) and
// a dose-date input. The default startsOn is today; we override to
// a fixed date so the assertion below is deterministic.
const oneShotDate = page.locator("#wizard-oneshot-date");
await expect(oneShotDate).toBeVisible();
await oneShotDate.fill("2026-10-15");
// Step 5 — pick Einmalig. The path compresses to 5 steps the
// moment the row is picked; the visible counter follows.
await expectStep(page, 5, 8);
await pickCadenceRow(page, "oneShot");
// After the pick the path is [1, 2, 3, 4, 8]; the cadence pick
// step (raw step 5) sits between slots 4 and 5 of 5, so the
// counter pins on slot 4 of 5 until Next.
await expectStep(page, 4, 5);
await clickNext(page);
await expectStep(page, 6);
// One-shot pins endsOn to startsOn — the end-date input is
// read-only and the "Kein Enddatum" Switch is hidden.
await expect(
page.locator('[data-slot="course-window-ends"]'),
).toHaveValue("2026-10-15");
await expect(
page.locator('[data-slot="course-window-oneshot-caption"]'),
).toBeVisible();
await clickNext(page);
await expectStep(page, 7);
const summary = page.locator('[data-slot="wizard-step7-summary"]');
await expectStep(page, 5, 5);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Einmaldosis/i);
await expect(summary).toContainText(/2026-10-15/);
await clickCreate(page);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
+37 -45
View File
@@ -2,28 +2,30 @@ import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickCreate,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Medication wizard end-to-end — rolling cadence.
* v1.5.4 — Medication wizard end-to-end — rolling cadence.
*
* The flexible-rolling cadence is the design-synthesis differentiator
* (only Dosecast + MedTimer model it among commercial competitors).
* The wizard emits a `rollingIntervalDays` integer in place of an
* rrule string; the step-7 summary surfaces the "Alle {n} Tage seit
* der letzten Einnahme" phrase.
*
* The spec pins rollingDays = 7 — the canonical Mounjaro / Ozempic
* cadence the design-synthesis P-2 case is anchored on.
* Walks the 8-step recurring path with "Flexibel ab letzter
* Einnahme" picked on Step 5 and rollingDays = 7 set on Step 6.
* Emits `rollingIntervalDays: 7` (no rrule). The cadence row also
* surfaces Marc's literal "Counter startet neu" copy directly below
* the row label — the assertion below pins the phrase so a future
* copy edit can't silently drop the rolling explainer.
*/
test.describe("medication wizard — rolling", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -41,54 +43,44 @@ test.describe("medication wizard — rolling", () => {
oneShot: false,
});
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await openCreateWizard(page);
await fillStep1(page, { name: "Mounjaro", doseAmount: "5" });
await fillStep1Name(page, { name: "Mounjaro" });
await clickNext(page);
await expectStep(page, 2);
await page.getByRole("radio", { name: /Wiederkehrend/i }).click();
await pickTreatmentRow(page, "glp1");
await clickNext(page);
await expectStep(page, 3);
await page
.getByRole("radio", {
name: /Alle N Tage seit der letzten Einnahme \(flexibel\)/i,
})
.click();
// Set rolling-days to 7. The sub-control is the only number input
// inside the selected `rolling` option block.
const daysInput = page
.locator('[data-slot="cadence-number-input"]')
.first();
await daysInput.fill("7");
// The explainer is the rolling-cadence-specific guidance the
// design-synthesis mandates — pin its presence so a future copy
// edit that drops it surfaces here.
await expect(
page.locator('[data-slot="cadence-rolling-explainer"]'),
).toBeVisible();
await fillStep3Dose(page, { amount: "5" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5);
await clickNext(page);
await expectStep(page, 6);
await clickNext(page);
await expectStep(page, 7);
const summary = page.locator('[data-slot="wizard-step7-summary"]');
await expect(summary).toContainText(
/Alle 7 Tage seit der letzten Einnahme/i,
await expectStep(page, 5, 8);
// The rolling row surfaces the Counter-Reset explainer right
// under the row label, not in a tooltip.
const rollingRow = page.locator(
'[data-slot="wizard-cadence-row"][data-row="rolling"]',
);
await expect(rollingRow).toContainText(/Counter/i);
await pickCadenceRow(page, "rolling");
await clickNext(page);
await clickCreate(page);
await expectStep(page, 6, 8);
await page.locator("#wizard-rolling-days").fill("7");
await clickNext(page);
await expectStep(page, 7, 8);
await clickNext(page);
await expectStep(page, 8, 8);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/letzten Einnahme/i);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
+40 -36
View File
@@ -2,29 +2,33 @@ import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickCreate,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Medication wizard end-to-end — weekdays cadence.
* v1.5.4 — Medication wizard end-to-end — specific weekdays.
*
* Walks the seven-step wizard via the DE label surface and picks
* "An bestimmten Wochentagen" on step 3, toggles Mo + Mi + Fr on the
* weekday chip-row, then completes the rest of the flow. The emitted
* rrule is `FREQ=WEEKLY;BYDAY=MO,WE,FR`.
* Walks the 8-step recurring path through the modal dialog with
* "Bestimmte Wochentage" picked on Step 5, then Mo / Mi / Fr toggled
* on the weekday chips of Step 6. The emitted rrule is
* `FREQ=WEEKLY;BYDAY=MO,WE,FR`.
*/
test.describe("medication wizard — weekdays", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a Mon/Wed/Fri medication end-to-end", async ({ page }) => {
test("creates a Mo/Mi/Fr medication end-to-end", async ({ page }) => {
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
@@ -35,51 +39,51 @@ test.describe("medication wizard — weekdays", () => {
oneShot: false,
});
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await openCreateWizard(page);
await fillStep1(page, { name: "Ibuprofen", doseAmount: "400" });
await fillStep1Name(page, { name: "Ibuprofen" });
await clickNext(page);
await expectStep(page, 2);
await page.getByRole("radio", { name: /Wiederkehrend/i }).click();
await pickTreatmentRow(page, "painRelief");
await clickNext(page);
await expectStep(page, 3);
await page
.getByRole("radio", { name: /An bestimmten Wochentagen/i })
.click();
// Toggle Mo + Mi + Fr — `aria-label` uses the long DE name.
await page
.getByRole("button", { name: /^Montag$/i })
.click();
// The "weekdays" default is [MO] so Monday may already be active;
// re-toggle if so. Cheaper than peeking at aria-pressed: just lean
// on the data-active attribute.
const moChip = page.locator(
'[data-slot="cadence-weekday-chip"][data-token="MO"]',
);
if ((await moChip.getAttribute("data-active")) === "false") {
await moChip.click();
}
await page.getByRole("button", { name: /^Mittwoch$/i }).click();
await page.getByRole("button", { name: /^Freitag$/i }).click();
await fillStep3Dose(page, { amount: "400" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5);
await expectStep(page, 5, 8);
await pickCadenceRow(page, "weekdays");
await clickNext(page);
await expectStep(page, 6);
await expectStep(page, 6, 8);
// The weekday default may already be [MO]; re-tick to make the
// selection deterministic for the assertion below.
const moChip = page.locator(
'[data-slot="wizard-weekday-chip"][data-token="MO"]',
);
if ((await moChip.getAttribute("data-active")) === "false") {
await moChip.click();
}
await page
.locator('[data-slot="wizard-weekday-chip"][data-token="WE"]')
.click();
await page
.locator('[data-slot="wizard-weekday-chip"][data-token="FR"]')
.click();
await clickNext(page);
await expectStep(page, 7);
const summary = page.locator('[data-slot="wizard-step7-summary"]');
await expect(summary).toContainText(/An ausgewählten Wochentagen/i);
await expectStep(page, 7, 8);
await clickNext(page);
await clickCreate(page);
await expectStep(page, 8, 8);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Wochentagen/i);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
@@ -298,3 +298,55 @@ describe("POST /api/medications — v1.5 scheduling primitives", () => {
expect(s.timesOfDay).toEqual(["08:00"]);
});
});
describe("POST /api/medications — v1.5.4 clinical category extension", () => {
/**
* The modal wizard's Step 2 taxonomy adds Diabetes and Antibiotikum
* as first-class rows. The mapping table writes `category:
* "DIABETES"` and `category: "ANTIBIOTIC"` to the side-table for
* those rows instead of collapsing them into `"OTHER"`.
*
* The Zod schema accepts the new values; the route's
* `setMedicationCategory` helper normalises them through
* `MEDICATION_CATEGORY_VALUES`. The tests below pin that the route
* accepts both new values without 422-ing.
*/
it("accepts DIABETES as a valid clinical category", async () => {
const res = await POST(
postReq({
name: "Metformin",
dose: "500 mg",
category: "DIABETES",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(res.status).toBe(201);
});
it("accepts ANTIBIOTIC as a valid clinical category", async () => {
const res = await POST(
postReq({
name: "Amoxicillin",
dose: "500 mg",
category: "ANTIBIOTIC",
oneShot: true,
startsOn: "2026-06-01",
schedules: [{ windowStart: "08:00", windowEnd: "08:30" }],
}),
);
expect(res.status).toBe(201);
});
it("still 422s on an unknown category string", async () => {
const res = await POST(
postReq({
name: "Random",
dose: "1 tab",
category: "MYSTERY",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,578 @@
import { describe, expect, it } from "vitest";
import {
buildCreateBody,
emptyWizardPayload,
hydrateWizardPayload,
type MedicationPayload,
progressIndices,
rowFromTreatment,
summariseCadence,
validateStep,
type WizardPayload,
WIZARD_TREATMENT_MAPPING,
type WizardTreatmentRow,
} from "@/components/medications/wizard/wizard-payload";
import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
import {
type CadenceKind,
DEFAULT_SUB_CONTROLS,
} from "@/components/medications/scheduling/types";
/**
* v1.5.4 — wizard payload pure-helper tests.
*
* Project convention is SSR-only Vitest (no `@testing-library/react`).
* The interactive dialog is covered by Playwright; the pure helpers
* here carry the unit-test contract:
*
* - `validateStep` — per-step gate boolean.
* - `progressIndices` — path table for the visible counter.
* - `buildCreateBody` — POST/PUT request body encoder, incl. the
* Step 2 → (treatmentClass, category) mapping.
* - `summariseCadence` — Step 8 plain-language summary.
* - `hydrateWizardPayload` — edit-path hydration, esp. the legacy
* `(daysOfWeek, intervalWeeks)` bridge.
*/
function makeStubT() {
return (
key: string,
params?: Record<string, string | number>,
): string => {
if (!params) return key;
return `${key}(${JSON.stringify(params)})`;
};
}
function withCadence(
kind: CadenceKind,
sub: Partial<typeof DEFAULT_SUB_CONTROLS> = {},
): WizardPayload {
const subControls = { ...DEFAULT_SUB_CONTROLS, ...sub };
return {
...emptyWizardPayload(),
mode: kind === "oneShot" ? "oneShot" : "recurring",
treatmentRow: "other",
name: "Foo",
doseAmount: "5",
doseUnit: "mg",
cadence: encodeCadence(kind, subControls),
subControls,
};
}
describe("progressIndices", () => {
it("returns the 8-step recurring path before the user picks a mode", () => {
expect(progressIndices(null, "daily")).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
});
it("returns the 5-step one-shot path for oneShot mode", () => {
expect(progressIndices("oneShot", "oneShot")).toEqual([1, 2, 3, 4, 8]);
});
it("returns the 7-step daily path when recurring + daily cadence", () => {
expect(progressIndices("recurring", "daily")).toEqual([
1, 2, 3, 4, 5, 7, 8,
]);
});
it("returns the 8-step recurring path for non-daily cadences", () => {
for (const kind of [
"weekdays",
"everyNWeeks",
"monthly",
"rolling",
] as const) {
expect(progressIndices("recurring", kind)).toEqual([
1, 2, 3, 4, 5, 6, 7, 8,
]);
}
});
});
describe("validateStep", () => {
it("step 1: rejects empty name; accepts populated", () => {
const base = emptyWizardPayload();
expect(validateStep(base, 1)).toBe(false);
expect(validateStep({ ...base, name: "Foo" }, 1)).toBe(true);
expect(validateStep({ ...base, name: " " }, 1)).toBe(false);
});
it("step 2: requires a treatment row", () => {
const base = emptyWizardPayload();
expect(validateStep(base, 2)).toBe(false);
expect(
validateStep({ ...base, treatmentRow: "bloodPressure" }, 2),
).toBe(true);
});
it("step 3: requires populated dose amount", () => {
const base = emptyWizardPayload();
expect(validateStep(base, 3)).toBe(false);
expect(validateStep({ ...base, doseAmount: "5" }, 3)).toBe(true);
});
it("step 4: rejects null startsOn", () => {
const base = emptyWizardPayload();
expect(validateStep({ ...base, startsOn: null }, 4)).toBe(false);
});
it("step 4: rejects endsOn before startsOn", () => {
const base = emptyWizardPayload();
const start = new Date(Date.UTC(2026, 5, 1));
const earlier = new Date(Date.UTC(2026, 4, 1));
expect(
validateStep({ ...base, startsOn: start, endsOn: earlier }, 4),
).toBe(false);
});
it("step 4: accepts null endsOn (chronic)", () => {
const base = emptyWizardPayload();
expect(
validateStep(
{ ...base, startsOn: new Date(Date.UTC(2026, 5, 1)), endsOn: null },
4,
),
).toBe(true);
});
it("step 5: requires a mode pick", () => {
const base = emptyWizardPayload();
expect(validateStep(base, 5)).toBe(false);
expect(validateStep({ ...base, mode: "recurring" }, 5)).toBe(true);
expect(validateStep({ ...base, mode: "oneShot" }, 5)).toBe(true);
});
it("step 6: weekdays requires >= 1 chip", () => {
expect(
validateStep(withCadence("weekdays", { weekdays: [] }), 6),
).toBe(false);
expect(
validateStep(withCadence("weekdays", { weekdays: ["MO"] }), 6),
).toBe(true);
});
it("step 6: monthly accepts day-of-month 1..31", () => {
expect(
validateStep(withCadence("monthly", { dayOfMonth: 1 }), 6),
).toBe(true);
expect(
validateStep(withCadence("monthly", { dayOfMonth: 31 }), 6),
).toBe(true);
});
it("step 6: rolling accepts 1..365 days", () => {
expect(
validateStep(withCadence("rolling", { rollingDays: 1 }), 6),
).toBe(true);
expect(
validateStep(withCadence("rolling", { rollingDays: 365 }), 6),
).toBe(true);
});
it("step 7: requires >= 1 valid HH:mm time", () => {
const base = withCadence("daily");
expect(validateStep({ ...base, timesOfDay: [] }, 7)).toBe(false);
expect(validateStep({ ...base, timesOfDay: ["nope"] }, 7)).toBe(false);
expect(validateStep({ ...base, timesOfDay: ["08:00"] }, 7)).toBe(true);
});
it("step 8: mirrors step 4 (course-window range)", () => {
const base = withCadence("daily");
expect(validateStep({ ...base, startsOn: null }, 8)).toBe(false);
const start = new Date(Date.UTC(2026, 5, 1));
expect(
validateStep({ ...base, startsOn: start, endsOn: null }, 8),
).toBe(true);
});
});
describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => {
it("maps Blutdruck → (GENERIC, BLOOD_PRESSURE)", () => {
expect(WIZARD_TREATMENT_MAPPING.bloodPressure).toEqual({
treatmentClass: "GENERIC",
category: "BLOOD_PRESSURE",
});
});
it("maps Diabetes → (GENERIC, DIABETES) — new in v1.5.4", () => {
expect(WIZARD_TREATMENT_MAPPING.diabetes).toEqual({
treatmentClass: "GENERIC",
category: "DIABETES",
});
});
it("maps Antibiotikum → (GENERIC, ANTIBIOTIC) — new in v1.5.4", () => {
expect(WIZARD_TREATMENT_MAPPING.antibiotic).toEqual({
treatmentClass: "GENERIC",
category: "ANTIBIOTIC",
});
});
it("maps GLP-1-Injektion → (GLP1, OTHER) — only row with GLP1", () => {
expect(WIZARD_TREATMENT_MAPPING.glp1).toEqual({
treatmentClass: "GLP1",
category: "OTHER",
});
});
it("every row picks GENERIC except glp1", () => {
const rows: WizardTreatmentRow[] = [
"bloodPressure",
"diabetes",
"hormone",
"painRelief",
"allergy",
"vitamin",
"supplement",
"antibiotic",
"other",
];
for (const row of rows) {
expect(WIZARD_TREATMENT_MAPPING[row].treatmentClass).toBe("GENERIC");
}
});
});
describe("rowFromTreatment — reverse mapping for edit-hydration", () => {
it("GLP1 treatment class wins over category", () => {
expect(rowFromTreatment("GLP1", "OTHER")).toBe("glp1");
expect(rowFromTreatment("GLP1", "BLOOD_PRESSURE")).toBe("glp1");
});
it("DIABETES category maps to the diabetes row", () => {
expect(rowFromTreatment("GENERIC", "DIABETES")).toBe("diabetes");
});
it("ANTIBIOTIC category maps to the antibiotic row", () => {
expect(rowFromTreatment("GENERIC", "ANTIBIOTIC")).toBe("antibiotic");
});
it("unknown category falls back to 'other'", () => {
expect(rowFromTreatment("GENERIC", "MYSTERY")).toBe("other");
expect(rowFromTreatment(undefined, undefined)).toBe("other");
});
});
describe("buildCreateBody", () => {
it("emits a daily recurring schedule with the rrule slot", () => {
const p: WizardPayload = {
...withCadence("daily"),
treatmentRow: "bloodPressure",
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
endsOn: null,
};
const body = buildCreateBody(p);
expect(body.name).toBe("Foo");
expect(body.dose).toBe("5 mg");
expect(body.oneShot).toBe(false);
expect(body.treatmentClass).toBe("GENERIC");
expect(body.category).toBe("BLOOD_PRESSURE");
expect(body.notificationsEnabled).toBe(true);
expect(body.startsOn).toBe("2026-05-28");
expect(body.endsOn).toBeUndefined();
expect(body.schedules).toHaveLength(1);
expect(body.schedules[0].rrule).toBe("FREQ=DAILY");
expect(body.schedules[0].timesOfDay).toEqual(["08:00"]);
expect(body.schedules[0].windowStart).toBe("08:00");
expect(body.schedules[0].windowEnd).toBe("09:00");
});
it("emits DIABETES category for the diabetes row", () => {
const p: WizardPayload = {
...withCadence("daily"),
treatmentRow: "diabetes",
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
expect(buildCreateBody(p).category).toBe("DIABETES");
expect(buildCreateBody(p).treatmentClass).toBe("GENERIC");
});
it("emits ANTIBIOTIC category for the antibiotic row", () => {
const p: WizardPayload = {
...withCadence("oneShot"),
treatmentRow: "antibiotic",
timesOfDay: ["09:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
endsOn: new Date(Date.UTC(2026, 4, 28)),
};
expect(buildCreateBody(p).category).toBe("ANTIBIOTIC");
});
it("emits the GLP1 treatmentClass for the glp1 row", () => {
const p: WizardPayload = {
...withCadence("everyNWeeks", {
intervalWeeks: 1,
weekdays: ["WE"],
}),
treatmentRow: "glp1",
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
const body = buildCreateBody(p);
expect(body.treatmentClass).toBe("GLP1");
expect(body.category).toBe("OTHER");
});
it("emits bi-weekly RRULE for everyNWeeks (n=2, Wednesday)", () => {
const p: WizardPayload = {
...withCadence("everyNWeeks", {
intervalWeeks: 2,
weekdays: ["WE"],
}),
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
const body = buildCreateBody(p);
expect(body.schedules[0].rrule).toBe("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE");
expect(body.schedules[0].rollingIntervalDays).toBeUndefined();
});
it("emits monthly RRULE with BYMONTHDAY", () => {
const p: WizardPayload = {
...withCadence("monthly", { dayOfMonth: 15 }),
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
const body = buildCreateBody(p);
expect(body.schedules[0].rrule).toBe("FREQ=MONTHLY;BYMONTHDAY=15");
});
it("emits rollingIntervalDays instead of rrule for rolling", () => {
const p: WizardPayload = {
...withCadence("rolling", { rollingDays: 7 }),
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
const body = buildCreateBody(p);
expect(body.schedules[0].rrule).toBeUndefined();
expect(body.schedules[0].rollingIntervalDays).toBe(7);
});
it("emits oneShot=true with no recurrence and endsOn = startsOn", () => {
const start = new Date(Date.UTC(2026, 9, 15));
const p: WizardPayload = {
...withCadence("oneShot"),
timesOfDay: ["09:00"],
startsOn: start,
endsOn: start,
};
const body = buildCreateBody(p);
expect(body.oneShot).toBe(true);
expect(body.startsOn).toBe("2026-10-15");
expect(body.endsOn).toBe("2026-10-15");
expect(body.schedules[0].rrule).toBeUndefined();
expect(body.schedules[0].rollingIntervalDays).toBeUndefined();
});
it("threads notificationsEnabled through", () => {
const p: WizardPayload = {
...withCadence("daily"),
notificationsEnabled: false,
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
expect(buildCreateBody(p).notificationsEnabled).toBe(false);
});
});
describe("summariseCadence", () => {
const t = makeStubT();
it("uses 'daily' for the daily cadence", () => {
const out = summariseCadence(withCadence("daily"), t);
expect(out).toContain("medications.wizard.summary.cadence.daily");
});
it("uses 'biweekly' for everyNWeeks with n=2", () => {
const out = summariseCadence(
withCadence("everyNWeeks", { intervalWeeks: 2 }),
t,
);
expect(out).toContain("medications.wizard.summary.cadence.biweekly");
});
it("uses 'rolling' and threads the day count", () => {
const p = withCadence("rolling", { rollingDays: 14 });
const out = summariseCadence(p, t);
expect(out).toMatch(/cadence\.rolling[^|]*"n":14/);
});
it("uses 'oneShot' and omits the times / endsOn phrases", () => {
const start = new Date(Date.UTC(2026, 9, 15));
const p: WizardPayload = {
...withCadence("oneShot"),
startsOn: start,
endsOn: start,
};
const out = summariseCadence(p, t);
expect(out).toContain("medications.wizard.summary.cadence.oneShot");
expect(out).not.toContain("summary.endsOn");
expect(out).not.toContain("summary.times");
});
it("surfaces 'noEndDate' when recurring + endsOn null", () => {
const p: WizardPayload = {
...withCadence("daily"),
startsOn: new Date(Date.UTC(2026, 4, 28)),
endsOn: null,
};
expect(summariseCadence(p, t)).toContain(
"medications.wizard.summary.noEndDate",
);
});
it("appends a weekdays-detail phrase for the weekday cadence", () => {
const p = withCadence("weekdays", { weekdays: ["MO", "WE", "FR"] });
const out = summariseCadence(p, t);
expect(out).toContain("medications.wizard.summary.weekdaysDetail");
});
it("appends day-of-month detail for the monthly cadence", () => {
const p = withCadence("monthly", { dayOfMonth: 15 });
const out = summariseCadence(p, t);
expect(out).toContain("medications.wizard.summary.dayOfMonthDetail");
expect(out).toMatch(/dayOfMonthDetail[^|]*"day":15/);
});
});
describe("hydrateWizardPayload", () => {
function makeInitial(
overrides: Partial<MedicationPayload> = {},
): MedicationPayload {
return {
id: "med_1",
name: "Ramipril",
dose: "5 mg",
category: "BLOOD_PRESSURE",
treatmentClass: "GENERIC",
notificationsEnabled: true,
startsOn: new Date(Date.UTC(2026, 0, 1)),
endsOn: null,
oneShot: false,
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
},
],
...overrides,
};
}
it("hydrates Ramipril / daily / BP as the blood-pressure row", () => {
const out = hydrateWizardPayload(makeInitial());
expect(out.name).toBe("Ramipril");
expect(out.doseAmount).toBe("5");
expect(out.doseUnit).toBe("mg");
expect(out.treatmentRow).toBe("bloodPressure");
expect(out.mode).toBe("recurring");
expect(out.cadence.kind).toBe("daily");
expect(out.timesOfDay).toEqual(["08:00"]);
});
it("hydrates a DIABETES medication as the diabetes row", () => {
const out = hydrateWizardPayload(
makeInitial({ category: "DIABETES" }),
);
expect(out.treatmentRow).toBe("diabetes");
});
it("hydrates an ANTIBIOTIC medication as the antibiotic row", () => {
const out = hydrateWizardPayload(
makeInitial({ category: "ANTIBIOTIC" }),
);
expect(out.treatmentRow).toBe("antibiotic");
});
it("hydrates a GLP-1 medication via treatmentClass priority", () => {
const out = hydrateWizardPayload(
makeInitial({ treatmentClass: "GLP1", category: "OTHER" }),
);
expect(out.treatmentRow).toBe("glp1");
});
it("hydrates a one-shot medication", () => {
const out = hydrateWizardPayload(
makeInitial({
oneShot: true,
endsOn: new Date(Date.UTC(2026, 0, 1)),
schedules: [
{
windowStart: "09:00",
windowEnd: "10:00",
timesOfDay: ["09:00"],
rrule: null,
rollingIntervalDays: null,
},
],
}),
);
expect(out.mode).toBe("oneShot");
expect(out.cadence.kind).toBe("oneShot");
});
it("hydrates a rolling-cadence schedule", () => {
const out = hydrateWizardPayload(
makeInitial({
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: null,
rollingIntervalDays: 7,
},
],
}),
);
expect(out.cadence.kind).toBe("rolling");
expect(out.subControls.rollingDays).toBe(7);
});
it("hydrates a legacy (daysOfWeek, intervalWeeks) schedule via the bridge", () => {
const out = hydrateWizardPayload(
makeInitial({
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: [3],
intervalWeeks: 2,
timesOfDay: [],
rrule: null,
rollingIntervalDays: null,
},
],
}),
);
expect(out.cadence.kind).toBe("everyNWeeks");
expect(out.subControls.weekdays).toEqual(["WE"]);
expect(out.subControls.intervalWeeks).toBe(2);
});
it("hydrates an everyNWeeks RRULE schedule with the interval picked up", () => {
const out = hydrateWizardPayload(
makeInitial({
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE",
rollingIntervalDays: null,
},
],
}),
);
expect(out.cadence.kind).toBe("everyNWeeks");
expect(out.subControls.weekdays).toEqual(["WE"]);
expect(out.subControls.intervalWeeks).toBe(2);
});
});