From 829a27da82f58f9b717830b08d66c6c45b719c70 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 13 Aug 2026 16:09:30 +0200 Subject: [PATCH] feat(recurrence): expand a series in memory over lib-recur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of docs/OWN-STORE.md, the engine half. RecurrenceExpander turns a stored rule set into its occurrences at read time — no materialised instances table, so none of its staleness bugs exist. Each occurrence is returned as its RECURRENCE-ID anchor, which is what the seam now addresses occurrences by. Expansion is bounded two ways: the window end, and a hard occurrence ceiling. The iterator is fast-forwarded to the window start first, so a FREQ=MINUTELY series anchored years back doesn't scan millions of instances to emit one. Three things lib-recur 0.12.2 forced. RecurrenceSet.iterator injects the start itself, so DTSTART is in the set for free and EXDATE can remove it (RFC 5545 §3.8.5.3). Its window end is exclusive. And a floating UNTIL against a zoned start throws, so the UNTIL's local fields are re-read in the series zone — the vendored provider worked around the same thing via TimeZone.getDefault(), which isn't deterministic. Malformed RRULE/RDATE/EXDATE values are dropped, not thrown: a task with an unparseable stored rule still has to appear. 38 tests. Multi-occurrence expansion has no provider behaviour to compare against, so the reference is RFC 5545 directly — daily/weekly/monthly/ yearly, COUNT, UNTIL, a Europe/Berlin DST boundary, all-day series pinned to UTC midnight, RDATE, EXDATE, and an unbounded rule hitting both bounds. --- .../domain/recurrence/RecurrenceExpander.kt | 150 +++++++ .../recurrence/DistanceFromCurrentTest.kt | 59 +++ .../recurrence/RecurrenceExpanderTest.kt | 402 ++++++++++++++++++ 3 files changed, 611 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt new file mode 100644 index 0000000..1fa97e9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt @@ -0,0 +1,150 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import org.dmfs.rfc5545.DateTime +import org.dmfs.rfc5545.recur.RecurrenceRule +import org.dmfs.rfc5545.recurrenceset.RecurrenceList +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet +import java.time.ZoneId +import java.util.TimeZone +import kotlin.time.Instant + +private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + +/** The rule set of one task series, as stored. All strings are raw iCalendar values. */ +data class RecurrenceSpec( + val rrule: String?, + val rdate: String?, + val exdate: String?, + /** The series anchor: DTSTART if present, else DUE. Never null for a recurring task. */ + val anchor: Instant, + val isAllDay: Boolean, + /** IANA zone id the anchor is expressed in; null means floating/local. */ + val timeZone: String?, +) + +/** + * The window expansion is bounded to: [from] inclusive, [until] exclusive, and + * never more than [maxOccurrences] results — so an unbounded `RRULE` terminates. + */ +data class ExpansionWindow( + val from: Instant, + val until: Instant, + val maxOccurrences: Int = 500, +) + +/** + * Expands a task series into its occurrences in memory, over `lib-recur`. + * + * There is no materialised instances table behind this: the repository already + * filters and sorts in Kotlin, so occurrences are computed at read time and the + * whole class of staleness bugs a cached table brings never exists. + */ +object RecurrenceExpander { + + /** + * Every occurrence of [spec] inside [window], as its `RECURRENCE-ID` anchor — + * the instant identifying that occurrence within the series. Ascending, + * deduplicated, `EXDATE` applied. + * + * The anchor itself is always part of the set (RFC 5545 §3.8.5.3: `DTSTART` + * is the first instance), so a spec with no rule and no `RDATE` expands to + * exactly its anchor. A malformed `RRULE`, `RDATE` or `EXDATE` is dropped + * rather than thrown — a task whose stored rule cannot be parsed still has + * to appear. + * + * [floatingZone] resolves a series with no [RecurrenceSpec.timeZone]; it is a + * parameter rather than a `TimeZone.getDefault()` lookup so expansion is + * deterministic under test. + */ + fun expand( + spec: RecurrenceSpec, + window: ExpansionWindow, + floatingZone: ZoneId = ZoneId.systemDefault(), + ): List { + val zone = zoneOf(spec, floatingZone) + val anchorMillis = anchorMillis(spec) + + val set = RecurrenceSet() + spec.rrule.orNull()?.let { raw -> ruleOf(raw, zone)?.let { set.addInstances(RecurrenceRuleAdapter(it)) } } + spec.rdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addInstances) } + spec.exdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addExceptions) } + + val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds()) + iterator.fastForward(window.from.toEpochMilliseconds()) + + val occurrences = ArrayList() + var previous = Long.MIN_VALUE + while (occurrences.size < window.maxOccurrences && iterator.hasNext()) { + val millis = iterator.next() + if (millis == previous) continue + previous = millis + occurrences += Instant.fromEpochMilliseconds(millis) + } + return occurrences + } + + /** + * Index of the current occurrence in an ascending [occurrences] list: the + * first one at or after [now], or the last one when the whole series is in + * the past. `-1` when there are no occurrences at all. + */ + fun currentOccurrenceIndex(occurrences: List, now: Instant): Int { + if (occurrences.isEmpty()) return -1 + val next = occurrences.indexOfFirst { it >= now } + return if (next >= 0) next else occurrences.lastIndex + } + + /** + * Each occurrence's distance from the current one, index-aligned with + * [occurrences]. `0` is the current occurrence, negative counts back into the + * past and positive counts forward — the convention `Task.distanceFromCurrent` + * carries and the data sources pick the current occurrence by. + * + * Purely positional: unlike the dmfs provider, which drove the same number off + * each instance's closed state, this knows only times. Completion-aware + * refinement belongs where overrides carry their status. + */ + fun distancesFromCurrent(occurrences: List, now: Instant): List { + val current = currentOccurrenceIndex(occurrences, now) + if (current < 0) return emptyList() + return occurrences.indices.map { it - current } + } + + private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone { + if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC")) + val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() } + return TimeZone.getTimeZone(stored ?: floatingZone) + } + + /** All-day series are date-anchored: pin the anchor to UTC midnight, as it is stored. */ + private fun anchorMillis(spec: RecurrenceSpec): Long { + val millis = spec.anchor.toEpochMilliseconds() + return if (!spec.isAllDay) millis else Math.floorDiv(millis, MILLIS_PER_DAY) * MILLIS_PER_DAY + } + + private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching { + RecurrenceRule(value).also { rule -> + // lib-recur refuses to iterate a floating UNTIL against a zoned start, + // and RFC 5545 §3.3.10 forbids that pairing — but stored rules carry it + // anyway. Re-read the UNTIL's local fields in the series zone. + val until = rule.until + if (until != null && until.isFloating) { + rule.until = DateTime( + zone, + until.year, + until.month, + until.dayOfMonth, + until.hours, + until.minutes, + until.seconds, + ) + } + } + }.getOrNull() + + private fun datesOf(value: String, zone: TimeZone): RecurrenceList? = + runCatching { RecurrenceList(value, zone) }.getOrNull() + + private fun String?.orNull(): String? = this?.trim()?.ifEmpty { null } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt new file mode 100644 index 0000000..2e0e9b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class DistanceFromCurrentTest { + + private fun at(text: String) = Instant.parse(text) + + private val occurrences = listOf( + at("2025-01-05T09:00:00Z"), + at("2025-01-06T09:00:00Z"), + at("2025-01-07T09:00:00Z"), + at("2025-01-08T09:00:00Z"), + ) + + @Test + fun `the current occurrence is the first one at or after now`() { + val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T10:00:00Z")) + assertThat(index).isEqualTo(2) + } + + @Test + fun `an occurrence exactly at now is the current one`() { + val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T09:00:00Z")) + assertThat(index).isEqualTo(1) + } + + @Test + fun `past occurrences count down and later ones count up`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-06T10:00:00Z")) + assertThat(distances).containsExactly(-2, -1, 0, 1).inOrder() + } + + @Test + fun `a series entirely in the future is current at its first occurrence`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2024-12-01T00:00:00Z")) + assertThat(distances).containsExactly(0, 1, 2, 3).inOrder() + } + + @Test + fun `a series entirely in the past is current at its last occurrence`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2026-01-01T00:00:00Z")) + assertThat(distances).containsExactly(-3, -2, -1, 0).inOrder() + } + + @Test + fun `exactly one occurrence is ever the current one`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-07T00:00:00Z")) + assertThat(distances.count { it == 0 }).isEqualTo(1) + } + + @Test + fun `an empty series has no distances`() { + assertThat(RecurrenceExpander.distancesFromCurrent(emptyList(), at("2025-01-01T00:00:00Z"))).isEmpty() + assertThat(RecurrenceExpander.currentOccurrenceIndex(emptyList(), at("2025-01-01T00:00:00Z"))).isEqualTo(-1) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt new file mode 100644 index 0000000..98baa1e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt @@ -0,0 +1,402 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.ZoneId +import kotlin.time.Instant + +/** + * The provider only ever materialised the single next occurrence, so there is no + * provider behaviour to compare multi-occurrence expansion against. These cases + * assert against RFC 5545 §3.8.5 directly. + */ +class RecurrenceExpanderTest { + + private val berlin = "Europe/Berlin" + private val newYork = ZoneId.of("America/New_York") + + private fun at(text: String) = Instant.parse(text) + + private fun spec( + rrule: String? = null, + rdate: String? = null, + exdate: String? = null, + anchor: String, + isAllDay: Boolean = false, + timeZone: String? = berlin, + ) = RecurrenceSpec(rrule, rdate, exdate, at(anchor), isAllDay, timeZone) + + private fun window( + from: String = "2000-01-01T00:00:00Z", + until: String = "2100-01-01T00:00:00Z", + max: Int = 500, + ) = ExpansionWindow(at(from), at(until), max) + + private fun expand( + spec: RecurrenceSpec, + window: ExpansionWindow = window(), + floatingZone: ZoneId = newYork, + ) = RecurrenceExpander.expand(spec, window, floatingZone).map { it.toString() } + + // --- frequencies --------------------------------------------------------- + + @Test + fun `daily rule yields consecutive days at the same local time`() { + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `interval skips the intervening days`() { + val result = expand(spec(rrule = "FREQ=DAILY;INTERVAL=2;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + "2025-01-11T08:00:00Z", + ).inOrder() + } + + @Test + fun `weekly by day expands to the named weekdays`() { + // The anchor is a Tuesday, which BYDAY=MO,WE,FR does not name. DTSTART is + // the first instance of the set regardless (RFC 5545 §3.8.5.3), so the + // Tuesday leads and the pattern takes over from there. + val result = expand( + spec(rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", // Tue, the anchor + "2025-01-08T08:00:00Z", // Wed + "2025-01-10T08:00:00Z", // Fri + "2025-01-13T08:00:00Z", // Mon + "2025-01-15T08:00:00Z", // Wed + ).inOrder() + } + + @Test + fun `monthly by day expands to the nth weekday of the month`() { + // 2025-01-07 is the first Tuesday, so BYDAY=2TU lands on the 14th; the + // anchor still leads. + val result = expand(spec(rrule = "FREQ=MONTHLY;BYDAY=2TU;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-14T08:00:00Z", + "2025-02-11T08:00:00Z", + ).inOrder() + } + + @Test + fun `monthly by month day skips months that lack the day`() { + val result = expand(spec(rrule = "FREQ=MONTHLY;BYMONTHDAY=31;COUNT=3", anchor = "2025-01-31T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-31T08:00:00Z", + "2025-03-31T07:00:00Z", // February and April have no 31st; March is already CEST + "2025-05-31T07:00:00Z", + ).inOrder() + } + + @Test + fun `yearly rule repeats on the anniversary`() { + val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2026-01-07T08:00:00Z", + "2027-01-07T08:00:00Z", + ).inOrder() + } + + @Test + fun `yearly rule on a leap day only recurs in leap years`() { + val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2024-02-29T08:00:00Z")) + assertThat(result).containsExactly( + "2024-02-29T08:00:00Z", + "2028-02-29T08:00:00Z", + "2032-02-29T08:00:00Z", + ).inOrder() + } + + // --- limits -------------------------------------------------------------- + + @Test + fun `COUNT limits the series`() { + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).hasSize(2) + } + + @Test + fun `UNTIL includes an occurrence falling exactly on it`() { + val result = expand( + spec(rrule = "FREQ=DAILY;UNTIL=20250109T080000Z", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a floating UNTIL is read in the series zone`() { + // RFC 5545 §3.3.10 requires UNTIL in UTC when DTSTART carries a zone, and + // lib-recur throws outright on the mismatch. Stored rules break the rule + // anyway, so 09:00 floating has to mean 09:00 in Berlin. + val result = expand( + spec(rrule = "FREQ=DAILY;UNTIL=20250109T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `an unbounded rule stops at the occurrence ceiling`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(max = 4), + ) + assertThat(result).hasSize(4) + assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") + } + + @Test + fun `an unbounded rule stops at the window end`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2025-01-10T00:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `the window end is exclusive`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2025-01-09T08:00:00Z"), + ) + assertThat(result).doesNotContain("2025-01-09T08:00:00Z") + } + + @Test + fun `occurrences before the window start are skipped`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(from = "2025-06-01T00:00:00Z", until = "2025-06-04T00:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-06-01T07:00:00Z", + "2025-06-02T07:00:00Z", + "2025-06-03T07:00:00Z", + ).inOrder() + } + + // --- DST and all-day ----------------------------------------------------- + + @Test + fun `a daily series keeps its local time across a DST boundary`() { + // Europe/Berlin springs forward on 2025-03-30, so 09:00 local moves from + // 08:00Z to 07:00Z while the wall-clock time the user set stays put. + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=4", anchor = "2025-03-28T08:00:00Z")) + assertThat(result).containsExactly( + "2025-03-28T08:00:00Z", + "2025-03-29T08:00:00Z", + "2025-03-30T07:00:00Z", + "2025-03-31T07:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day series pins every occurrence to UTC midnight`() { + // Date-anchored, matching TaskWriteMapper's forAllDay: the stored zone and + // the device zone are both irrelevant, and no DST shift reaches it. + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=3", + anchor = "2025-03-29T22:45:00Z", + isAllDay = true, + timeZone = berlin, + ), + ) + assertThat(result).containsExactly( + "2025-03-29T00:00:00Z", + "2025-03-30T00:00:00Z", + "2025-03-31T00:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day weekly series stays on the same weekday`() { + val result = expand( + spec(rrule = "FREQ=WEEKLY;COUNT=3", anchor = "2025-01-15T00:00:00Z", isAllDay = true, timeZone = null), + ) + assertThat(result).containsExactly( + "2025-01-15T00:00:00Z", + "2025-01-22T00:00:00Z", + "2025-01-29T00:00:00Z", + ).inOrder() + } + + @Test + fun `a series with no zone expands in the floating zone`() { + val spec = spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = null) + // 09:00 in New York, over the 2025-03-09 US DST switch. + assertThat(expand(spec, floatingZone = newYork)).containsExactly( + "2025-03-08T14:00:00Z", + "2025-03-09T13:00:00Z", + ).inOrder() + } + + // --- RDATE / EXDATE ------------------------------------------------------ + + @Test + fun `RDATE adds occurrences the rule does not produce`() { + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=2", + rdate = "20250115T140000", + anchor = "2025-01-07T08:00:00Z", + ), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-15T13:00:00Z", // 14:00 Berlin + ).inOrder() + } + + @Test + fun `an RDATE before the anchor is part of the set`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "20250101T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result.first()).isEqualTo("2025-01-01T08:00:00Z") + } + + @Test + fun `an RDATE repeating a rule instance is not emitted twice`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", rdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `multiple RDATEs are comma-separated`() { + val result = expand( + spec(rdate = "20250110T090000,20250112T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-10T08:00:00Z", + "2025-01-12T08:00:00Z", + ).inOrder() + } + + @Test + fun `EXDATE removes an occurrence`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `EXDATE can remove the anchor itself`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250107T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a UTC EXDATE matches a zoned occurrence at the same instant`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T080000Z", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day EXDATE removes the matching date`() { + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=3", + exdate = "20250116", + anchor = "2025-01-15T00:00:00Z", + isAllDay = true, + ), + ) + assertThat(result).containsExactly( + "2025-01-15T00:00:00Z", + "2025-01-17T00:00:00Z", + ).inOrder() + } + + // --- degenerate input ---------------------------------------------------- + + @Test + fun `a spec with no rule expands to just its anchor`() { + assertThat(expand(spec(anchor = "2025-01-07T08:00:00Z"))) + .containsExactly("2025-01-07T08:00:00Z") + } + + @Test + fun `a malformed rule degrades to the anchor instead of throwing`() { + assertThat(expand(spec(rrule = "FREQ=NONSENSE", anchor = "2025-01-07T08:00:00Z"))) + .containsExactly("2025-01-07T08:00:00Z") + } + + @Test + fun `a malformed RDATE is dropped and the rule still expands`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "not-a-date", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + ).inOrder() + } + + @Test + fun `an unknown zone id falls back to the floating zone`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = "Mars/Olympus"), + floatingZone = newYork, + ) + assertThat(result).containsExactly( + "2025-03-08T14:00:00Z", + "2025-03-09T13:00:00Z", + ).inOrder() + } + + @Test + fun `a window that ends before the anchor yields nothing`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2024-01-01T00:00:00Z"), + ) + assertThat(result).isEmpty() + } +}