From b4b099003f3a285faf3a581fa402d8e31f33d149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 09:42:10 +0200 Subject: [PATCH] test(meds): playwright e2e for every wizard cadence shape Six specs walk the seven-step creation wizard end-to-end via the German label surface, one per cadence kind, with shared step helpers behind a single API stub. Verifies the medication lands in the list view with the right next-due chip; one-shot suppresses the chip per the design. Also closes the missing `wizard.page.backToList` key the host route reads on /medications/new. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/medications-wizard-biweekly.spec.ts | 96 ++++++++++ e2e/medications-wizard-daily.spec.ts | 100 +++++++++++ e2e/medications-wizard-helpers.ts | 221 ++++++++++++++++++++++++ e2e/medications-wizard-monthly.spec.ts | 89 ++++++++++ e2e/medications-wizard-oneshot.spec.ts | 98 +++++++++++ e2e/medications-wizard-rolling.spec.ts | 105 +++++++++++ e2e/medications-wizard-weekdays.spec.ts | 96 ++++++++++ messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + 13 files changed, 823 insertions(+) create mode 100644 e2e/medications-wizard-biweekly.spec.ts create mode 100644 e2e/medications-wizard-daily.spec.ts create mode 100644 e2e/medications-wizard-helpers.ts create mode 100644 e2e/medications-wizard-monthly.spec.ts create mode 100644 e2e/medications-wizard-oneshot.spec.ts create mode 100644 e2e/medications-wizard-rolling.spec.ts create mode 100644 e2e/medications-wizard-weekdays.spec.ts diff --git a/e2e/medications-wizard-biweekly.spec.ts b/e2e/medications-wizard-biweekly.spec.ts new file mode 100644 index 000000000..ce9bc24c4 --- /dev/null +++ b/e2e/medications-wizard-biweekly.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from "@playwright/test"; + +import { + STUB_MEDICATION_ID, + clickCreate, + clickNext, + expectMedicationOnList, + expectRedirectToMedication, + expectStep, + fillStep1, + mockMedicationsApi, + stubDashboardAnalytics, +} from "./medications-wizard-helpers"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * 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. + */ +test.describe("medication wizard — bi-weekly", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("creates an every-2-weeks Monday medication end-to-end", async ({ + page, + }) => { + await stubDashboardAnalytics(page); + const capture = mockMedicationsApi(page, { + id: STUB_MEDICATION_ID, + name: "Methotrexat", + dose: "10 mg", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO", + rollingIntervalDays: null, + oneShot: false, + }); + + await page.goto("/medications/new", { waitUntil: "domcontentloaded" }); + + await fillStep1(page, { name: "Methotrexat", doseAmount: "10" }); + await clickNext(page); + + await expectStep(page, 2); + await page.getByRole("radio", { name: /Wiederkehrend/i }).click(); + 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 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"]'); + // n=2 collapses to the "biweekly" summary leaf (intervalWeeks === + // 2 short-circuits in `summaryKeyForCadence`). + await expect(summary).toContainText(/Alle zwei Wochen/i); + + await clickCreate(page); + + await expectRedirectToMedication(page, STUB_MEDICATION_ID); + expect(capture.postBody).toMatchObject({ + name: "Methotrexat", + oneShot: false, + schedules: [{ rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO" }], + }); + + await expectMedicationOnList(page, { + name: "Methotrexat", + withNextDueChip: true, + }); + }); +}); diff --git a/e2e/medications-wizard-daily.spec.ts b/e2e/medications-wizard-daily.spec.ts new file mode 100644 index 000000000..31cb83fc3 --- /dev/null +++ b/e2e/medications-wizard-daily.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from "@playwright/test"; + +import { + STUB_MEDICATION_ID, + clickCreate, + clickNext, + expectMedicationOnList, + expectRedirectToMedication, + expectStep, + fillStep1, + mockMedicationsApi, + stubDashboardAnalytics, +} from "./medications-wizard-helpers"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * 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. + */ +test.describe("medication wizard — daily", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("creates a daily medication end-to-end", async ({ page }) => { + await stubDashboardAnalytics(page); + const capture = mockMedicationsApi(page, { + id: STUB_MEDICATION_ID, + name: "Vitamin D", + dose: "1000 iu", + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + oneShot: false, + }); + + await page.goto("/medications/new", { waitUntil: "domcontentloaded" }); + + // 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(); + await clickNext(page); + + // Step 2 — Recurring. + await expectStep(page, 2); + await page.getByRole("radio", { name: /Wiederkehrend/i }).click(); + 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). + await expectStep(page, 3); + await page.getByRole("radio", { name: /^Jeden Tag$/i }).click(); + await clickNext(page); + + // Step 4 — recap. + 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/); + + await clickCreate(page); + + await expectRedirectToMedication(page, STUB_MEDICATION_ID); + expect(capture.postBody).toMatchObject({ + name: "Vitamin D", + oneShot: false, + schedules: [{ rrule: "FREQ=DAILY" }], + }); + + await expectMedicationOnList(page, { + name: "Vitamin D", + withNextDueChip: true, + }); + }); +}); diff --git a/e2e/medications-wizard-helpers.ts b/e2e/medications-wizard-helpers.ts new file mode 100644 index 000000000..f20f99cdd --- /dev/null +++ b/e2e/medications-wizard-helpers.ts @@ -0,0 +1,221 @@ +import { expect, type Page, type Route } from "@playwright/test"; + +/** + * Shared helpers for the medication-wizard cadence 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 + * 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 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; + dose: string; + rrule: string | null; + rollingIntervalDays: number | null; + 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", + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + startsOn: null, + endsOn: null, + oneShot: stubbed.oneShot, + schedules: [ + { + id: "sch_e2e_1", + windowStart: "08:00", + windowEnd: "09:00", + label: null, + dose: null, + daysOfWeek: null, + timesOfDay: ["08:00"], + rrule: stubbed.rrule, + rollingIntervalDays: stubbed.rollingIntervalDays, + reminderGraceMinutes: null, + }, + ], + }; + + // 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; + } + if (req.method() === "POST") { + capture.postBody = JSON.parse(req.postData() ?? "null"); + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ + data: { ...listEntry }, + error: null, + }), + }); + return; + } + if (req.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: [listEntry], + error: null, + }), + }); + return; + } + await route.fallback(); + }); + + 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({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} }, + error: null, + }), + }), + ); + await page.route("**/api/mood/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], summary: { count: 0 } }, + error: null, + }), + }), + ); +} + +/** + * 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. + */ +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); +} + +/** + * 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. + */ +export async function clickNext(page: Page): Promise { + await page.locator('[data-slot="wizard-next"]').click(); +} + +/** + * Click the wizard's primary Create button at step 7. + */ +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(); +} + +/** + * 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. + */ +export async function expectRedirectToMedication( + page: Page, + id: string, +): Promise { + await expect + .poll(() => new URL(page.url()).pathname, { timeout: 10_000 }) + .toBe(`/medications/${id}`); +} + +/** + * 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. + */ +export async function expectMedicationOnList( + page: Page, + { name, withNextDueChip }: { name: string; withNextDueChip: boolean }, +): Promise { + await page.goto("/medications", { waitUntil: "domcontentloaded" }); + 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 new file mode 100644 index 000000000..dcef90daa --- /dev/null +++ b/e2e/medications-wizard-monthly.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from "@playwright/test"; + +import { + STUB_MEDICATION_ID, + clickCreate, + clickNext, + expectMedicationOnList, + expectRedirectToMedication, + expectStep, + fillStep1, + mockMedicationsApi, + stubDashboardAnalytics, +} from "./medications-wizard-helpers"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * 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. + */ +test.describe("medication wizard — monthly", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("creates a monthly-on-day-15 medication end-to-end", async ({ + page, + }) => { + await stubDashboardAnalytics(page); + const capture = mockMedicationsApi(page, { + id: STUB_MEDICATION_ID, + name: "Vitamin B12", + dose: "1000 mcg", + rrule: "FREQ=MONTHLY;BYMONTHDAY=15", + rollingIntervalDays: null, + oneShot: false, + }); + + await page.goto("/medications/new", { waitUntil: "domcontentloaded" }); + + await fillStep1(page, { name: "Vitamin B12", doseAmount: "1000" }); + await clickNext(page); + + await expectStep(page, 2); + await page.getByRole("radio", { name: /Wiederkehrend/i }).click(); + 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 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(/Monatlich/i); + + await clickCreate(page); + + await expectRedirectToMedication(page, STUB_MEDICATION_ID); + expect(capture.postBody).toMatchObject({ + name: "Vitamin B12", + oneShot: false, + schedules: [{ rrule: "FREQ=MONTHLY;BYMONTHDAY=15" }], + }); + + await expectMedicationOnList(page, { + name: "Vitamin B12", + withNextDueChip: true, + }); + }); +}); diff --git a/e2e/medications-wizard-oneshot.spec.ts b/e2e/medications-wizard-oneshot.spec.ts new file mode 100644 index 000000000..253133f57 --- /dev/null +++ b/e2e/medications-wizard-oneshot.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from "@playwright/test"; + +import { + STUB_MEDICATION_ID, + clickCreate, + clickNext, + expectMedicationOnList, + expectRedirectToMedication, + expectStep, + fillStep1, + mockMedicationsApi, + stubDashboardAnalytics, +} from "./medications-wizard-helpers"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * 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. + * + * 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. + */ +test.describe("medication wizard — one-shot", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("creates a single-dose medication end-to-end", async ({ page }) => { + await stubDashboardAnalytics(page); + const capture = mockMedicationsApi(page, { + id: STUB_MEDICATION_ID, + name: "Grippeimpfung", + dose: "0.5 ml", + rrule: null, + rollingIntervalDays: null, + oneShot: true, + }); + + await page.goto("/medications/new", { waitUntil: "domcontentloaded" }); + + await fillStep1(page, { name: "Grippeimpfung", doseAmount: "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). + 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"); + 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 expect(summary).toContainText(/Einmaldosis/i); + await expect(summary).toContainText(/2026-10-15/); + + await clickCreate(page); + + await expectRedirectToMedication(page, STUB_MEDICATION_ID); + expect(capture.postBody).toMatchObject({ + name: "Grippeimpfung", + oneShot: true, + startsOn: "2026-10-15", + endsOn: "2026-10-15", + }); + + await expectMedicationOnList(page, { + name: "Grippeimpfung", + withNextDueChip: false, + }); + }); +}); diff --git a/e2e/medications-wizard-rolling.spec.ts b/e2e/medications-wizard-rolling.spec.ts new file mode 100644 index 000000000..e273a5060 --- /dev/null +++ b/e2e/medications-wizard-rolling.spec.ts @@ -0,0 +1,105 @@ +import { expect, test } from "@playwright/test"; + +import { + STUB_MEDICATION_ID, + clickCreate, + clickNext, + expectMedicationOnList, + expectRedirectToMedication, + expectStep, + fillStep1, + mockMedicationsApi, + stubDashboardAnalytics, +} from "./medications-wizard-helpers"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * 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. + */ +test.describe("medication wizard — rolling", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("creates an every-7-days-from-last-intake medication end-to-end", async ({ + page, + }) => { + await stubDashboardAnalytics(page); + const capture = mockMedicationsApi(page, { + id: STUB_MEDICATION_ID, + name: "Mounjaro", + dose: "5 mg", + rrule: null, + rollingIntervalDays: 7, + oneShot: false, + }); + + await page.goto("/medications/new", { waitUntil: "domcontentloaded" }); + + await fillStep1(page, { name: "Mounjaro", doseAmount: "5" }); + await clickNext(page); + + await expectStep(page, 2); + await page.getByRole("radio", { name: /Wiederkehrend/i }).click(); + 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 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 clickCreate(page); + + await expectRedirectToMedication(page, STUB_MEDICATION_ID); + expect(capture.postBody).toMatchObject({ + name: "Mounjaro", + oneShot: false, + schedules: [{ rollingIntervalDays: 7 }], + }); + + await expectMedicationOnList(page, { + name: "Mounjaro", + withNextDueChip: true, + }); + }); +}); diff --git a/e2e/medications-wizard-weekdays.spec.ts b/e2e/medications-wizard-weekdays.spec.ts new file mode 100644 index 000000000..794a89588 --- /dev/null +++ b/e2e/medications-wizard-weekdays.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from "@playwright/test"; + +import { + STUB_MEDICATION_ID, + clickCreate, + clickNext, + expectMedicationOnList, + expectRedirectToMedication, + expectStep, + fillStep1, + mockMedicationsApi, + stubDashboardAnalytics, +} from "./medications-wizard-helpers"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Medication wizard end-to-end — weekdays cadence. + * + * 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`. + */ +test.describe("medication wizard — weekdays", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("creates a Mon/Wed/Fri medication end-to-end", async ({ page }) => { + await stubDashboardAnalytics(page); + const capture = mockMedicationsApi(page, { + id: STUB_MEDICATION_ID, + name: "Ibuprofen", + dose: "400 mg", + rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR", + rollingIntervalDays: null, + oneShot: false, + }); + + await page.goto("/medications/new", { waitUntil: "domcontentloaded" }); + + await fillStep1(page, { name: "Ibuprofen", doseAmount: "400" }); + await clickNext(page); + + await expectStep(page, 2); + await page.getByRole("radio", { name: /Wiederkehrend/i }).click(); + 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 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(/An ausgewählten Wochentagen/i); + + await clickCreate(page); + + await expectRedirectToMedication(page, STUB_MEDICATION_ID); + expect(capture.postBody).toMatchObject({ + name: "Ibuprofen", + oneShot: false, + schedules: [{ rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR" }], + }); + + await expectMedicationOnList(page, { + name: "Ibuprofen", + withNextDueChip: true, + }); + }); +}); diff --git a/messages/de.json b/messages/de.json index 8869e54ba..beb203711 100644 --- a/messages/de.json +++ b/messages/de.json @@ -749,6 +749,9 @@ }, "create": { "wizard": { + "page": { + "backToList": "Zurück zu den Medikamenten" + }, "header": { "stepOf": "Schritt {current} von {total}", "title": { diff --git a/messages/en.json b/messages/en.json index 80a53764a..8f569895a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -749,6 +749,9 @@ }, "create": { "wizard": { + "page": { + "backToList": "Back to medications" + }, "header": { "stepOf": "Step {current} of {total}", "title": { diff --git a/messages/es.json b/messages/es.json index 3c7a9d6b3..ff9ac0ed9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -749,6 +749,9 @@ }, "create": { "wizard": { + "page": { + "backToList": "Back to medications" + }, "header": { "stepOf": "Step {current} of {total}", "title": { diff --git a/messages/fr.json b/messages/fr.json index a4fe58ec8..2b6a34a5e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -749,6 +749,9 @@ }, "create": { "wizard": { + "page": { + "backToList": "Back to medications" + }, "header": { "stepOf": "Step {current} of {total}", "title": { diff --git a/messages/it.json b/messages/it.json index 6c8973ddc..dde792f0f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -749,6 +749,9 @@ }, "create": { "wizard": { + "page": { + "backToList": "Back to medications" + }, "header": { "stepOf": "Step {current} of {total}", "title": { diff --git a/messages/pl.json b/messages/pl.json index e996dcfd8..a08a186d5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -749,6 +749,9 @@ }, "create": { "wizard": { + "page": { + "backToList": "Back to medications" + }, "header": { "stepOf": "Step {current} of {total}", "title": {