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