feat(recurrence): expand a series in memory over lib-recur
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.
This commit is contained in:
@@ -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<Instant> {
|
||||||
|
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<Instant>()
|
||||||
|
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<Instant>, 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<Instant>, now: Instant): List<Int> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user