feat(medications): add a multi-step forward-jump lookahead to the wizard payload

This commit is contained in:
Marc-André Bombeck committed 2026-06-01 22:59:14 +02:00
1 parent 308d9c9d6d
commit 38b12607c2
2 files changed
+127

No files matched your search

@@ -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({
@@ -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
// ────────────────────────────────────────────────────────────────────