diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index caa0854aa..24f920607 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -5,6 +5,7 @@ import { buildCreateBody, commitActiveDraft, emptyWizardPayload, + firstInvalidIndex, hydrateWizardPayload, landingStepForEdit, type MedicationPayload, @@ -193,6 +194,102 @@ describe("validateStep", () => { }); }); +/** + * v1.8.6 W4b — `firstInvalidIndex` is the multi-step forward lookahead + * that powers the dot stepper's jump gate (`goToStep`) and its + * `reachableUntil` ceiling. The dialog's rule: + * - backward jumps are always allowed; + * - a forward jump to path-index `j` is allowed iff every gate from + * the active slot up to (but not including) `j` validates, which is + * exactly `firstInvalidIndex(payload, stepList, from) >= j`. + */ +describe("firstInvalidIndex — forward-jump lookahead", () => { + /** A daily-path payload with every gate satisfied. */ + function completeDaily(): WizardPayload { + return { + ...withCadence("daily"), + startsOn: new Date(Date.UTC(2026, 5, 1)), + endsOn: null, + timesOfDay: ["08:00"], + }; + } + + it("returns stepList.length when every slot from the index validates", () => { + const payload = completeDaily(); + const list = progressIndices(payload.mode, payload.cadence.kind); + expect(firstInvalidIndex(payload, list, 0)).toBe(list.length); + }); + + it("stops at the first failing slot — blank name gates the path at 0", () => { + const payload = { ...completeDaily(), name: " " }; + const list = progressIndices(payload.mode, payload.cadence.kind); + // Step 1 (index 0) fails its gate, so no forward jump is allowed. + expect(firstInvalidIndex(payload, list, 0)).toBe(0); + }); + + it("a missing dose gates the path at Step 3 (path-index 2)", () => { + const payload = { ...completeDaily(), doseAmount: "" }; + const list = progressIndices(payload.mode, payload.cadence.kind); + // [1,2,3,...] — Step 3 sits at index 2; it's the first to fail. + expect(firstInvalidIndex(payload, list, 0)).toBe(2); + }); + + it("blocks a forward jump past an invalid intervening step", () => { + // Missing times (Step 7) — daily path is [1,2,3,4,5,7,8]; Step 7 + // sits at index 5. A jump from Step 1 (index 0) to the review slot + // (Step 8, index 6) must be refused because index 5 fails. + const payload = { ...completeDaily(), timesOfDay: [] }; + const list = progressIndices(payload.mode, payload.cadence.kind); + const firstInvalid = firstInvalidIndex(payload, list, 0); + expect(firstInvalid).toBe(5); + const targetIndex = list.indexOf(8); + // Gate rule: forward jump allowed iff firstInvalid >= targetIndex. + expect(firstInvalid >= targetIndex).toBe(false); + }); + + it("allows a forward jump when all intervening gates pass", () => { + const payload = completeDaily(); + const list = progressIndices(payload.mode, payload.cadence.kind); + const fromIndex = 0; + const targetIndex = list.indexOf(8); + expect(firstInvalidIndex(payload, list, fromIndex) >= targetIndex).toBe( + true, + ); + }); + + it("a fully-hydrated edit payload makes jump-to-last (review) reachable", () => { + // Edit hydration populates every field, so on the recurring path + // every gate validates and the whole path is reachable — including + // the final review slot, so jump-to-last is one tap. + const initial: MedicationPayload = { + id: "med-1", + name: "Ramipril", + dose: "5 mg", + category: "BLOOD_PRESSURE", + treatmentClass: "GENERIC", + deliveryForm: "ORAL", + notificationsEnabled: true, + startsOn: new Date(Date.UTC(2026, 5, 1)), + endsOn: null, + oneShot: false, + schedules: [ + { + windowStart: "06:00", + windowEnd: "22:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }, + ], + }; + const payload = hydrateWizardPayload(initial); + const list = progressIndices(payload.mode, payload.cadence.kind); + expect(firstInvalidIndex(payload, list, 0)).toBe(list.length); + // Whole-path validity → jump-to-last enabled. + expect(firstInvalidIndex(payload, list, 0) >= list.length).toBe(true); + }); +}); + describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => { it("maps Blutdruck → (GENERIC, BLOOD_PRESSURE)", () => { expect(WIZARD_TREATMENT_MAPPING.bloodPressure).toEqual({ diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 984a05707..16ec63843 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -465,6 +465,36 @@ export function validateStep(payload: WizardPayload, step: number): boolean { } } +// ──────────────────────────────────────────────────────────────────── +// firstInvalidIndex — multi-step forward lookahead +// ──────────────────────────────────────────────────────────────────── + +/** + * Walks `stepList` from `fromIndex` (inclusive) and returns the index of + * the first slot whose `validateStep` gate fails, or `stepList.length` + * when every slot from `fromIndex` onward validates. + * + * This generalises the single-step `canContinue` gate + * (`validateStep(payload, step)`) into the forward-jump lookahead the + * dot stepper uses: a forward jump to path-index `j` is allowed iff + * `firstInvalidIndex(payload, stepList, i) >= j` — i.e. every gate + * between the current slot `i` and the target `j` passes. The dot + * stepper reuses the same value as its `reachableUntil` ceiling so the + * gate and the visual reachability never drift. + * + * Pure: reuses `validateStep` verbatim, no new validation logic. + */ +export function firstInvalidIndex( + payload: WizardPayload, + stepList: readonly number[], + fromIndex: number, +): number { + for (let k = Math.max(0, fromIndex); k < stepList.length; k++) { + if (!validateStep(payload, stepList[k])) return k; + } + return stepList.length; +} + // ──────────────────────────────────────────────────────────────────── // buildCreateBody — POST /api/medications request body // ────────────────────────────────────────────────────────────────────