feat(edit): event editing — shared form, scoped recurring writes, recurrence picker (v1.3)

The create form (v1.2) now edits: a pencil on the detail screen (writable
calendars only, contextual WRITE upgrade like delete) opens it prefilled via
EventDetail.toEditForm; populated sections always show, the calendar is
fixed, and a dirty-check writes only changed columns (pristine saves are
no-ops). Saving a dirty recurring event parks in SaveUiState.AwaitingScope
and asks how far the change reaches (Google model): "only this event" =
modified-occurrence exception via CONTENT_EXCEPTION_URI (empty optionals as
explicit NULLs since the provider clones the parent row), "this and all
following" = series split (insert new event first, then truncate), "all
events" = series-row update with the time delta applied to the series
DTSTART. A changed rule drops the exception option. Delete gained the same
middle scope.

Recurrence: EventForm.rrule + SimpleRecurrence (FREQ/INTERVAL/UNTIL/COUNT +
weekly BYDAY with locale-ordered weekday toggles) behind a picker on create
and edit; unrepresentable rules render humanized (shared ui/common
RecurrenceText) and survive verbatim. UNTIL validation flags rules ending
before the event starts.

Provider lessons baked in (verified on-device via adb probes): instance
caches regenerate only from an update's own values, so truncation sends the
full time-column set (truncateSeries) — RRULE-only updates left a stale
duplicate occurrence on the split day; UNTIL is written as the local end of
day in UTC (toRRule(zone), previousLocalDayEndUtcMillis) so UTC+x zones
can't leak an extra day. Reminder edits reconcile against actual provider
rows, keeping untouched rows' methods.

Tests: RecurrenceTest (parse/render/round-trip, truncation), update/exception
mapper paths, repository pass-throughs, prefill + populatedFields, raw-title
mapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 20:57:32 +02:00
parent bdedf47972
commit f0e2e12939
28 changed files with 2289 additions and 242 deletions

View File

