mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
030e97906c
commit
b4b099003f
13 files changed
+823
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/<id>` 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/<id>/...) — 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<void> {
|
||||
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<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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function expectRedirectToMedication(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -749,6 +749,9 @@
|
||||
},
|
||||
"create": {
|
||||
"wizard": {
|
||||
"page": {
|
||||
"backToList": "Zurück zu den Medikamenten"
|
||||
},
|
||||
"header": {
|
||||
"stepOf": "Schritt {current} von {total}",
|
||||
"title": {
|
||||
|
||||
@@ -749,6 +749,9 @@
|
||||
},
|
||||
"create": {
|
||||
"wizard": {
|
||||
"page": {
|
||||
"backToList": "Back to medications"
|
||||
},
|
||||
"header": {
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"title": {
|
||||
|
||||
@@ -749,6 +749,9 @@
|
||||
},
|
||||
"create": {
|
||||
"wizard": {
|
||||
"page": {
|
||||
"backToList": "Back to medications"
|
||||
},
|
||||
"header": {
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"title": {
|
||||
|
||||
@@ -749,6 +749,9 @@
|
||||
},
|
||||
"create": {
|
||||
"wizard": {
|
||||
"page": {
|
||||
"backToList": "Back to medications"
|
||||
},
|
||||
"header": {
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"title": {
|
||||
|
||||
@@ -749,6 +749,9 @@
|
||||
},
|
||||
"create": {
|
||||
"wizard": {
|
||||
"page": {
|
||||
"backToList": "Back to medications"
|
||||
},
|
||||
"header": {
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"title": {
|
||||
|
||||
@@ -749,6 +749,9 @@
|
||||
},
|
||||
"create": {
|
||||
"wizard": {
|
||||
"page": {
|
||||
"backToList": "Back to medications"
|
||||
},
|
||||
"header": {
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"title": {
|
||||
|
||||
Reference in new issue
Block a user