From 320970c97da0364861bb9c79bb8f9c377b782151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 18:49:19 +0200 Subject: [PATCH] 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) --- docs/api/openapi.yaml | 8 +- e2e/medications-wizard-biweekly.spec.ts | 66 +- e2e/medications-wizard-daily.spec.ts | 83 +-- e2e/medications-wizard-helpers.ts | 164 ++--- e2e/medications-wizard-monthly.spec.ts | 51 +- e2e/medications-wizard-oneshot.spec.ts | 80 +-- e2e/medications-wizard-rolling.spec.ts | 82 ++- e2e/medications-wizard-weekdays.spec.ts | 76 +-- .../api/medications/__tests__/route.test.ts | 52 ++ .../wizard/__tests__/wizard-payload.test.ts | 578 ++++++++++++++++++ 10 files changed, 944 insertions(+), 296 deletions(-) create mode 100644 src/components/medications/wizard/__tests__/wizard-payload.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 96e7eebdd..2818f582c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/e2e/medications-wizard-biweekly.spec.ts b/e2e/medications-wizard-biweekly.spec.ts index ce9bc24c4..b4aebe942 100644 --- a/e2e/medications-wizard-biweekly.spec.ts +++ b/e2e/medications-wizard-biweekly.spec.ts @@ -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({ diff --git a/e2e/medications-wizard-daily.spec.ts b/e2e/medications-wizard-daily.spec.ts index 31cb83fc3..578ce5d89 100644 --- a/e2e/medications-wizard-daily.spec.ts +++ b/e2e/medications-wizard-daily.spec.ts @@ -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/` 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({ diff --git a/e2e/medications-wizard-helpers.ts b/e2e/medications-wizard-helpers.ts index f20f99cdd..a0386d431 100644 --- a/e2e/medications-wizard-helpers.ts +++ b/e2e/medications-wizard-helpers.ts @@ -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//...) — 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 { await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ @@ -146,47 +120,95 @@ export async function stubDashboardAnalytics(page: Page): Promise { } /** - * 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 { - 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 { + 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 { + 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 { + 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 { + 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 { + 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 { await page.locator('[data-slot="wizard-next"]').click(); } +/** Click Save (last step CTA on create + edit). */ +export async function clickSave(page: Page): Promise { + 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 { - 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 { - await expect( - page.getByText(new RegExp(`Schritt ${step} von 7`, "i")), - ).toBeVisible(); +export async function expectStep( + page: Page, + displayIndex: number, + totalSteps?: number, +): Promise { + 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/`. 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/` (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(); } } diff --git a/e2e/medications-wizard-monthly.spec.ts b/e2e/medications-wizard-monthly.spec.ts index dcef90daa..ea3ed848b 100644 --- a/e2e/medications-wizard-monthly.spec.ts +++ b/e2e/medications-wizard-monthly.spec.ts @@ -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({ diff --git a/e2e/medications-wizard-oneshot.spec.ts b/e2e/medications-wizard-oneshot.spec.ts index 253133f57..c90974342 100644 --- a/e2e/medications-wizard-oneshot.spec.ts +++ b/e2e/medications-wizard-oneshot.spec.ts @@ -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({ diff --git a/e2e/medications-wizard-rolling.spec.ts b/e2e/medications-wizard-rolling.spec.ts index e273a5060..a720476d3 100644 --- a/e2e/medications-wizard-rolling.spec.ts +++ b/e2e/medications-wizard-rolling.spec.ts @@ -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({ diff --git a/e2e/medications-wizard-weekdays.spec.ts b/e2e/medications-wizard-weekdays.spec.ts index 794a89588..913f2c101 100644 --- a/e2e/medications-wizard-weekdays.spec.ts +++ b/e2e/medications-wizard-weekdays.spec.ts @@ -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({ diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index f8c6983af..50011d07d 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts new file mode 100644 index 000000000..3caf5bd74 --- /dev/null +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -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 => { + if (!params) return key; + return `${key}(${JSON.stringify(params)})`; + }; +} + +function withCadence( + kind: CadenceKind, + sub: Partial = {}, +): 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 { + 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); + }); +});