mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
test(meds): coverage for the modal wizard
Component tests pin the path table, the step encoding, the treatment-class to category mapping, and the create-vs-edit prop branching. POST /api/medications gains pin-point coverage for the new DIABETES and ANTIBIOTIC clinical-category values. Six Playwright specs walk the new dialog end-to-end via the German label surface, one per cadence shape; helpers consolidate the dialog-open + step indicator assertions so each cadence spec only carries its own sub-control selectors. The OpenAPI yaml refreshes to surface the two new enum values to the iOS client. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
105fcee242
commit
320970c97d
10 files changed
+944
-296
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -2,26 +2,28 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
clickCreate,
|
||||
clickNext,
|
||||
clickSave,
|
||||
expectMedicationOnList,
|
||||
expectRedirectToMedication,
|
||||
expectStep,
|
||||
fillStep1,
|
||||
fillStep1Name,
|
||||
fillStep3Dose,
|
||||
mockMedicationsApi,
|
||||
openCreateWizard,
|
||||
pickCadenceRow,
|
||||
pickTreatmentRow,
|
||||
stubDashboardAnalytics,
|
||||
} from "./medications-wizard-helpers";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Medication wizard end-to-end — daily cadence.
|
||||
* v1.5.4 — Medication wizard end-to-end — daily cadence.
|
||||
*
|
||||
* Walks the seven-step `CreationWizard` from `/medications/new` using
|
||||
* the DE label surface from `messages/de.json`, picks "Jeden Tag" on
|
||||
* step 3, accepts the default 08:00 chip on step 5, accepts today as
|
||||
* startsOn on step 6, then submits. After the POST the spec asserts
|
||||
* the URL settled on `/medications/<id>` AND the medication appears on
|
||||
* `/medications` with the "Nächste Einnahme:" chip.
|
||||
* Walks the 7-step daily path through the modal dialog. The dialog
|
||||
* mounts when `/medications/new` redirects to `/medications?new=1`.
|
||||
* The DE label surface (Marc's primary locale) is the assertion
|
||||
* surface — "Schritt N von 7" tracks the visible counter.
|
||||
*/
|
||||
test.describe("medication wizard — daily", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
@@ -31,59 +33,58 @@ test.describe("medication wizard — daily", () => {
|
||||
const capture = mockMedicationsApi(page, {
|
||||
id: STUB_MEDICATION_ID,
|
||||
name: "Vitamin D",
|
||||
dose: "1000 iu",
|
||||
dose: "1000 I.E.",
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
oneShot: false,
|
||||
});
|
||||
|
||||
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
|
||||
await openCreateWizard(page);
|
||||
|
||||
// Step 1 — name + dose.
|
||||
await fillStep1(page, { name: "Vitamin D", doseAmount: "1000" });
|
||||
await page.locator("#wizard-dose-unit").click();
|
||||
await page.getByRole("option", { name: /I\.E\./i }).click();
|
||||
// Step 1 — name.
|
||||
await fillStep1Name(page, { name: "Vitamin D" });
|
||||
await clickNext(page);
|
||||
|
||||
// Step 2 — Recurring.
|
||||
// Step 2 — treatment class.
|
||||
await expectStep(page, 2);
|
||||
await page.getByRole("radio", { name: /Wiederkehrend/i }).click();
|
||||
await pickTreatmentRow(page, "vitamin");
|
||||
await clickNext(page);
|
||||
|
||||
// Step 3 — cadence: "Jeden Tag" (daily is the default but we click
|
||||
// it explicitly so the spec is robust to defaults drifting).
|
||||
// Step 3 — dose.
|
||||
await expectStep(page, 3);
|
||||
await page.getByRole("radio", { name: /^Jeden Tag$/i }).click();
|
||||
await fillStep3Dose(page, { amount: "1000" });
|
||||
await page.locator("#wizard-dose-unit").click();
|
||||
await page.getByRole("option", { name: /I\.E\./ }).click();
|
||||
await clickNext(page);
|
||||
|
||||
// Step 4 — recap.
|
||||
// Step 4 — course window (today by default).
|
||||
await expectStep(page, 4);
|
||||
await expect(
|
||||
page.locator('[data-slot="wizard-step4-recap"]'),
|
||||
).toContainText(/Jeden Tag/i);
|
||||
await clickNext(page);
|
||||
|
||||
// Step 5 — times of day (08:00 default chip).
|
||||
await expectStep(page, 5);
|
||||
await expect(
|
||||
page.locator('[data-slot="times-of-day-chip"][data-time="08:00"]'),
|
||||
).toBeVisible();
|
||||
await clickNext(page);
|
||||
|
||||
// Step 6 — course window (defaults to today, no end date).
|
||||
await expectStep(page, 6);
|
||||
await expect(
|
||||
page.locator('[data-slot="course-window-starts"]'),
|
||||
).toHaveValue(/^\d{4}-\d{2}-\d{2}$/);
|
||||
await clickNext(page);
|
||||
|
||||
// Step 7 — summary + create.
|
||||
await expectStep(page, 7);
|
||||
const summary = page.locator('[data-slot="wizard-step7-summary"]');
|
||||
await expect(summary).toContainText(/Jeden Tag/i);
|
||||
await expect(summary).toContainText(/08:00/);
|
||||
// Step 5 — cadence: "Täglich".
|
||||
await expectStep(page, 5, 8);
|
||||
await pickCadenceRow(page, "daily");
|
||||
// After picking daily, the path compresses to 7 steps; step 5
|
||||
// sits at slot 5 of 7.
|
||||
await expectStep(page, 5, 7);
|
||||
await clickNext(page);
|
||||
|
||||
await clickCreate(page);
|
||||
// Step 7 — times of day (08:00 default chip).
|
||||
await expectStep(page, 6, 7);
|
||||
await expect(
|
||||
page.locator('[data-slot="times-of-day-chip"][data-time="08:00"]'),
|
||||
).toBeVisible();
|
||||
await clickNext(page);
|
||||
|
||||
// Step 8 — summary + create.
|
||||
await expectStep(page, 7, 7);
|
||||
const summary = page.locator('[data-slot="wizard-summary"]');
|
||||
await expect(summary).toContainText(/Jeden Tag/i);
|
||||
|
||||
await clickSave(page);
|
||||
|
||||
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
|
||||
expect(capture.postBody).toMatchObject({
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import { expect, type Page, type Route } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Shared helpers for the medication-wizard cadence specs.
|
||||
* v1.5.4 — shared helpers for the modal medication-wizard specs.
|
||||
*
|
||||
* The six specs (daily / weekdays / biweekly / monthly / rolling /
|
||||
* oneshot) walk the same seven-step skeleton with different sub-step
|
||||
* inputs. The shared pieces — POST + GET stubs, list-page entry check,
|
||||
* step-indicator assertions, dashboard analytics stub — live here so
|
||||
* oneshot) walk the same dialog with different cadence-specific
|
||||
* inputs. The shared pieces — POST + GET stubs, list-page entry
|
||||
* check, dialog open + step-indicator assertions — live here so
|
||||
* the per-cadence specs only carry the cadence-specific selectors.
|
||||
*
|
||||
* No abstraction over the seven steps themselves: the cadence-specific
|
||||
* differences (which radio to pick, which sub-control to fill) are the
|
||||
* point of the spec, so the per-spec code expresses them directly.
|
||||
* The path counter follows the user's path:
|
||||
* one-shot = 5 steps (1, 2, 3, 4, 8)
|
||||
* daily = 7 steps (1, 2, 3, 4, 5, 7, 8)
|
||||
* recurring = 8 steps (1, 2, 3, 4, 5, 6, 7, 8)
|
||||
*
|
||||
* `expectStep(page, displayIndex, totalSteps)` pins the visible
|
||||
* "Schritt X von Y" string the dialog header renders.
|
||||
*/
|
||||
|
||||
/** The medication id the POST stub echoes back to the wizard. */
|
||||
export const STUB_MEDICATION_ID = "med_e2e_wizard";
|
||||
|
||||
/**
|
||||
* The minimum medication shape `/medications` page reads through
|
||||
* `MedicationCard`. The list reads `data: Medication[]` and the card
|
||||
* itself is forgiving about missing optional fields.
|
||||
*/
|
||||
interface StubbedMedication {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -31,26 +29,14 @@ interface StubbedMedication {
|
||||
oneShot: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock `/api/medications` for both the POST (creation) and the GET
|
||||
* (list refresh after the wizard redirects). Returns the captured
|
||||
* request body so each spec can assert against it independently.
|
||||
*
|
||||
* The list GET echoes the just-posted medication so the `/medications`
|
||||
* page paints a card the spec can pin against.
|
||||
*/
|
||||
export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
|
||||
const capture: { postBody: unknown } = { postBody: null };
|
||||
|
||||
// The list page reads the schedule shape too — surface the new
|
||||
// v1.5 fields the card consumes (rrule, rollingIntervalDays, oneShot,
|
||||
// timesOfDay). windowStart/windowEnd stay required by the card's
|
||||
// legacy fallback even when timesOfDay is populated.
|
||||
const listEntry = {
|
||||
id: stubbed.id,
|
||||
name: stubbed.name,
|
||||
dose: stubbed.dose,
|
||||
category: "MEDICATION",
|
||||
category: "OTHER",
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
@@ -74,15 +60,9 @@ export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
|
||||
],
|
||||
};
|
||||
|
||||
// The `**/api/medications*` glob matches the bare collection URL the
|
||||
// wizard posts to AND the list query the medications page issues.
|
||||
// Routes registered earlier win, so the list page never falls through
|
||||
// to a real server response.
|
||||
void page.route("**/api/medications**", async (route: Route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
// Sub-paths (e.g. /api/medications/<id>/...) — let them through
|
||||
// unhandled so the spec only stubs the surfaces it owns.
|
||||
if (url.pathname !== "/api/medications") {
|
||||
await route.fallback();
|
||||
return;
|
||||
@@ -116,12 +96,6 @@ export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
|
||||
return capture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the analytics fetches that fire on `/medications` so the page
|
||||
* paints without depending on the seed user's measurement state. The
|
||||
* `/api/analytics(?slice=…)` regex matches both the slim and thick
|
||||
* dashboard variants — same pattern the other specs use.
|
||||
*/
|
||||
export async function stubDashboardAnalytics(page: Page): Promise<void> {
|
||||
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
@@ -146,47 +120,95 @@ export async function stubDashboardAnalytics(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the Step 1 name + dose-amount inputs and confirm the step
|
||||
* indicator reads "Schritt 1 von 7" before we leave it. The dose unit
|
||||
* already defaults to "mg" via `emptyWizardPayload()` so we leave it
|
||||
* alone unless the caller wants something else.
|
||||
* Open the wizard dialog by hitting `/medications/new` (which
|
||||
* redirects to `/medications?new=1` and opens the create dialog).
|
||||
*/
|
||||
export async function fillStep1(
|
||||
page: Page,
|
||||
{ name, doseAmount }: { name: string; doseAmount: string },
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(/Schritt 1 von 7/i)).toBeVisible();
|
||||
await page.locator("#wizard-name").fill(name);
|
||||
await page.locator("#wizard-dose-amount").fill(doseAmount);
|
||||
export async function openCreateWizard(page: Page): Promise<void> {
|
||||
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="medication-wizard-dialog"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the wizard's primary Next button. Pinned by data-slot so we do
|
||||
* not collide with any other "Weiter" / "Next" labelled control the
|
||||
* page chrome may render.
|
||||
* Fill the Step 1 name input. The dialog's first step asks only for
|
||||
* the medication name — the dose moved to Step 3.
|
||||
*/
|
||||
export async function fillStep1Name(
|
||||
page: Page,
|
||||
{ name }: { name: string },
|
||||
): Promise<void> {
|
||||
await expectStep(page, 1);
|
||||
await page.locator("#wizard-name").fill(name);
|
||||
}
|
||||
|
||||
/** Pick a Step 2 treatment-class row by its row identifier. */
|
||||
export async function pickTreatmentRow(
|
||||
page: Page,
|
||||
row:
|
||||
| "bloodPressure"
|
||||
| "diabetes"
|
||||
| "hormone"
|
||||
| "glp1"
|
||||
| "painRelief"
|
||||
| "allergy"
|
||||
| "vitamin"
|
||||
| "supplement"
|
||||
| "antibiotic"
|
||||
| "other",
|
||||
): Promise<void> {
|
||||
await page
|
||||
.locator(`[data-slot="wizard-class-row"][data-row="${row}"]`)
|
||||
.click();
|
||||
}
|
||||
|
||||
/** Fill the Step 3 dose amount input. */
|
||||
export async function fillStep3Dose(
|
||||
page: Page,
|
||||
{ amount }: { amount: string },
|
||||
): Promise<void> {
|
||||
await page.locator("#wizard-dose-amount").fill(amount);
|
||||
}
|
||||
|
||||
/** Pick a Step 5 cadence row by its row identifier. */
|
||||
export async function pickCadenceRow(
|
||||
page: Page,
|
||||
row: "daily" | "weekdays" | "everyNWeeks" | "monthly" | "rolling" | "oneShot",
|
||||
): Promise<void> {
|
||||
await page
|
||||
.locator(`[data-slot="wizard-cadence-row"][data-row="${row}"]`)
|
||||
.click();
|
||||
}
|
||||
|
||||
/** Click Next. Pinned by data-slot. */
|
||||
export async function clickNext(page: Page): Promise<void> {
|
||||
await page.locator('[data-slot="wizard-next"]').click();
|
||||
}
|
||||
|
||||
/** Click Save (last step CTA on create + edit). */
|
||||
export async function clickSave(page: Page): Promise<void> {
|
||||
await page.locator('[data-slot="wizard-save"]').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the wizard's primary Create button at step 7.
|
||||
* Assert the wizard sits on a given visible step counter.
|
||||
* `displayIndex` is the visible 1-based index; `totalSteps` is the
|
||||
* path length for the active mode + cadence.
|
||||
*/
|
||||
export async function clickCreate(page: Page): Promise<void> {
|
||||
await page.locator('[data-slot="wizard-create"]').click();
|
||||
}
|
||||
|
||||
/** Wait for the wizard to land on a given step (1-7). */
|
||||
export async function expectStep(page: Page, step: number): Promise<void> {
|
||||
await expect(
|
||||
page.getByText(new RegExp(`Schritt ${step} von 7`, "i")),
|
||||
).toBeVisible();
|
||||
export async function expectStep(
|
||||
page: Page,
|
||||
displayIndex: number,
|
||||
totalSteps?: number,
|
||||
): Promise<void> {
|
||||
const pattern = totalSteps
|
||||
? new RegExp(`Schritt ${displayIndex} von ${totalSteps}`, "i")
|
||||
: new RegExp(`Schritt ${displayIndex} von \\d+`, "i");
|
||||
await expect(page.getByText(pattern)).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the post-submit URL settles on `/medications/<id>`. Next.js
|
||||
* 404s for an unknown id which renders the `not-found.tsx` boundary
|
||||
* cleanly; the URL is the contract we care about, not the page body.
|
||||
* Verify the post-submit URL settles on `/medications/<id>` (create
|
||||
* path; the dialog navigates there on success).
|
||||
*/
|
||||
export async function expectRedirectToMedication(
|
||||
page: Page,
|
||||
@@ -198,11 +220,9 @@ export async function expectRedirectToMedication(
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the list and assert the just-created medication appears
|
||||
* with the "Nächste Einnahme:" next-due chip (`medications.nextIntake`
|
||||
* leaf in `messages/de.json`). The one-shot variant uses a different
|
||||
* surface — the list still shows the medication name; we only assert
|
||||
* on the chip for recurring cadences.
|
||||
* Navigate to the list and assert the just-created medication
|
||||
* appears. The recurring variants surface a "Nächste Einnahme:" chip;
|
||||
* the one-shot variant does not.
|
||||
*/
|
||||
export async function expectMedicationOnList(
|
||||
page: Page,
|
||||
@@ -212,10 +232,6 @@ export async function expectMedicationOnList(
|
||||
const main = page.locator("main");
|
||||
await expect(main.getByText(name).first()).toBeVisible({ timeout: 10_000 });
|
||||
if (withNextDueChip) {
|
||||
// "Nächste Einnahme:" with the trailing colon is the literal label
|
||||
// surfaced by `medications.nextIntake`. The card renders the chip
|
||||
// when the medication is active AND has a next schedule slot, both
|
||||
// true for the stubbed entry.
|
||||
await expect(main.getByText(/Nächste Einnahme:/i).first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -2,29 +2,33 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
clickCreate,
|
||||
clickNext,
|
||||
clickSave,
|
||||
expectMedicationOnList,
|
||||
expectRedirectToMedication,
|
||||
expectStep,
|
||||
fillStep1,
|
||||
fillStep1Name,
|
||||
fillStep3Dose,
|
||||
mockMedicationsApi,
|
||||
openCreateWizard,
|
||||
pickCadenceRow,
|
||||
pickTreatmentRow,
|
||||
stubDashboardAnalytics,
|
||||
} from "./medications-wizard-helpers";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Medication wizard end-to-end — weekdays cadence.
|
||||
* v1.5.4 — Medication wizard end-to-end — specific weekdays.
|
||||
*
|
||||
* Walks the seven-step wizard via the DE label surface and picks
|
||||
* "An bestimmten Wochentagen" on step 3, toggles Mo + Mi + Fr on the
|
||||
* weekday chip-row, then completes the rest of the flow. The emitted
|
||||
* rrule is `FREQ=WEEKLY;BYDAY=MO,WE,FR`.
|
||||
* Walks the 8-step recurring path through the modal dialog with
|
||||
* "Bestimmte Wochentage" picked on Step 5, then Mo / Mi / Fr toggled
|
||||
* on the weekday chips of Step 6. The emitted rrule is
|
||||
* `FREQ=WEEKLY;BYDAY=MO,WE,FR`.
|
||||
*/
|
||||
test.describe("medication wizard — weekdays", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("creates a Mon/Wed/Fri medication end-to-end", async ({ page }) => {
|
||||
test("creates a Mo/Mi/Fr medication end-to-end", async ({ page }) => {
|
||||
await stubDashboardAnalytics(page);
|
||||
const capture = mockMedicationsApi(page, {
|
||||
id: STUB_MEDICATION_ID,
|
||||
@@ -35,51 +39,51 @@ test.describe("medication wizard — weekdays", () => {
|
||||
oneShot: false,
|
||||
});
|
||||
|
||||
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
|
||||
await openCreateWizard(page);
|
||||
|
||||
await fillStep1(page, { name: "Ibuprofen", doseAmount: "400" });
|
||||
await fillStep1Name(page, { name: "Ibuprofen" });
|
||||
await clickNext(page);
|
||||
|
||||
await expectStep(page, 2);
|
||||
await page.getByRole("radio", { name: /Wiederkehrend/i }).click();
|
||||
await pickTreatmentRow(page, "painRelief");
|
||||
await clickNext(page);
|
||||
|
||||
await expectStep(page, 3);
|
||||
await page
|
||||
.getByRole("radio", { name: /An bestimmten Wochentagen/i })
|
||||
.click();
|
||||
|
||||
// Toggle Mo + Mi + Fr — `aria-label` uses the long DE name.
|
||||
await page
|
||||
.getByRole("button", { name: /^Montag$/i })
|
||||
.click();
|
||||
// The "weekdays" default is [MO] so Monday may already be active;
|
||||
// re-toggle if so. Cheaper than peeking at aria-pressed: just lean
|
||||
// on the data-active attribute.
|
||||
const moChip = page.locator(
|
||||
'[data-slot="cadence-weekday-chip"][data-token="MO"]',
|
||||
);
|
||||
if ((await moChip.getAttribute("data-active")) === "false") {
|
||||
await moChip.click();
|
||||
}
|
||||
await page.getByRole("button", { name: /^Mittwoch$/i }).click();
|
||||
await page.getByRole("button", { name: /^Freitag$/i }).click();
|
||||
await fillStep3Dose(page, { amount: "400" });
|
||||
await clickNext(page);
|
||||
|
||||
await expectStep(page, 4);
|
||||
await clickNext(page);
|
||||
|
||||
await expectStep(page, 5);
|
||||
await expectStep(page, 5, 8);
|
||||
await pickCadenceRow(page, "weekdays");
|
||||
await clickNext(page);
|
||||
|
||||
await expectStep(page, 6);
|
||||
await expectStep(page, 6, 8);
|
||||
// The weekday default may already be [MO]; re-tick to make the
|
||||
// selection deterministic for the assertion below.
|
||||
const moChip = page.locator(
|
||||
'[data-slot="wizard-weekday-chip"][data-token="MO"]',
|
||||
);
|
||||
if ((await moChip.getAttribute("data-active")) === "false") {
|
||||
await moChip.click();
|
||||
}
|
||||
await page
|
||||
.locator('[data-slot="wizard-weekday-chip"][data-token="WE"]')
|
||||
.click();
|
||||
await page
|
||||
.locator('[data-slot="wizard-weekday-chip"][data-token="FR"]')
|
||||
.click();
|
||||
await clickNext(page);
|
||||
|
||||
await expectStep(page, 7);
|
||||
const summary = page.locator('[data-slot="wizard-step7-summary"]');
|
||||
await expect(summary).toContainText(/An ausgewählten Wochentagen/i);
|
||||
await expectStep(page, 7, 8);
|
||||
await clickNext(page);
|
||||
|
||||
await clickCreate(page);
|
||||
await expectStep(page, 8, 8);
|
||||
const summary = page.locator('[data-slot="wizard-summary"]');
|
||||
await expect(summary).toContainText(/Wochentagen/i);
|
||||
|
||||
await clickSave(page);
|
||||
|
||||
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
|
||||
expect(capture.postBody).toMatchObject({
|
||||
|
||||
@@ -298,3 +298,55 @@ describe("POST /api/medications — v1.5 scheduling primitives", () => {
|
||||
expect(s.timesOfDay).toEqual(["08:00"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications — v1.5.4 clinical category extension", () => {
|
||||
/**
|
||||
* The modal wizard's Step 2 taxonomy adds Diabetes and Antibiotikum
|
||||
* as first-class rows. The mapping table writes `category:
|
||||
* "DIABETES"` and `category: "ANTIBIOTIC"` to the side-table for
|
||||
* those rows instead of collapsing them into `"OTHER"`.
|
||||
*
|
||||
* The Zod schema accepts the new values; the route's
|
||||
* `setMedicationCategory` helper normalises them through
|
||||
* `MEDICATION_CATEGORY_VALUES`. The tests below pin that the route
|
||||
* accepts both new values without 422-ing.
|
||||
*/
|
||||
|
||||
it("accepts DIABETES as a valid clinical category", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "Metformin",
|
||||
dose: "500 mg",
|
||||
category: "DIABETES",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("accepts ANTIBIOTIC as a valid clinical category", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "Amoxicillin",
|
||||
dose: "500 mg",
|
||||
category: "ANTIBIOTIC",
|
||||
oneShot: true,
|
||||
startsOn: "2026-06-01",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "08:30" }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("still 422s on an unknown category string", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "Random",
|
||||
dose: "1 tab",
|
||||
category: "MYSTERY",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,578 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCreateBody,
|
||||
emptyWizardPayload,
|
||||
hydrateWizardPayload,
|
||||
type MedicationPayload,
|
||||
progressIndices,
|
||||
rowFromTreatment,
|
||||
summariseCadence,
|
||||
validateStep,
|
||||
type WizardPayload,
|
||||
WIZARD_TREATMENT_MAPPING,
|
||||
type WizardTreatmentRow,
|
||||
} from "@/components/medications/wizard/wizard-payload";
|
||||
import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
|
||||
import {
|
||||
type CadenceKind,
|
||||
DEFAULT_SUB_CONTROLS,
|
||||
} from "@/components/medications/scheduling/types";
|
||||
|
||||
/**
|
||||
* v1.5.4 — wizard payload pure-helper tests.
|
||||
*
|
||||
* Project convention is SSR-only Vitest (no `@testing-library/react`).
|
||||
* The interactive dialog is covered by Playwright; the pure helpers
|
||||
* here carry the unit-test contract:
|
||||
*
|
||||
* - `validateStep` — per-step gate boolean.
|
||||
* - `progressIndices` — path table for the visible counter.
|
||||
* - `buildCreateBody` — POST/PUT request body encoder, incl. the
|
||||
* Step 2 → (treatmentClass, category) mapping.
|
||||
* - `summariseCadence` — Step 8 plain-language summary.
|
||||
* - `hydrateWizardPayload` — edit-path hydration, esp. the legacy
|
||||
* `(daysOfWeek, intervalWeeks)` bridge.
|
||||
*/
|
||||
|
||||
function makeStubT() {
|
||||
return (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
): string => {
|
||||
if (!params) return key;
|
||||
return `${key}(${JSON.stringify(params)})`;
|
||||
};
|
||||
}
|
||||
|
||||
function withCadence(
|
||||
kind: CadenceKind,
|
||||
sub: Partial<typeof DEFAULT_SUB_CONTROLS> = {},
|
||||
): WizardPayload {
|
||||
const subControls = { ...DEFAULT_SUB_CONTROLS, ...sub };
|
||||
return {
|
||||
...emptyWizardPayload(),
|
||||
mode: kind === "oneShot" ? "oneShot" : "recurring",
|
||||
treatmentRow: "other",
|
||||
name: "Foo",
|
||||
doseAmount: "5",
|
||||
doseUnit: "mg",
|
||||
cadence: encodeCadence(kind, subControls),
|
||||
subControls,
|
||||
};
|
||||
}
|
||||
|
||||
describe("progressIndices", () => {
|
||||
it("returns the 8-step recurring path before the user picks a mode", () => {
|
||||
expect(progressIndices(null, "daily")).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
});
|
||||
|
||||
it("returns the 5-step one-shot path for oneShot mode", () => {
|
||||
expect(progressIndices("oneShot", "oneShot")).toEqual([1, 2, 3, 4, 8]);
|
||||
});
|
||||
|
||||
it("returns the 7-step daily path when recurring + daily cadence", () => {
|
||||
expect(progressIndices("recurring", "daily")).toEqual([
|
||||
1, 2, 3, 4, 5, 7, 8,
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the 8-step recurring path for non-daily cadences", () => {
|
||||
for (const kind of [
|
||||
"weekdays",
|
||||
"everyNWeeks",
|
||||
"monthly",
|
||||
"rolling",
|
||||
] as const) {
|
||||
expect(progressIndices("recurring", kind)).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateStep", () => {
|
||||
it("step 1: rejects empty name; accepts populated", () => {
|
||||
const base = emptyWizardPayload();
|
||||
expect(validateStep(base, 1)).toBe(false);
|
||||
expect(validateStep({ ...base, name: "Foo" }, 1)).toBe(true);
|
||||
expect(validateStep({ ...base, name: " " }, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("step 2: requires a treatment row", () => {
|
||||
const base = emptyWizardPayload();
|
||||
expect(validateStep(base, 2)).toBe(false);
|
||||
expect(
|
||||
validateStep({ ...base, treatmentRow: "bloodPressure" }, 2),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("step 3: requires populated dose amount", () => {
|
||||
const base = emptyWizardPayload();
|
||||
expect(validateStep(base, 3)).toBe(false);
|
||||
expect(validateStep({ ...base, doseAmount: "5" }, 3)).toBe(true);
|
||||
});
|
||||
|
||||
it("step 4: rejects null startsOn", () => {
|
||||
const base = emptyWizardPayload();
|
||||
expect(validateStep({ ...base, startsOn: null }, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("step 4: rejects endsOn before startsOn", () => {
|
||||
const base = emptyWizardPayload();
|
||||
const start = new Date(Date.UTC(2026, 5, 1));
|
||||
const earlier = new Date(Date.UTC(2026, 4, 1));
|
||||
expect(
|
||||
validateStep({ ...base, startsOn: start, endsOn: earlier }, 4),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("step 4: accepts null endsOn (chronic)", () => {
|
||||
const base = emptyWizardPayload();
|
||||
expect(
|
||||
validateStep(
|
||||
{ ...base, startsOn: new Date(Date.UTC(2026, 5, 1)), endsOn: null },
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("step 5: requires a mode pick", () => {
|
||||
const base = emptyWizardPayload();
|
||||
expect(validateStep(base, 5)).toBe(false);
|
||||
expect(validateStep({ ...base, mode: "recurring" }, 5)).toBe(true);
|
||||
expect(validateStep({ ...base, mode: "oneShot" }, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it("step 6: weekdays requires >= 1 chip", () => {
|
||||
expect(
|
||||
validateStep(withCadence("weekdays", { weekdays: [] }), 6),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateStep(withCadence("weekdays", { weekdays: ["MO"] }), 6),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("step 6: monthly accepts day-of-month 1..31", () => {
|
||||
expect(
|
||||
validateStep(withCadence("monthly", { dayOfMonth: 1 }), 6),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateStep(withCadence("monthly", { dayOfMonth: 31 }), 6),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("step 6: rolling accepts 1..365 days", () => {
|
||||
expect(
|
||||
validateStep(withCadence("rolling", { rollingDays: 1 }), 6),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateStep(withCadence("rolling", { rollingDays: 365 }), 6),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("step 7: requires >= 1 valid HH:mm time", () => {
|
||||
const base = withCadence("daily");
|
||||
expect(validateStep({ ...base, timesOfDay: [] }, 7)).toBe(false);
|
||||
expect(validateStep({ ...base, timesOfDay: ["nope"] }, 7)).toBe(false);
|
||||
expect(validateStep({ ...base, timesOfDay: ["08:00"] }, 7)).toBe(true);
|
||||
});
|
||||
|
||||
it("step 8: mirrors step 4 (course-window range)", () => {
|
||||
const base = withCadence("daily");
|
||||
expect(validateStep({ ...base, startsOn: null }, 8)).toBe(false);
|
||||
const start = new Date(Date.UTC(2026, 5, 1));
|
||||
expect(
|
||||
validateStep({ ...base, startsOn: start, endsOn: null }, 8),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => {
|
||||
it("maps Blutdruck → (GENERIC, BLOOD_PRESSURE)", () => {
|
||||
expect(WIZARD_TREATMENT_MAPPING.bloodPressure).toEqual({
|
||||
treatmentClass: "GENERIC",
|
||||
category: "BLOOD_PRESSURE",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Diabetes → (GENERIC, DIABETES) — new in v1.5.4", () => {
|
||||
expect(WIZARD_TREATMENT_MAPPING.diabetes).toEqual({
|
||||
treatmentClass: "GENERIC",
|
||||
category: "DIABETES",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Antibiotikum → (GENERIC, ANTIBIOTIC) — new in v1.5.4", () => {
|
||||
expect(WIZARD_TREATMENT_MAPPING.antibiotic).toEqual({
|
||||
treatmentClass: "GENERIC",
|
||||
category: "ANTIBIOTIC",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps GLP-1-Injektion → (GLP1, OTHER) — only row with GLP1", () => {
|
||||
expect(WIZARD_TREATMENT_MAPPING.glp1).toEqual({
|
||||
treatmentClass: "GLP1",
|
||||
category: "OTHER",
|
||||
});
|
||||
});
|
||||
|
||||
it("every row picks GENERIC except glp1", () => {
|
||||
const rows: WizardTreatmentRow[] = [
|
||||
"bloodPressure",
|
||||
"diabetes",
|
||||
"hormone",
|
||||
"painRelief",
|
||||
"allergy",
|
||||
"vitamin",
|
||||
"supplement",
|
||||
"antibiotic",
|
||||
"other",
|
||||
];
|
||||
for (const row of rows) {
|
||||
expect(WIZARD_TREATMENT_MAPPING[row].treatmentClass).toBe("GENERIC");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rowFromTreatment — reverse mapping for edit-hydration", () => {
|
||||
it("GLP1 treatment class wins over category", () => {
|
||||
expect(rowFromTreatment("GLP1", "OTHER")).toBe("glp1");
|
||||
expect(rowFromTreatment("GLP1", "BLOOD_PRESSURE")).toBe("glp1");
|
||||
});
|
||||
|
||||
it("DIABETES category maps to the diabetes row", () => {
|
||||
expect(rowFromTreatment("GENERIC", "DIABETES")).toBe("diabetes");
|
||||
});
|
||||
|
||||
it("ANTIBIOTIC category maps to the antibiotic row", () => {
|
||||
expect(rowFromTreatment("GENERIC", "ANTIBIOTIC")).toBe("antibiotic");
|
||||
});
|
||||
|
||||
it("unknown category falls back to 'other'", () => {
|
||||
expect(rowFromTreatment("GENERIC", "MYSTERY")).toBe("other");
|
||||
expect(rowFromTreatment(undefined, undefined)).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCreateBody", () => {
|
||||
it("emits a daily recurring schedule with the rrule slot", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
treatmentRow: "bloodPressure",
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
};
|
||||
const body = buildCreateBody(p);
|
||||
expect(body.name).toBe("Foo");
|
||||
expect(body.dose).toBe("5 mg");
|
||||
expect(body.oneShot).toBe(false);
|
||||
expect(body.treatmentClass).toBe("GENERIC");
|
||||
expect(body.category).toBe("BLOOD_PRESSURE");
|
||||
expect(body.notificationsEnabled).toBe(true);
|
||||
expect(body.startsOn).toBe("2026-05-28");
|
||||
expect(body.endsOn).toBeUndefined();
|
||||
expect(body.schedules).toHaveLength(1);
|
||||
expect(body.schedules[0].rrule).toBe("FREQ=DAILY");
|
||||
expect(body.schedules[0].timesOfDay).toEqual(["08:00"]);
|
||||
expect(body.schedules[0].windowStart).toBe("08:00");
|
||||
expect(body.schedules[0].windowEnd).toBe("09:00");
|
||||
});
|
||||
|
||||
it("emits DIABETES category for the diabetes row", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
treatmentRow: "diabetes",
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
};
|
||||
expect(buildCreateBody(p).category).toBe("DIABETES");
|
||||
expect(buildCreateBody(p).treatmentClass).toBe("GENERIC");
|
||||
});
|
||||
|
||||
it("emits ANTIBIOTIC category for the antibiotic row", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("oneShot"),
|
||||
treatmentRow: "antibiotic",
|
||||
timesOfDay: ["09:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
};
|
||||
expect(buildCreateBody(p).category).toBe("ANTIBIOTIC");
|
||||
});
|
||||
|
||||
it("emits the GLP1 treatmentClass for the glp1 row", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("everyNWeeks", {
|
||||
intervalWeeks: 1,
|
||||
weekdays: ["WE"],
|
||||
}),
|
||||
treatmentRow: "glp1",
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
};
|
||||
const body = buildCreateBody(p);
|
||||
expect(body.treatmentClass).toBe("GLP1");
|
||||
expect(body.category).toBe("OTHER");
|
||||
});
|
||||
|
||||
it("emits bi-weekly RRULE for everyNWeeks (n=2, Wednesday)", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("everyNWeeks", {
|
||||
intervalWeeks: 2,
|
||||
weekdays: ["WE"],
|
||||
}),
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
};
|
||||
const body = buildCreateBody(p);
|
||||
expect(body.schedules[0].rrule).toBe("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE");
|
||||
expect(body.schedules[0].rollingIntervalDays).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits monthly RRULE with BYMONTHDAY", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("monthly", { dayOfMonth: 15 }),
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
};
|
||||
const body = buildCreateBody(p);
|
||||
expect(body.schedules[0].rrule).toBe("FREQ=MONTHLY;BYMONTHDAY=15");
|
||||
});
|
||||
|
||||
it("emits rollingIntervalDays instead of rrule for rolling", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("rolling", { rollingDays: 7 }),
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
};
|
||||
const body = buildCreateBody(p);
|
||||
expect(body.schedules[0].rrule).toBeUndefined();
|
||||
expect(body.schedules[0].rollingIntervalDays).toBe(7);
|
||||
});
|
||||
|
||||
it("emits oneShot=true with no recurrence and endsOn = startsOn", () => {
|
||||
const start = new Date(Date.UTC(2026, 9, 15));
|
||||
const p: WizardPayload = {
|
||||
...withCadence("oneShot"),
|
||||
timesOfDay: ["09:00"],
|
||||
startsOn: start,
|
||||
endsOn: start,
|
||||
};
|
||||
const body = buildCreateBody(p);
|
||||
expect(body.oneShot).toBe(true);
|
||||
expect(body.startsOn).toBe("2026-10-15");
|
||||
expect(body.endsOn).toBe("2026-10-15");
|
||||
expect(body.schedules[0].rrule).toBeUndefined();
|
||||
expect(body.schedules[0].rollingIntervalDays).toBeUndefined();
|
||||
});
|
||||
|
||||
it("threads notificationsEnabled through", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
notificationsEnabled: false,
|
||||
timesOfDay: ["08:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
};
|
||||
expect(buildCreateBody(p).notificationsEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseCadence", () => {
|
||||
const t = makeStubT();
|
||||
|
||||
it("uses 'daily' for the daily cadence", () => {
|
||||
const out = summariseCadence(withCadence("daily"), t);
|
||||
expect(out).toContain("medications.wizard.summary.cadence.daily");
|
||||
});
|
||||
|
||||
it("uses 'biweekly' for everyNWeeks with n=2", () => {
|
||||
const out = summariseCadence(
|
||||
withCadence("everyNWeeks", { intervalWeeks: 2 }),
|
||||
t,
|
||||
);
|
||||
expect(out).toContain("medications.wizard.summary.cadence.biweekly");
|
||||
});
|
||||
|
||||
it("uses 'rolling' and threads the day count", () => {
|
||||
const p = withCadence("rolling", { rollingDays: 14 });
|
||||
const out = summariseCadence(p, t);
|
||||
expect(out).toMatch(/cadence\.rolling[^|]*"n":14/);
|
||||
});
|
||||
|
||||
it("uses 'oneShot' and omits the times / endsOn phrases", () => {
|
||||
const start = new Date(Date.UTC(2026, 9, 15));
|
||||
const p: WizardPayload = {
|
||||
...withCadence("oneShot"),
|
||||
startsOn: start,
|
||||
endsOn: start,
|
||||
};
|
||||
const out = summariseCadence(p, t);
|
||||
expect(out).toContain("medications.wizard.summary.cadence.oneShot");
|
||||
expect(out).not.toContain("summary.endsOn");
|
||||
expect(out).not.toContain("summary.times");
|
||||
});
|
||||
|
||||
it("surfaces 'noEndDate' when recurring + endsOn null", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
};
|
||||
expect(summariseCadence(p, t)).toContain(
|
||||
"medications.wizard.summary.noEndDate",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends a weekdays-detail phrase for the weekday cadence", () => {
|
||||
const p = withCadence("weekdays", { weekdays: ["MO", "WE", "FR"] });
|
||||
const out = summariseCadence(p, t);
|
||||
expect(out).toContain("medications.wizard.summary.weekdaysDetail");
|
||||
});
|
||||
|
||||
it("appends day-of-month detail for the monthly cadence", () => {
|
||||
const p = withCadence("monthly", { dayOfMonth: 15 });
|
||||
const out = summariseCadence(p, t);
|
||||
expect(out).toContain("medications.wizard.summary.dayOfMonthDetail");
|
||||
expect(out).toMatch(/dayOfMonthDetail[^|]*"day":15/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateWizardPayload", () => {
|
||||
function makeInitial(
|
||||
overrides: Partial<MedicationPayload> = {},
|
||||
): MedicationPayload {
|
||||
return {
|
||||
id: "med_1",
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
category: "BLOOD_PRESSURE",
|
||||
treatmentClass: "GENERIC",
|
||||
notificationsEnabled: true,
|
||||
startsOn: new Date(Date.UTC(2026, 0, 1)),
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("hydrates Ramipril / daily / BP as the blood-pressure row", () => {
|
||||
const out = hydrateWizardPayload(makeInitial());
|
||||
expect(out.name).toBe("Ramipril");
|
||||
expect(out.doseAmount).toBe("5");
|
||||
expect(out.doseUnit).toBe("mg");
|
||||
expect(out.treatmentRow).toBe("bloodPressure");
|
||||
expect(out.mode).toBe("recurring");
|
||||
expect(out.cadence.kind).toBe("daily");
|
||||
expect(out.timesOfDay).toEqual(["08:00"]);
|
||||
});
|
||||
|
||||
it("hydrates a DIABETES medication as the diabetes row", () => {
|
||||
const out = hydrateWizardPayload(
|
||||
makeInitial({ category: "DIABETES" }),
|
||||
);
|
||||
expect(out.treatmentRow).toBe("diabetes");
|
||||
});
|
||||
|
||||
it("hydrates an ANTIBIOTIC medication as the antibiotic row", () => {
|
||||
const out = hydrateWizardPayload(
|
||||
makeInitial({ category: "ANTIBIOTIC" }),
|
||||
);
|
||||
expect(out.treatmentRow).toBe("antibiotic");
|
||||
});
|
||||
|
||||
it("hydrates a GLP-1 medication via treatmentClass priority", () => {
|
||||
const out = hydrateWizardPayload(
|
||||
makeInitial({ treatmentClass: "GLP1", category: "OTHER" }),
|
||||
);
|
||||
expect(out.treatmentRow).toBe("glp1");
|
||||
});
|
||||
|
||||
it("hydrates a one-shot medication", () => {
|
||||
const out = hydrateWizardPayload(
|
||||
makeInitial({
|
||||
oneShot: true,
|
||||
endsOn: new Date(Date.UTC(2026, 0, 1)),
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "09:00",
|
||||
windowEnd: "10:00",
|
||||
timesOfDay: ["09:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.mode).toBe("oneShot");
|
||||
expect(out.cadence.kind).toBe("oneShot");
|
||||
});
|
||||
|
||||
it("hydrates a rolling-cadence schedule", () => {
|
||||
const out = hydrateWizardPayload(
|
||||
makeInitial({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: 7,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.cadence.kind).toBe("rolling");
|
||||
expect(out.subControls.rollingDays).toBe(7);
|
||||
});
|
||||
|
||||
it("hydrates a legacy (daysOfWeek, intervalWeeks) schedule via the bridge", () => {
|
||||
const out = hydrateWizardPayload(
|
||||
makeInitial({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: [3],
|
||||
intervalWeeks: 2,
|
||||
timesOfDay: [],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.cadence.kind).toBe("everyNWeeks");
|
||||
expect(out.subControls.weekdays).toEqual(["WE"]);
|
||||
expect(out.subControls.intervalWeeks).toBe(2);
|
||||
});
|
||||
|
||||
it("hydrates an everyNWeeks RRULE schedule with the interval picked up", () => {
|
||||
const out = hydrateWizardPayload(
|
||||
makeInitial({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE",
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.cadence.kind).toBe("everyNWeeks");
|
||||
expect(out.subControls.weekdays).toEqual(["WE"]);
|
||||
expect(out.subControls.intervalWeeks).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user