@@ -198,6 +198,43 @@ class CalendarRepositoryImplTest {
}
}
@Test
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
)
val updated = original.copy(title = "Daily")
repo.updateEvent(eventId = 42L, original = original, updated = updated)
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
}
@Test
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("update event id=42")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
try {
repo.updateEvent(eventId = 42L, original = form, updated = form.copy(title = "X"))
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("42")
}
}
@Test
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
@@ -220,6 +257,61 @@ class CalendarRepositoryImplTest {
assertThat(fake.deletedEventIds).isEmpty()
}
@Test
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
assertThat(fake.deletedFromOccurrences).containsExactly(42L to 1_000L)
assertThat(fake.deletedEventIds).isEmpty()
assertThat(fake.deletedOccurrences).isEmpty()
}
@Test
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
title = "Moved",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
assertThat(id).isEqualTo(88L)
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Weekly",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
rrule = "FREQ=WEEKLY",
)
val updated = original.copy(title = "Weekly, renamed")
val id = repo.updateEventFromOccurrence(
eventId = 42L,
beginMillis = 1_000L,
original = original,
updated = updated,
)
assertThat(id).isEqualTo(99L)
assertThat(fake.updatedFromOccurrences).containsExactly(Triple(42L, 1_000L, updated))
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {

View File

@@ -88,6 +88,12 @@ class EventDetailMapperTest {
assertThat(detail.attendees).isEmpty()
}
@Test
fun `missing title stays raw so the edit form does not inherit a placeholder`() {
assertThat(detailReader(title = null).toDetail()!!.instance.title).isEmpty()
assertThat(detailReader(title = "").toDetail()!!.instance.title).isEmpty()
}
@Test
fun `event color falls back to calendar color when null`() {
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())

View File

@@ -71,4 +71,151 @@ class EventWriteMapperTest {
// 11th, 12th, 13th inclusive = 3 days.
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L)
}
@Test
fun `truncation cutoff is the end of the previous local day`() {
// June 9 2026 15:30Z == 17:30 in Berlin. The cutoff must be
// June 8 23:59:59 Berlin == June 8 21:59:59Z.
assertThat(previousLocalDayEndUtcMillis(1_781_019_000_000L, berlin))
.isEqualTo(1_780_955_999_000L)
// All-day series live in UTC: the cutoff for a June 9 UTC-midnight
// occurrence is June 8 23:59:59Z.
assertThat(previousLocalDayEndUtcMillis(1_780_963_200_000L, ZoneId.of("UTC")))
.isEqualTo(1_780_963_199_000L)
}
@Test
fun `duration renders seconds for timed and days for all-day events`() {
assertThat(form().toWriteTimes(berlin).toRfc2445Duration(isAllDay = false))
.isEqualTo("P5400S")
assertThat(form(isAllDay = true).toWriteTimes(berlin).toRfc2445Duration(isAllDay = true))
.isEqualTo("P1D")
}
// --- buildEventUpdateValues (dirty-checked partial update) ---
private val seriesStart = 1_700_000_000_000L
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
buildEventUpdateValues(original, updated, seriesStart, berlin)
@Test
fun `pristine form produces no values`() {
val original = form()
assertThat(update(original, original.copy())).isEmpty()
}
@Test
fun `text-only edit writes just the changed columns`() {
val original = form()
val values = update(original, original.copy(title = "New", description = "Body"))
assertThat(values).containsExactly(
CalendarContract.Events.TITLE, "New",
CalendarContract.Events.DESCRIPTION, "Body",
)
}
@Test
fun `clearing location writes an explicit null`() {
val original = form().copy(location = "Berlin")
val values = update(original, original.copy(location = " "))
assertThat(values).containsExactly(CalendarContract.Events.EVENT_LOCATION, null)
}
@Test
fun `time edit on a one-off event writes absolute times and clears recurrence columns`() {
val original = form()
val updated = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)),
)
val values = update(original, updated)
// 2026-06-11 11:00 CEST == 09:00Z.
assertThat(values[CalendarContract.Events.DTSTART])
.isEqualTo(1_781_164_800_000L + 3_600_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 2L * 3_600_000L)
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
}
@Test
fun `time edit on a recurring event moves the series start by the same delta`() {
val original = form().copy(rrule = "FREQ=WEEKLY")
val updated = original.copy(
// Pushed one hour later than the displayed occurrence.
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
)
val values = update(original, updated)
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart + 3_600_000L)
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
}
@Test
fun `adding a recurrence keeps the times and writes rule plus duration`() {
val original = form()
val values = update(original, original.copy(rrule = "FREQ=DAILY;COUNT=5"))
// The event was one-off, so the row's DTSTART is the occurrence start
// and a zero delta keeps it in place.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(seriesStart)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
assertThat(values).containsEntry(CalendarContract.Events.DTEND, null)
}
@Test
fun `removing the recurrence writes absolute occurrence times and clears the rule`() {
val original = form().copy(rrule = "FREQ=WEEKLY")
val values = update(original, original.copy(rrule = null))
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 5_400_000L)
assertThat(values).containsEntry(CalendarContract.Events.RRULE, null)
assertThat(values).containsEntry(CalendarContract.Events.DURATION, null)
}
@Test
fun `reminder-only changes touch no event columns`() {
val original = form()
assertThat(update(original, original.copy(reminders = listOf(10)))).isEmpty()
}
// --- buildOccurrenceExceptionValues ("edit only this event") ---
@Test
fun `occurrence exception carries absolute times and the original instance`() {
val values = buildOccurrenceExceptionValues(
form = form().copy(title = "Moved", location = "Berlin"),
originalInstanceMillis = 1_700_000_000_000L,
zone = berlin,
)
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
.isEqualTo(1_700_000_000_000L)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 5_400_000L)
// A single occurrence never carries its own rule.
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
}
@Test
fun `occurrence exception clears empty optionals explicitly`() {
// The provider clones the parent row, so a blank field must be an
// explicit NULL or the parent's value would survive.
val values = buildOccurrenceExceptionValues(
form = form(),
originalInstanceMillis = 0L,
zone = berlin,
)
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
}
}

View File

