diff --git a/src/lib/medications/scheduling/__tests__/aligned-rrule-start.property.test.ts b/src/lib/medications/scheduling/__tests__/aligned-rrule-start.property.test.ts index 781c5f65e..103f603bb 100644 --- a/src/lib/medications/scheduling/__tests__/aligned-rrule-start.property.test.ts +++ b/src/lib/medications/scheduling/__tests__/aligned-rrule-start.property.test.ts @@ -9,7 +9,10 @@ import { describe, expect, it } from "vitest"; import { alignedRruleStart } from "@/lib/medications/scheduling/aligned-rrule-start"; -import { assertProperty } from "../../../../../tests/property/fast-check"; +import { + assertProperty, + propertyTimeout, +} from "../../../../../tests/property/fast-check"; const DAY_MS = 24 * 60 * 60 * 1000; const BYDAY = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const; @@ -33,8 +36,9 @@ const ruleArb = fc ), wkst: fc.option(fc.constantFrom("MO", "SU")), count: fc.option(fc.integer({ min: 1, max: 40 })), + bysetpos: fc.option(fc.constantFrom(1, 2, -1)), }) - .map(({ freq, interval, byday, byhour, wkst, count }) => + .map(({ freq, interval, byday, byhour, wkst, count, bysetpos }) => [ `FREQ=${freq}`, interval > 1 ? `INTERVAL=${interval}` : null, @@ -42,6 +46,7 @@ const ruleArb = fc byhour ? `BYHOUR=${byhour.join(",")}` : null, wkst ? `WKST=${wkst}` : null, count ? `COUNT=${count}` : null, + bysetpos && byday && !byhour ? `BYSETPOS=${bysetpos}` : null, ] .filter(Boolean) .join(";"), @@ -61,26 +66,32 @@ function occurrences(rule: string, start: Date, after: Date, before: Date) { } describe("alignedRruleStart", () => { - it("leaves every occurrence in the window where it was", () => { - assertProperty( - fc.property( - ruleArb, - dayArb, - fc.integer({ min: -30, max: 2 * 365 }), - fc.integer({ min: 1, max: 120 }), - (rule, anchor, offsetDays, spanDays) => { - const after = new Date(anchor.getTime() + offsetDays * DAY_MS); - const before = new Date(after.getTime() + spanDays * DAY_MS); - const aligned = alignedRruleStart(rule, anchor, after); - expect(aligned.getTime()).toBeGreaterThanOrEqual(anchor.getTime()); - expect(occurrences(rule, aligned, after, before)).toEqual( - occurrences(rule, anchor, after, before), - ); - }, - ), - 300, - ); - }); + it( + "leaves every occurrence in the window where it was", + () => { + assertProperty( + fc.property( + ruleArb, + dayArb, + fc.integer({ min: -30, max: 2 * 365 }), + fc.integer({ min: 1, max: 120 }), + (rule, anchor, offsetDays, spanDays) => { + const after = new Date(anchor.getTime() + offsetDays * DAY_MS); + const before = new Date(after.getTime() + spanDays * DAY_MS); + const aligned = alignedRruleStart(rule, anchor, after); + expect(aligned.getTime()).toBeGreaterThanOrEqual(anchor.getTime()); + expect(occurrences(rule, aligned, after, before)).toEqual( + occurrences(rule, anchor, after, before), + ); + }, + ), + 300, + ); + // The reference walks from the real anchor, which is the cost this + // module removes from the engine. + }, + propertyTimeout(30_000), + ); it("moves a daily or weekly start close to the window", () => { const anchor = new Date(Date.UTC(2024, 5, 1)); diff --git a/src/lib/medications/scheduling/aligned-rrule-start.ts b/src/lib/medications/scheduling/aligned-rrule-start.ts index cf8bec14c..a2e284b34 100644 --- a/src/lib/medications/scheduling/aligned-rrule-start.ts +++ b/src/lib/medications/scheduling/aligned-rrule-start.ts @@ -14,7 +14,8 @@ const DAY_MS = 24 * 60 * 60 * 1000; * occurrence inside it where it was: the weekday, the week parity of an * every-other-week rule and the BYHOUR expansion all ride on the period. * A rule with COUNT keeps its anchor (the count runs from the first - * occurrence), and so does any other frequency, whose period is not a fixed + * occurrence), as does one with BYSETPOS (see below), and so does any other + * frequency, whose period is not a fixed * number of days. The day before `after` is the latest safe start, because * a start the rule does not match is not an occurrence, and anything * between it and `after` is outside the window anyway. @@ -31,6 +32,10 @@ export function alignedRruleStart( return anchor; } if (options.count != null) return anchor; + // BYSETPOS picks among a period's candidates, and the library drops the + // candidates before DTSTART in the first period before it picks, so a + // start moved into the middle of a week would choose a different day. + if (options.bysetpos != null) return anchor; const interval = options.interval ?? 1; if (!Number.isInteger(interval) || interval < 1) return anchor; const periodDays =