@@ -20,8 +20,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var nextInsertId: Long = 100L
val insertedForms = mutableListOf<EventForm>()
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
val deletedEventIds = mutableListOf<Long>()
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>()
private val listeners = mutableListOf<() -> Unit>()
@@ -36,6 +40,33 @@ internal class FakeCalendarDataSource : CalendarDataSource {
return nextInsertId
}
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
writeError?.let { throw it }
updatedEvents += Triple(eventId, original, updated)
}
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
writeError?.let { throw it }
updatedOccurrences += Triple(eventId, beginMillis, form)
return nextInsertId
}
override fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long {
writeError?.let { throw it }
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
return nextInsertId
}
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
writeError?.let { throw it }
deletedFromOccurrences += eventId to beginMillis
}
override fun deleteEvent(eventId: Long) {
writeError?.let { throw it }
deletedEventIds += eventId

View File

@@ -4,7 +4,9 @@ import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class EventFormTest {
@@ -69,4 +71,132 @@ class EventFormTest {
EventFormProblem.EndBeforeStart,
)
}
@Test
fun `recurrence until before the first day is a problem`() {
// Days before the start, so it parses to an earlier date in any zone.
val bad = form().copy(rrule = "FREQ=DAILY;UNTIL=20260601T120000Z")
assertThat(bad.problems())
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
@Test
fun `recurrence until on or after the first day is fine`() {
// Date-only UNTIL parses zone-independently.
assertThat(form().copy(rrule = "FREQ=DAILY;UNTIL=20260611").problems()).isEmpty()
assertThat(form().copy(rrule = "FREQ=WEEKLY").problems()).isEmpty()
}
@Test
fun `complex rrules are not validated against the start`() {
// The picker can't have produced this ("second Monday" ordinal BYDAY);
// it is preserved verbatim and never flagged.
assertThat(form().copy(rrule = "FREQ=MONTHLY;BYDAY=2MO;UNTIL=20200101").problems()).isEmpty()
}
@Test
fun `weekly byday rules are validated against the start`() {
val bad = form().copy(rrule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20200101")
assertThat(bad.problems())
.containsExactly(EventFormProblem.RecurrenceEndsBeforeStart)
}
private val berlin = TimeZone.of("Europe/Berlin")
private fun detail(
isAllDay: Boolean = false,
title: String = "Stand-up",
location: String? = "Berlin",
description: String? = "Body",
rrule: String? = null,
reminders: List<Reminder> = emptyList(),
availability: Availability = Availability.Busy,
accessLevel: AccessLevel = AccessLevel.Default,
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 7L,
title = title,
start = Instant.fromEpochMilliseconds(0L),
end = Instant.fromEpochMilliseconds(0L),
isAllDay = isAllDay,
color = 0,
location = location,
),
description = description,
organizer = null,
attendees = emptyList(),
rrule = rrule,
reminders = reminders,
availability = availability,
accessLevel = accessLevel,
)
@Test
fun `toEditForm prefills a timed event from the occurrence times`() {
// 2026-06-11 is CEST (UTC+2): 08:00Z == 10:00 local.
val prefilled = detail().toEditForm(
beginMillis = 1_781_164_800_000L,
endMillis = 1_781_164_800_000L + 90L * 60L * 1000L,
zone = berlin,
)
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(prefilled.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)))
assertThat(prefilled.isAllDay).isFalse()
assertThat(prefilled.calendarId).isEqualTo(7L)
assertThat(prefilled.title).isEqualTo("Stand-up")
assertThat(prefilled.location).isEqualTo("Berlin")
assertThat(prefilled.description).isEqualTo("Body")
}
@Test
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
val prefilled = detail(isAllDay = true).toEditForm(
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
endMillis = LocalDate(2026, 6, 14).toEpochDays() * 86_400_000L,
zone = berlin,
)
assertThat(prefilled.start.date).isEqualTo(LocalDate(2026, 6, 11))
assertThat(prefilled.end.date).isEqualTo(LocalDate(2026, 6, 13))
assertThat(prefilled.isAllDay).isTrue()
}
@Test
fun `toEditForm sorts and dedupes reminder minutes and strips an RRULE prefix`() {
val prefilled = detail(
rrule = "RRULE:FREQ=WEEKLY",
reminders = listOf(
Reminder(30, ReminderMethod.Email),
Reminder(10, ReminderMethod.Alert),
Reminder(30, ReminderMethod.Alert),
),
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
assertThat(prefilled.reminders).containsExactly(10, 30).inOrder()
assertThat(prefilled.rrule).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `populatedFields reports exactly the sections holding values`() {
val empty = form().copy(location = "", description = "")
assertThat(empty.populatedFields()).isEmpty()
val full = form().copy(
location = "Berlin",
description = "Body",
reminders = listOf(10),
rrule = "FREQ=DAILY",
availability = Availability.Free,
accessLevel = AccessLevel.Private,
)
assertThat(full.populatedFields()).containsExactly(
EventFormField.Location,
EventFormField.Description,
EventFormField.Reminders,
EventFormField.Recurrence,
EventFormField.Availability,
EventFormField.Visibility,
)
}
}

View File

@@ -0,0 +1,164 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
class RecurrenceTest {
private val utc = TimeZone.UTC
private val berlin = TimeZone.of("Europe/Berlin")
@Test
fun `plain frequency parses with defaults`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly))
assertThat(parseSimpleRecurrence("FREQ=DAILY"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Daily))
}
@Test
fun `leading RRULE prefix and WKST are tolerated`() {
assertThat(parseSimpleRecurrence("RRULE:FREQ=MONTHLY;WKST=MO"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Monthly))
}
@Test
fun `interval parses`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;INTERVAL=2"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly, interval = 2))
}
@Test
fun `until parses date-only and UTC datetime forms`() {
val expected = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
)
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801", utc)).isEqualTo(expected)
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T235959Z", utc))
.isEqualTo(expected)
}
@Test
fun `until datetime converts from UTC into the given zone before taking the date`() {
// 21:59:59Z == 23:59:59 in Berlin (CEST) — still August 1 there.
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T215959Z", berlin))
.isEqualTo(
SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Until(LocalDate(2026, 8, 1))),
)
}
@Test
fun `count parses`() {
assertThat(parseSimpleRecurrence("FREQ=YEARLY;COUNT=5"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Yearly, end = RecurrenceEnd.Count(5)))
}
@Test
fun `weekly byday parses as weekday picks`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,FR"))
.isEqualTo(
SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY),
),
)
}
@Test
fun `rules beyond the simple shape are rejected`() {
// Ordinal BYDAY ("second Thursday") and BYDAY on non-weekly rules.
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,2TH")).isNull()
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYDAY=MO")).isNull()
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYMONTHDAY=15")).isNull()
assertThat(parseSimpleRecurrence("FREQ=HOURLY")).isNull()
assertThat(parseSimpleRecurrence("")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801;COUNT=3")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;INTERVAL=0")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;COUNT=abc")).isNull()
}
@Test
fun `toRRule renders the minimal form`() {
assertThat(SimpleRecurrence(RecurrenceFreq.Weekly).toRRule()).isEqualTo("FREQ=WEEKLY")
assertThat(SimpleRecurrence(RecurrenceFreq.Daily, interval = 3).toRRule())
.isEqualTo("FREQ=DAILY;INTERVAL=3")
assertThat(
SimpleRecurrence(RecurrenceFreq.Monthly, end = RecurrenceEnd.Count(12)).toRRule(),
).isEqualTo("FREQ=MONTHLY;COUNT=12")
}
@Test
fun `toRRule renders weekdays in ISO order regardless of set order`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY),
).toRRule()
assertThat(rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE,FR")
}
@Test
fun `toRRule ignores weekday picks on non-weekly frequencies`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Monthly,
byDays = setOf(DayOfWeek.MONDAY),
).toRRule()
assertThat(rule).isEqualTo("FREQ=MONTHLY")
}
@Test
fun `toRRule writes until as the end of the chosen day in the given zone`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Weekly,
interval = 2,
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
)
assertThat(rule.toRRule(utc))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T235959Z")
// 23:59:59 Berlin (CEST, +2) == 21:59:59Z.
assertThat(rule.toRRule(berlin))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T215959Z")
}
// 2026-06-19T23:59:00Z — a moment just before a June 20 occurrence.
private val cutoffMillis = 1_781_913_540_000L
@Test
fun `truncation replaces count and keeps every other part`() {
assertThat(rruleTruncatedAt("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;COUNT=30", cutoffMillis))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20260619T235900Z")
}
@Test
fun `truncation replaces an existing until`() {
assertThat(rruleTruncatedAt("FREQ=DAILY;UNTIL=20301231T235959Z", cutoffMillis))
.isEqualTo("FREQ=DAILY;UNTIL=20260619T235900Z")
}
@Test
fun `truncation works on rules the simple picker cannot express`() {
assertThat(rruleTruncatedAt("RRULE:FREQ=MONTHLY;BYDAY=2TH", cutoffMillis))
.isEqualTo("FREQ=MONTHLY;BYDAY=2TH;UNTIL=20260619T235900Z")
}
@Test
fun `parse and render round-trip`() {
val rules = listOf(
"FREQ=DAILY",
"FREQ=WEEKLY;INTERVAL=2",
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20301231T235959Z",
"FREQ=MONTHLY;COUNT=6",
"FREQ=YEARLY;UNTIL=20301231T235959Z",
)
rules.forEach { rule ->
assertThat(parseSimpleRecurrence(rule, utc)!!.toRRule(utc)).isEqualTo(rule)
}
// Round-trips through a non-UTC zone too: 22:59:59Z == 23:59:59 CET.
val berlinRule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20301231T225959Z"
assertThat(parseSimpleRecurrence(berlinRule, berlin)!!.toRRule(berlin))
.isEqualTo(berlinRule)
}
}