Merge remote-tracking branch 'origin/main' into release/v2.20.0

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
This commit is contained in:
2026-09-02 15:34:17 +02:00
114 changed files with 9849 additions and 630 deletions
@@ -143,6 +143,76 @@ class AllDayReminderEncodingTest {
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
}
@Test
fun `an imported yearly series samples at its next occurrence, not its anchor`() {
// Fossify anchors a year-less contact birthday at 1970, when Berlin had
// no DST; sampling there fires a modern June occurrence at 10:00 instead
// of the 09:00 the user picked (Codeberg #225).
val anchor = LocalDate.of(1970, 6, 3)
val today = LocalDate.of(2026, 6, 1)
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY;BYMONTH=6", today)
assertThat(sampled).isEqualTo(LocalDate.of(2026, 6, 3))
val raw = toProviderAllDayMinutes(0, sampled, berlin, nineAm)
val fire = actualFire(raw, LocalDate.of(2027, 6, 3))
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
assertThat(fire).isEqualTo(LocalTime.of(9, 0))
}
@Test
fun `the lead time still reads back from the anchor's own date`() {
// The offset is sampled at the next occurrence but decoded against
// DTSTART, so the two must still agree on the whole-day lead time.
val anchor = LocalDate.of(1970, 6, 3)
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY", LocalDate.of(2026, 6, 1))
for (semantic in listOf(0, 1_440, 2_880)) {
val raw = toProviderAllDayMinutes(semantic, sampled, berlin, nineAm)
assertThat(fromProviderAllDayMinutes(raw, anchor, berlin, nineAm)).isEqualTo(semantic)
}
}
@Test
fun `a one-off import samples at its own date, a stale series at today`() {
val past = LocalDate.of(1970, 6, 3)
val today = LocalDate.of(2026, 6, 1)
val future = LocalDate.of(2027, 3, 4)
assertThat(importedAllDayReminderDate(past, null, today)).isEqualTo(past)
assertThat(importedAllDayReminderDate(past, "FREQ=WEEKLY;BYDAY=MO", today)).isEqualTo(today)
assertThat(importedAllDayReminderDate(future, "FREQ=WEEKLY", today)).isEqualTo(future)
}
@Test
fun `the provider default sentinel survives a round trip untouched`() {
// -1 is Reminders.MINUTES_DEFAULT, not an offset. Decoded it landed on
// the start date and came back as 0 — a concrete at-start alarm on the
// detail screen and in an exported file.
assertThat(toProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
assertThat(fromProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
}
@Test
fun `a real encoding that is negative is still decoded`() {
// "At time of event" encodes negative — the sentinel check must be the
// exact value, not any negative one.
val raw = toProviderAllDayMinutes(0, winter, berlin, nineAm)
assertThat(raw).isLessThan(0)
assertThat(fromProviderAllDayMinutes(raw, winter, berlin, nineAm)).isEqualTo(0)
}
@Test
fun `an encoding that would land on the sentinel is nudged off it`() {
// 01:01 Berlin in winter (CET, +1) is 00:01 UTC — one minute past the
// event's UTC-midnight DTSTART, i.e. exactly -1. Left there it would read
// back as "use the account default" and the real reminder would be gone
// from the screen and from an export.
val raw = toProviderAllDayMinutes(0, winter, berlin, 61)
assertThat(raw).isEqualTo(-2)
assertThat(fromProviderAllDayMinutes(raw, winter, berlin, 61)).isEqualTo(0)
}
@Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An
@@ -22,6 +22,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.time.Instant
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
@@ -642,7 +643,12 @@ class CalendarRepositoryImplTest {
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
val id = repo.updateOccurrence(
eventId = 42L,
beginMillis = 1_000L,
original = form,
form = form,
)
assertThat(id).isEqualTo(88L)
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
@@ -801,6 +807,113 @@ class CalendarRepositoryImplTest {
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
}
@Test
fun `importEvents carries on past an event the provider rejects`(
@TempDir tempDir: Path,
) = runTest {
// A foreign export can hold a row the provider throws on outright; that
// must cost the user one event, not the whole file (Codeberg #225).
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val events = listOf(
parsedEvent("a@x", summary = "good"),
parsedEvent("b@x", summary = "bad"),
parsedEvent("c@x", summary = "good"),
)
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
assertThat(summary.imported).isEqualTo(2)
assertThat(summary.failed).isEqualTo(1)
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
}
@Test
fun `importEvents stops at a permission failure and reports what landed`(
@TempDir tempDir: Path,
) = runTest {
// The permission or the target calendar is gone, so every remaining insert
// would fail the same way. Counted per event, the screen reported "N events
// couldn't be added" for a file it never had a chance at; thrown, it
// reported that nothing landed — and since only events carrying a UID dedup,
// the retry that invites duplicated everything that already had.
val fake = FakeCalendarDataSource().apply {
importsBeforeError = 2
writeError = SecurityException("WRITE_CALENDAR revoked")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val summary = repo.importEvents(
targetCalendarId = 3L,
events = List(5) { parsedEvent("e$it@x") },
)
assertThat(summary.imported).isEqualTo(2)
// The one that threw and the two never reached.
assertThat(summary.notAttempted).isEqualTo(3)
assertThat(summary.failed).isEqualTo(0)
}
@Test
fun `a stopped import does not count known duplicates as work left to do`(
@TempDir tempDir: Path,
) = runTest {
// The remainder is not all work: a UID already in the calendar would have
// been skipped whatever happened, so counting it against a retry
// overstates how much of the file is missing.
val fake = FakeCalendarDataSource().apply {
existingUidsResult = setOf("e3@x", "e4@x")
importsBeforeError = 1
writeError = SecurityException("WRITE_CALENDAR revoked")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val summary = repo.importEvents(
targetCalendarId = 3L,
events = List(5) { parsedEvent("e$it@x") },
)
assertThat(summary.imported).isEqualTo(1)
// e1 threw and e2 was never reached; e3 and e4 were duplicates either way.
assertThat(summary.notAttempted).isEqualTo(2)
assertThat(summary.skippedDuplicate).isEqualTo(2)
}
@Test
fun `importEvents looks the target palette up once, not per event`(
@TempDir tempDir: Path,
) = runTest {
var lookups = 0
val fake = FakeCalendarDataSource().apply {
publishedEventColorsResult = { lookups++; emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
assertThat(lookups).isEqualTo(1)
}
@Test
fun `importEvents matches colours against the uncurated palette`(
@TempDir tempDir: Path,
) = runTest {
// Curation is a picker concession — it folds look-alikes and drops the
// neutrals from an oversized palette — but every published key is one
// the calendar accepts, so an imported colour matches against all of
// them (Codeberg #225).
val published = listOf(EventColorOption("1", 0xFF808080.toInt()))
val fake = FakeCalendarDataSource().apply {
publishedEventColorsResult = { published }
eventColorPaletteResult = { emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.importEvents(3L, listOf(parsedEvent("a@x")))
assertThat(fake.lastImportPalette).isEqualTo(published)
}
@Test
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
@TempDir tempDir: Path,
@@ -825,9 +938,10 @@ class CalendarRepositoryImplTest {
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
private fun parsedEvent(uid: String?, summary: String = "E") =
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
summary = "E",
summary = summary,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,
@@ -128,7 +128,8 @@ class EventWriteMapperTest {
original: EventForm,
updated: EventForm,
series: Long = seriesStart,
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
exdate: String? = null,
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, exdate, berlin)
/** The instant [local] names in [zoneId], as the provider would store it. */
private fun instantAt(local: String, zoneId: String): Long =
@@ -497,6 +498,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -520,6 +522,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -535,6 +538,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -542,6 +546,27 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
}
@Test
fun `an all-day drop folds away a padded repeat of the same day`() {
// Compared verbatim, the two spellings of 15 July are different strings:
// the day ends up excluded twice, and exdateContains — the guard against
// detaching the same occurrence twice — misses it entirely.
val occurrence = 1_784_073_600_000L // 2026-07-15T00:00:00Z
val values = buildOccurrenceExdateValues(
existingExdate = "20260715T000000Z",
occurrenceMillis = occurrence,
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=WEEKLY",
rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
assertThat(exdateContains("20260715T000000Z", occurrence, isAllDay = true, timezone = null))
.isTrue()
}
@Test
fun `all-day exdate drop uses the date-only form`() {
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
@@ -550,6 +575,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=YEARLY",
rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
@@ -558,6 +584,439 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
}
@Test
fun `an exdate list zoned the way the provider writes it is read in that zone`() {
// AOSP's RecurrenceSet keeps the TZID parameter's *value*, not the
// parameter: `Europe/Berlin;<stamps>`, no `TZID=`. Matching only the
// iCalendar spelling read every list the provider itself wrote as if its
// floating stamps were UTC — hours off for any zone but UTC.
assertThat(exdateListZone("Europe/Berlin;20260708T100000", "UTC"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
assertThat(exdateListZone("TZID=Europe/Berlin;20260708T100000", "UTC"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
// No prefix, or one this device can't place: the row's own zone.
assertThat(exdateListZone("20260708T100000", "Europe/Berlin"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
assertThat(exdateListZone("W. Europe Standard Time;20260708T100000", "Europe/Berlin"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
}
@Test
fun `a bare-zoned exclusion list splits on the instants its prefix names`() {
assertThat(
exdateAfter(
"Europe/Berlin;20260708T100000,20260729T100000",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isEqualTo("20260729T080000Z")
}
@Test
fun `an exdate drop keeps a list it cannot read whole, prefix and all`() {
// Stripping the prefix off a list and re-emitting the stamps it can't
// parse leaves them floating: "09:00 in Berlin" silently becomes 09:00 in
// whatever zone the next reader picks.
val values = buildOccurrenceExdateValues(
existingExdate = "Europe/Berlin;20260722T090000",
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=WEEKLY",
rdate = null,
duration = "P1D",
timezone = "Europe/Berlin",
allDay = 1,
)
assertThat(values[CalendarContract.Events.EXDATE])
.isEqualTo("Europe/Berlin;20260722T090000,20260715")
}
@Test
fun `a new exclusion joins an unreadable timed list in that list's own form`() {
val values = buildOccurrenceExdateValues(
existingExdate = "Europe/Berlin;sometime next Tuesday",
occurrenceMillis = 1_784_102_400_000L, // 2026-07-15T08:00:00Z == 10:00 Berlin
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE])
.isEqualTo("Europe/Berlin;sometime next Tuesday,20260715T100000")
}
@Test
fun `an exdate drop rewrites RDATE along with the rule`() {
// A series may be a list of dates with no rule at all. Left out of the
// rewrite, the provider has only DTSTART to re-expand from and collapses
// the series to its first occurrence.
val values = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = null,
rdate = "20260716T080000Z,20260722T080000Z",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.RDATE])
.isEqualTo("20260716T080000Z,20260722T080000Z")
assertThat(values[CalendarContract.Events.RRULE]).isNull()
}
// --- EXDATE follows the series when its times change (Codeberg #248) ---
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:0010:00. */
private fun julySeries(): EventForm = form(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
private fun EventForm.atHour(hour: Int): EventForm = copy(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
)
@Test
fun `a series time edit moves its exclusions with the anchor`() {
// The bug: the stamp stayed at the old instant, which the moved series no
// longer generates, so the occurrence the user deleted came back.
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
// 8 July 09:00 Berlin (CEST, +2) == 07:00Z; at 10:00 it must read 08:00Z.
val values = update(original, original.atHour(10), series, "20260708T070000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260708T080000Z")
}
@Test
fun `an exclusion keeps its wall clock across a DST boundary`() {
// Anchor and edited occurrence are in July (CEST, +2); the excluded
// occurrence sits in January (CET, +1). Shifting by the millisecond delta
// measured at the edited occurrence would leave it an hour off.
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = julySeries()
// 14 January 09:00 Berlin == 08:00Z; at 10:00 it must read 09:00Z.
val values = update(original, original.atHour(10), series, "20260114T080000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260114T090000Z")
}
@Test
fun `pinning a series to another zone re-resolves its exclusions there`() {
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
val values = update(
original,
original.copy(timezone = "Asia/Tokyo"),
series,
"20260716T070000Z",
)
// The exclusion still reads 09:00 — now 09:00 in Tokyo (+9) == 00:00Z.
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260716T000000Z")
}
@Test
fun `an all-day series move shifts its exclusions by whole days`() {
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
)
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
.isEqualTo("20260724")
}
@Test
fun `an all-day exclusion a sync adapter padded still moves with the series`() {
// The bug: only the bare yyyyMMdd form parsed, so a padded midnight stamp
// bailed the whole list and buildEventUpdateValues wrote no EXDATE at all
// — the deleted occurrence came back the moment the series was re-timed.
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
)
val values = update(original, moved, series, "20260722T000000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
}
@Test
fun `an all-day exclusion padded without a Z is read the same way`() {
// DAVx5 and AOSP's own RecurrenceSet write a floating midnight for a
// date value. Only the trailing-Z spelling parsed, so the floating one
// bailed the whole list and the deleted occurrence came back.
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
)
val values = update(original, moved, series, "20260722T000000")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
}
@Test
fun `a padded all-day stamp naming any other time is still left alone`() {
// Not a form an all-day series is written in, so it could be naming
// something else entirely — guessing could exclude the wrong occurrence.
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
)
val values = update(original, moved, series, "20260722T090000Z")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `switching a series to all-day rewrites its exclusions as dates`() {
// The two forms aren't interchangeable: a date-time stamp on an all-day
// series matches no occurrence, so the exclusion would be lost.
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
val values = update(original, original.copy(isAllDay = true), series, "20260722T070000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722")
}
@Test
fun `switching a series back to timed rewrites its exclusions as instants`() {
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val timed = original.copy(
isAllDay = false,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
)
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
.isEqualTo("20260722T070000Z")
}
@Test
fun `a text-only edit leaves the exclusions alone`() {
val original = julySeries()
val values = update(original, original.copy(title = "Renamed"), exdate = "20260722T070000Z")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `changing only the rule rewrites no exclusions`() {
// The times are untouched, so every surviving occurrence keeps its instant
// and the stamps still name the right ones.
val original = julySeries()
val values = update(original, original.copy(rrule = "FREQ=DAILY"), exdate = "20260722T070000Z")
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `a zoned exdate list is read in the zone its prefix names`() {
// AOSP's RecurrenceSet form, TZID=<zone>;<floating stamps>. Read as UTC the
// stamps name the wrong instants; skipped, every exclusion of the series is
// dropped the moment it is re-timed.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=Europe/Berlin;20260722T090000",
)
// 22 July 09:00 Berlin (CEST, +2) == 07:00Z; the +1h edit puts it at 08:00Z.
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
}
@Test
fun `a zoned list whose zone this device cannot place falls back to the event's`() {
// Exchange spells zones its own way, so ZoneId.of refuses the name. The
// series' own zone is what such a list is written in anyway.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=W. Europe Standard Time;20260722T090000",
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
}
@Test
fun `an exdate form we do not write is left untouched`() {
// A stale stamp excludes nothing; a mangled one could exclude the wrong
// occurrence, so anything unreadable is left exactly as it stands.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"sometime next Tuesday",
)
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `removing the recurrence clears the exclusions with it`() {
// Dormant, not harmless: adding a recurrence back later would punch the old
// holes into the new one.
val original = julySeries()
val values = update(original, original.copy(rrule = null), exdate = "20260722T070000Z")
assertThat(values).containsEntry(CalendarContract.Events.EXDATE, null)
}
@Test
fun `undoing a series move lands the exclusions back where they started`() {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = julySeries()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
)
val exdate = "20260722T070000Z"
val forward = update(original, moved, series, exdate)
val movedExdate = forward[CalendarContract.Events.EXDATE] as String
assertThat(movedExdate).isNotEqualTo(exdate)
val back = update(
moved,
original,
forward[CalendarContract.Events.DTSTART] as Long,
movedExdate,
)
assertThat(back[CalendarContract.Events.EXDATE]).isEqualTo(exdate)
}
@Test
fun `a series with no exclusions writes no exdate column`() {
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
assertThat(update(original, original.atHour(10), series, exdate = null))
.doesNotContainKey(CalendarContract.Events.EXDATE)
}
// --- exdateAfter (the exclusions a "this and following" split inherits) ---
@Test
fun `a split carries only the exclusions past the split point`() {
// The occurrence at the split point is the one being edited, so it exists
// by definition — a stale exclusion for it would swallow the edit whole.
assertThat(
exdateAfter(
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isEqualTo("20260722T080000Z")
}
@Test
fun `a split carries nothing when every exclusion is behind it`() {
assertThat(
exdateAfter(
"20260708T080000Z",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isNull()
assertThat(exdateAfter(null, 0L, isAllDay = false, timezone = null)).isNull()
}
@Test
fun `all-day exclusions split on their UTC date`() {
assertThat(
exdateAfter(
"20260708,20260722",
instantAt("2026-07-15T00:00", "UTC"),
isAllDay = true,
timezone = null,
),
).isEqualTo("20260722")
}
@Test
fun `an unreadable exclusion carries nothing across a split`() {
assertThat(
exdateAfter(
"20260722T080000Z,sometime next Tuesday",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isNull()
}
@Test
fun `a zoned exclusion list splits on the instants its prefix names`() {
// Canonicalised on the way out: the prefix that explained how to read the
// stamps doesn't survive the filter, so the survivors can't stay floating.
assertThat(
exdateAfter(
"TZID=Europe/Berlin;20260708T100000,20260729T100000",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isEqualTo("20260729T080000Z")
}
@Test
fun `split exclusions move by the same shift as the new series start`() {
// What the split path composes: filter to the tail, then re-time it by the
// shift the user applied to the occurrence they split at.
val original = julySeries()
val carried = shiftedExdate(
existingExdate = exdateAfter(
"20260716T070000Z",
instantAt("2026-07-15T07:00", "UTC"),
isAllDay = false,
timezone = null,
),
original = original,
updated = original.atHour(11),
zone = berlin,
)
// 16 July 09:00 Berlin, pushed two hours, is 11:00 Berlin == 09:00Z.
assertThat(carried).isEqualTo("20260716T090000Z")
}
@Test
fun `nothing to shift yields no exdate`() {
assertThat(shiftedExdate(null, julySeries(), julySeries().atHour(10), berlin)).isNull()
assertThat(shiftedExdate(" ", julySeries(), julySeries().atHour(10), berlin)).isNull()
}
// --- per-event colour ---
@Test
@@ -746,4 +1205,173 @@ class EventWriteMapperTest {
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
}
@Test
fun `a detached all-day occurrence matches its date-only exdate stamp`() {
val parent = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
dtStartMillis = 1_749_600_000_000L,
rrule = "FREQ=YEARLY",
rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
val detached = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence()
val inserted = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611")
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
}
@Test
fun `a detached all-day occurrence stays on UTC midnights`() {
val edited = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
).copy(title = "Birthday", rrule = "FREQ=YEARLY")
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
// Exclusive DTEND — the next UTC midnight.
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
}
@Test
fun `a detached occurrence carries every edited field onto the new row`() {
// Built from the form, not cloned from the parent: a field dropped here
// is an edit silently lost.
val edited = form(timezone = "America/New_York").copy(
title = " Standup ",
location = "Room 2",
description = "notes",
reminders = listOf(10),
availability = Availability.Free,
accessLevel = AccessLevel.Private,
rrule = "FREQ=DAILY",
)
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2")
assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes")
assertThat(values[CalendarContract.Events.AVAILABILITY])
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
// The pinned zone survives — never re-anchored to the device.
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
// Reminders aren't columns; the insert path seeds them from the form.
assertThat(detached.reminders).containsExactly(10)
}
@Test
fun `a detached occurrence drops the series rule and becomes a one-off row`() {
val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH")
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
// A surviving rule would insert a second *series* overlapping the first
// (Codeberg #234's stray duplicate).
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
// A one-off row carries DTEND rather than a duration.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
}
@Test
fun `an exclusion is found anywhere in a multi-entry exdate list`() {
// Whitespace after a comma is legal in the stored column.
assertThat(
exdateContains(
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
1_781_164_800_000L,
isAllDay = false,
timezone = null,
),
).isTrue()
}
@Test
fun `an occurrence already excluded is recognised, timed and all-day`() {
// Detaching twice would leave a second standalone copy.
assertThat(
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
).isTrue()
assertThat(
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true, timezone = null),
).isTrue()
}
@Test
fun `an occurrence not in the exdate list is not mistaken for an excluded one`() {
assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false, timezone = null))
.isFalse()
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false, timezone = null))
.isFalse()
// A neighbouring occurrence must not match — the guard is per-instant.
assertThat(
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
).isFalse()
}
@Test
fun `the detached row lands exactly where the parent's exdate removes it`() {
// The two halves must agree on the instant, or the user sees the
// occurrence twice or not at all.
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
val occurrenceMillis = 1_781_164_800_000L
val parent = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = occurrenceMillis,
dtStartMillis = 1_780_560_000_000L,
rrule = "FREQ=WEEKLY",
rdate = null,
duration = "P5400S",
timezone = "Europe/Berlin",
allDay = 0,
)
val detached = edited.toDetachedOccurrence()
val inserted = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z")
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis)
// The parent keeps its own anchor and rule — only this occurrence leaves.
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
}
}
@@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
var lastExportableEventsCalendarIds: Set<Long>? = null
@@ -81,7 +82,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
publishedEventColorsResult(calendarId)
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult
}
@@ -91,8 +97,25 @@ internal class FakeCalendarDataSource : CalendarDataSource {
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
writeError?.let { throw it }
/** Thrown instead of [writeError] for events whose summary is in this set. */
val failingImportSummaries = mutableSetOf<String>()
/** Imports let through before [writeError] applies, for part-way failures. */
var importsBeforeError: Int = 0
private var imports = 0
/** The [colorPalette] the last [insertImportedEvent] call received. */
var lastImportPalette: List<EventColorOption> = emptyList()
override fun insertImportedEvent(
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long {
lastImportPalette = colorPalette
if (imports++ >= importsBeforeError) writeError?.let { throw it }
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
importedEvents += event to calendarId
return nextInsertId
}
@@ -173,6 +196,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
@@ -4,6 +4,7 @@ import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventStatus
import org.junit.jupiter.api.Test
import java.time.ZoneId
class IcsExportMapperTest {
@@ -20,7 +21,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
)
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
val event = reader.toIcsEvent(
reminderMinutes = listOf(10),
calendarName = "Personal",
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.uid).isEqualTo("abc@host")
assertThat(event.summary).isEqualTo("Standup")
@@ -47,7 +52,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.uid).isEqualTo("7-1000000@calendula")
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
@@ -65,6 +74,224 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue()
}
@Test
fun `an all-day reminder is exported as its whole-day lead time`() {
// The raw offset encodes the firing time and is normally negative; left
// raw it would be written as a trigger after the event and dropped, so
// the backup would come back with no reminder at all.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 1L,
EventExportProjection.IDX_TITLE to "Anna Birthday",
EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST
EventExportProjection.IDX_DTEND to 1_782_950_400_000L,
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN)
val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN)
assertThat(onTheDay.reminderMinutes).containsExactly(0)
assertThat(dayBefore.reminderMinutes).containsExactly(1_440)
}
private companion object {
const val NINE_AM = 9 * 60
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
}
@Test
fun `a recurring row carries its EXDATE exclusions`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 7L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to "20260901T080000Z,20260908T080000Z",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates)
.containsExactly("20260901T080000Z", "20260908T080000Z").inOrder()
}
@Test
fun `an all-day EXDATE keeps only the day, however the adapter padded it`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 8L,
EventExportProjection.IDX_TITLE to "Holiday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DURATION to "P1D",
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
EventExportProjection.IDX_EXDATE to "20260901,20270901T000000Z",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260901", "20270901").inOrder()
}
@Test
fun `a TZID-prefixed EXDATE is resolved to UTC, not handed on verbatim`() {
// The provider stores a zoned exclusion as AOSP RecurrenceSet does. Passed
// through, the writer would emit `EXDATE:TZID=Europe/Berlin;2026...` — the
// parameter belongs before the colon, so the line is unreadable.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 11L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to
"TZID=Europe/Berlin;20260722T090000,20260729T090000",
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
// 09:00 Berlin in July is CEST (+2) == 07:00Z.
assertThat(event.exDates)
.containsExactly("20260722T070000Z", "20260729T070000Z").inOrder()
}
@Test
fun `a zoned EXDATE in the provider's own bare form resolves in that zone`() {
// AOSP's RecurrenceSet stores the TZID parameter's value alone. Read as
// if it were unzoned, 09:00 Berlin exported as 09:00Z — two hours out,
// so the exclusion no longer matched its occurrence in the file.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 14L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to "Europe/Berlin;20260722T090000",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260722T070000Z")
}
@Test
fun `a TZID this device cannot place falls back to the row's own zone`() {
// Exchange spells zones its own way, so ZoneId.of refuses the name.
// Stripping the prefix and emitting the stamp as it stands would export a
// floating time against a `DTSTART;TZID=…` line — every reader in another
// zone resolves it to another instant and the exclusion stops matching.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 13L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to
"TZID=W. Europe Standard Time;20260722T090000",
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260722T070000Z")
}
@Test
fun `an all-day EXDATE loses a TZID prefix along with its time`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 12L,
EventExportProjection.IDX_TITLE to "Holiday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DURATION to "P1D",
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
EventExportProjection.IDX_EXDATE to "TZID=Europe/Berlin;20260901T000000",
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260901")
}
@Test
fun `a default-lead all-day reminder is not decoded into an at-start alarm`() {
// MINUTES_DEFAULT (-1) says "use the account default", not "0 minutes
// before". Decoded, it landed on the start date and became a real alarm
// that IcsWriter no longer dropped.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 13L,
EventExportProjection.IDX_TITLE to "Birthday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DURATION to "P1D",
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = listOf(-1),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
zone = BERLIN,
)
assertThat(event.reminderMinutes).containsExactly(-1)
}
@Test
fun `a one-off row exports no exclusions even if the column is set`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 9L,
EventExportProjection.IDX_TITLE to "Standup",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DTEND to 1_900_000L,
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_EXDATE to "20260901T080000Z",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).isEmpty()
}
}
@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ImportedEventValuesTest {
private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
private fun allDay(
rrule: String? = null,
exDates: List<String> = emptyList(),
color: Int? = null,
days: Int = 1,
) = ParsedIcsEvent(
uid = "u@x",
summary = "Anna Birthday",
start = dayStart,
end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
isAllDay = true,
zoneId = "UTC",
recurrenceRule = rrule,
exDates = exDates,
color = color,
)
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
@Test
fun `a one-off carries DTEND and no recurrence`() {
val v = values(allDay())
assertThat(v[CalendarContract.Events.DTEND])
.isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
}
@Test
fun `a recurring row carries DURATION instead of DTEND`() {
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `an all-day series is never zero days long`() {
// A degenerate span would expand into no instances at all — the series
// would simply not exist (Codeberg #225).
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `EXDATEs ride along with the recurrence`() {
val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
}
@Test
fun `EXDATEs are not written without a recurrence to exclude from`() {
val v = values(allDay(exDates = listOf("20260820")))
assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `an imported colour is written raw when the account publishes no palette`() {
val v = values(allDay(color = -2818048))
assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
}
@Test
fun `an imported colour snaps to the nearest published palette key`() {
val palette = listOf(
EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
)
// Tomato — visually an orangered, nothing like the blue or the green.
val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
// A raw colour alongside a key is what a palette calendar rejects.
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `an event with no colour touches neither colour column`() {
val v = values(allDay())
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
}
@Test
fun `a timed event keeps its own zone and a seconds duration`() {
val start = Instant.parse("2026-08-19T08:00:00Z")
val event = ParsedIcsEvent(
uid = "u@x",
summary = "Standup",
start = start,
end = Instant.parse("2026-08-19T08:15:00Z"),
isAllDay = false,
zoneId = "Europe/Berlin",
recurrenceRule = "FREQ=WEEKLY",
)
val v = values(event)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
@@ -17,6 +18,7 @@ class InstanceMapperTest {
eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null,
selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE,
): MapColumnReader = MapColumnReader(
InstanceProjection.IDX_INSTANCE_ID to instanceId,
InstanceProjection.IDX_EVENT_ID to eventId,
@@ -28,6 +30,7 @@ class InstanceMapperTest {
InstanceProjection.IDX_EVENT_COLOR to eventColor,
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
InstanceProjection.IDX_LOCATION to location,
InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus,
)
@Test
@@ -90,4 +93,20 @@ class InstanceMapperTest {
val inst = reader(location = "Berlin").toEventInstance()
assertThat(inst!!.location).isEqualTo("Berlin")
}
@Test
fun `a declined invitation is marked, any other answer is not`() {
assertThat(reader().toEventInstance()!!.isDeclined).isFalse()
assertThat(
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED)
.toEventInstance()!!.isDeclined,
).isTrue()
listOf(
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED,
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE,
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
).forEach { status ->
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
}
}
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The provider's "use the account default" sentinel is translated out here, so
* the pure planner only ever sees offsets it can resolve (#75).
*/
class ProviderReminderMinutesTest {
@Test
fun `the account-default sentinel is dropped`() {
// Taken as an offset it armed an alarm a minute *after* the event began,
// while the detail screen said "Default reminder" for the same row.
assertThat(mapOf(1L to listOf(-1)).withoutProviderDefaults())
.containsExactly(1L, emptyList<Int>())
}
@Test
fun `a real reminder beside a default one survives`() {
assertThat(mapOf(1L to listOf(-1, 10)).withoutProviderDefaults())
.containsExactly(1L, listOf(10))
}
@Test
fun `an all-day encoding that is negative is not mistaken for the sentinel`() {
// An all-day offset carries the firing time of day, so it is routinely
// negative; a `< 0` test would silence reminders that do fire.
assertThat(mapOf(1L to listOf(-420, -2_000)).withoutProviderDefaults())
.containsExactly(1L, listOf(-420, -2_000))
}
}
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsColorTest {
@Test
fun `a raw Android colour int round-trips`() {
assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048)
}
@Test
fun `a CSS3 name resolves opaque`() {
assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt())
assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt())
}
@Test
fun `hex forms are accepted with and without alpha`() {
assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt())
assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt())
}
@Test
fun `a colour with no alpha byte is made opaque`() {
// 0x00FF6347 as a decimal int — an RGB triple that lost its alpha.
assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt())
}
@Test
fun `unusable values resolve to null`() {
assertThat(parseIcsColorValue(null)).isNull()
assertThat(parseIcsColorValue("")).isNull()
// Fossify interpolates a nullable calendar straight into the property.
assertThat(parseIcsColorValue("null")).isNull()
assertThat(parseIcsColorValue("chartreusey")).isNull()
assertThat(parseIcsColorValue("#abc")).isNull()
}
}
@@ -0,0 +1,607 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/**
* Reading the Simple Calendar / Fossify export dialect (Codeberg #225).
*
* Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
* the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
*
* Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected":
* `Event.endTS` anchors a UI-created all-day event at noon of its last day
* (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a
* CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the
* following midnight. Shifting those by a day would corrupt them.
*
* The exception — and the whole of #225 — is events mirrored from Contacts.
* `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h`
* lands back on the starting day and they export zero length.
*/
class IcsFossifyImportTest {
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
private fun fossify(vararg body: String) = (
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Fossify//NONSGML Event Calendar//EN",
"VERSION:2.0",
) + body + listOf("END:VCALENDAR")
).joinToString("\r\n")
private fun days(event: ParsedIcsEvent): Long =
(event.end - event.start).inWholeMilliseconds / 86_400_000L
@Test
fun `an all-day event keeps the exact span the file gives it`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.isAllDay).isTrue()
assertThat(days(event)).isEqualTo(1)
}
@Test
fun `contact-mirrored birthdays export zero length and must not vanish`() {
// The reported failure (#225). Fossify mirrors Contacts birthdays and
// anniversaries with startTS == endTS, so the exporter's +12h rounds
// back to the starting day: DTSTART == DTEND. Read literally these are
// yearly series of zero-length occurrences, which the provider expands
// into nothing at all — the birthdays import "successfully" and are
// then nowhere to be seen.
val text = checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) }
val result = parser.parse(text)
assertThat(result.events).hasSize(3)
result.events.forEach {
assertThat(it.isAllDay).isTrue()
assertThat(days(it)).isEqualTo(1)
assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY")
// Their all-day reminder encoding: "on the day", not midnight UTC.
assertThat(it.semanticReminderMinutes()).containsExactly(0)
}
}
@Test
fun `a real Fossify holiday file imports unchanged`() {
// Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its
// PRODID names Fossify, so any producer-sniffing shortcut would corrupt
// it — the file is plain, conformant iCalendar.
val text = checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) }
val result = parser.parse(text)
assertThat(result.events.map { it.summary })
.containsExactly("Neujahr", "Heilige Drei Könige").inOrder()
assertThat(result.events.map { days(it) }).containsExactly(1L, 1L)
assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY")
}
@Test
fun `an all-day event with no DTEND lasts one day`() {
// RFC 5545 3.6.1, and the span the provider needs to expand a series.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a zero-length all-day event is widened rather than left to vanish`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a malformed repeat rule is repaired rather than passed to the provider`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
"END:VEVENT",
),
)
assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
}
@Test
fun `a task is imported as an event and reported`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.isTask).isTrue()
assertThat(task.summary).isEqualTo("Pay rent")
assertThat(days(task)).isEqualTo(1)
assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
}
@Test
fun `a task dated only by DUE still imports`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:File taxes",
"DUE;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.single().summary).isEqualTo("File taxes")
}
@Test
fun `a task with both DTSTART and DUE spans between them`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Write report",
"DTSTART:20260901T090000Z",
"DUE:20260901T170000Z",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
}
@Test
fun `a DUE ahead of the DTSTART is still the end`() {
// Property order isn't guaranteed; DUE must not be taken for the start.
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Write report",
"DUE:20260901T170000Z",
"DTSTART:20260901T090000Z",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
}
@Test
fun `the per-event colour wins over the calendar colour`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"COLOR:tomato",
"X-FOSSIFY-EVENT-COLOR:-2818048",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-2818048)
}
@Test
fun `the calendar colour stands in when the event has none of its own`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the legacy Simple Calendar colour spellings are read too`() {
val result = parser.parse(
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN",
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-SMT-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the literal null Fossify writes for a missing calendar is not a colour`() {
// Both properties interpolate a nullable calendar straight into the value.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:null",
"CATEGORIES:null",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.color).isNull()
assertThat(event.calendarName).isNull()
}
@Test
fun `CATEGORIES names the source calendar`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"CATEGORIES:Birthdays",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
}
@Test
fun `CATEGORIES does not outrank a property that names the calendar outright`() {
// Elsewhere CATEGORIES is a tag list, so it only stands in where nothing
// else named the calendar — X-WR-CALNAME and our own label win.
val result = IcsParser().parse(
listOf(
"BEGIN:VCALENDAR",
"X-WR-CALNAME:Work",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"CATEGORIES:Holiday",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().calendarName).isEqualTo("Work")
}
@Test
fun `X-WR-CALNAME names the calendar wherever in the file it sits`() {
// Why the property is scanned up front instead of in document order: an
// exporter that writes it at the tail still names the calendar every
// event in the file came from.
val result = IcsParser().parse(
listOf(
"BEGIN:VCALENDAR",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"X-WR-CALNAME:Work",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().calendarName).isEqualTo("Work")
}
@Test
fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"CATEGORIES:Meeting,Personal",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().calendarName).isNull()
}
@Test
fun `a reminder written as a positive trigger is not read as a lead time`() {
// The exporter flips the sign for reminders stored below -1
// (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can
// point after the start. On an all-day event that means a time of day.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:P0DT9H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.reminderMinutes).containsExactly(-540)
assertThat(event.semanticReminderMinutes()).containsExactly(0)
}
@Test
fun `an all-day reminder whose firing time is after noon keeps its full day`() {
// The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification
// time puts "1 day before" only six hours ahead of the UTC midnight.
// Rounding to the nearest day would read that as "on the day".
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT6H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `an all-day reminder rounds to whole days before`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
// 15h before midnight == "1 day before at 09:00".
"TRIGGER:-P0DT15H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `a timed reminder keeps its exact lead time`() {
// Their P0DT1H5M0S duration spelling, from Parser.getDurationCode.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT0H10M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
}
@Test
fun `a timed trigger that fires after the start is dropped, not clamped`() {
// A legal RFC 5545 follow-up alarm. Modelling it as a lead time of zero
// would invent a notification at the start the file never asked for.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:PT30M",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).isEmpty()
}
@Test
fun `an all-day series' EXDATE day codes carry over`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"EXDATE:20260822",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
}
@Test
fun `an all-day EXDATE keeps the day it spells out, whatever zone it carries`() {
// A midnight east of UTC resolves to an instant on the previous day;
// reading that back in UTC would exclude a day that isn't an occurrence
// and leave the one the user deleted in place.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"RRULE:FREQ=DAILY",
"EXDATE;TZID=Europe/Berlin:20260821T000000",
"EXDATE;VALUE=DATE:20260823",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates)
.containsExactly("20260821", "20260823").inOrder()
}
@Test
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
// They store excluded occurrences as day codes and write them out that
// way even for a timed series, where the property alone is ambiguous.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
}
@Test
fun `a floating EXDATE is read in the series' zone, not the device's`() {
// RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the
// value is New York wall time. Reading it in the device's Berlin zone
// would land on an instant no occurrence has, and the excluded
// occurrence would come back.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;TZID=America/New_York:20260819T100000",
"DTEND;TZID=America/New_York:20260819T103000",
"RRULE:FREQ=WEEKLY",
"EXDATE:20260826T100000",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260826T140000Z")
}
@Test
fun `EXDATE is dropped along with an unusable recurrence rule`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.recurrenceRule).isNull()
assertThat(event.exDates).isEmpty()
}
@Test
fun `a whole export imports every component`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"CATEGORIES:Birthdays",
"LAST-MODIFIED:20260101T120000Z",
"TRANSP:TRANSPARENT",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"DTSTAMP:20260819T100000Z",
"CLASS:PUBLIC",
"STATUS:CONFIRMED",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"UID:def456",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU",
"END:VEVENT",
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"UID:ghi789",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.map { it.summary })
.containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
// Nothing may reach the provider as a zero-length all-day row.
assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue()
}
}
@@ -165,4 +165,36 @@ class IcsParserTest {
assertThat(result.events.map { it.uid }).containsExactly("good")
assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
}
@Test
fun `round-trips a timed series' deleted occurrences`() {
val event = IcsEvent(
uid = "u20@calendula",
summary = "Weekly",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 13, 30),
isAllDay = false,
zoneId = "UTC",
recurrenceRule = "FREQ=WEEKLY",
exDates = listOf("20260625T130000Z"),
)
assertThat(roundTrip(event).exDates).containsExactly("20260625T130000Z")
}
@Test
fun `round-trips an all-day series' deleted occurrences`() {
val event = IcsEvent(
uid = "u21@calendula",
summary = "Holiday",
start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
isAllDay = true,
zoneId = "UTC",
recurrenceRule = "FREQ=YEARLY",
exDates = listOf("20270618"),
)
assertThat(roundTrip(event).exDates).containsExactly("20270618")
}
}
@@ -0,0 +1,166 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsRecurrenceTest {
@Test
fun `a well-formed rule is unchanged`() {
val result = sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
assertThat(result.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
assertThat(result.repaired).isFalse()
}
@Test
fun `the RRULE prefix is stripped`() {
assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY")
}
@Test
fun `an empty list part is dropped`() {
val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
assertThat(bareByDay.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(bareByDay.repaired).isTrue()
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=").rule)
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
}
@Test
fun `a nonsensical INTERVAL or COUNT is dropped, not fatal`() {
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;COUNT=0").rule).isEqualTo("FREQ=DAILY")
}
@Test
fun `a rule without a usable FREQ is unsalvageable`() {
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO").rule).isNull()
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1").rule).isNull()
assertThat(sanitizeRrule("FREQ=;INTERVAL=1").rule).isNull()
assertThat(sanitizeRrule("").rule).isNull()
assertThat(sanitizeRrule(null).rule).isNull()
}
@Test
fun `no rule at all is not a repair`() {
assertThat(sanitizeRrule(null).repaired).isFalse()
assertThat(sanitizeRrule("").repaired).isFalse()
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY").repaired).isTrue()
}
@Test
fun `normalising is not repairing`() {
// Case and a trailing separator change the text but lose nothing, and
// telling the user their file was faulty over either is crying wolf.
val lowercase = sanitizeRrule("freq=daily;count=3")
assertThat(lowercase.rule).isEqualTo("FREQ=DAILY;COUNT=3")
assertThat(lowercase.repaired).isFalse()
val trailing = sanitizeRrule("FREQ=DAILY;")
assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
assertThat(trailing.repaired).isFalse()
}
@Test
fun `parts the provider's parser has no entry for are dropped`() {
// EventRecurrence.parse throws on any part name outside its own table,
// so passing one on would cost the whole event (Codeberg #225).
val result = sanitizeRrule("FREQ=MONTHLY;RSCALE=GREGORIAN;BYMONTHDAY=15")
assertThat(result.rule).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
assertThat(result.repaired).isTrue()
assertThat(sanitizeRrule("FREQ=YEARLY;X-THING=7").rule).isEqualTo("FREQ=YEARLY")
}
@Test
fun `WKST survives, a nonsense weekday does not`() {
assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=SU").rule).isEqualTo("FREQ=WEEKLY;WKST=SU")
assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=XX").rule).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `a BYDAY item that isn't a weekday is dropped`() {
assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=-1FR,1,MO").rule)
.isEqualTo("FREQ=MONTHLY;BYDAY=-1FR,MO")
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=mo,tu").rule)
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,TU")
}
@Test
fun `a zero is dropped from the by-position parts only`() {
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=0,15").rule)
.isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=0,9").rule)
.isEqualTo("FREQ=DAILY;BYHOUR=0,9")
}
@Test
fun `an out-of-range numeric item is dropped`() {
// EventRecurrence.parseNumberList range-checks every one of these and
// throws out of insert, so being a number isn't enough (Codeberg #225).
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=15,32").rule)
.isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=YEARLY;BYMONTH=13").rule).isEqualTo("FREQ=YEARLY")
assertThat(sanitizeRrule("FREQ=YEARLY;BYWEEKNO=60").rule).isEqualTo("FREQ=YEARLY")
assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=400").rule).isEqualTo("FREQ=YEARLY")
assertThat(sanitizeRrule("FREQ=DAILY;BYSECOND=60").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").repaired).isTrue()
}
@Test
fun `the edges of each range survive`() {
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=-31,31").repaired).isFalse()
assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=-366,366;BYWEEKNO=-53,53").repaired)
.isFalse()
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=23;BYMINUTE=59;BYSECOND=59").repaired)
.isFalse()
// BYSETPOS is the one part the provider leaves unbounded.
assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400").rule)
.isEqualTo("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400")
}
@Test
fun `an UNTIL that isn't a timestamp is dropped`() {
assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=20260819T235959Z").rule)
.isEqualTo("FREQ=DAILY;UNTIL=20260819T235959Z")
assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=2026-08-19").rule).isEqualTo("FREQ=DAILY")
}
@Test
fun `UNTIL and COUNT together keep UNTIL`() {
// EventRecurrence.parse throws outright on a rule carrying both.
val both = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=20260101T000000Z")
assertThat(both.rule).isEqualTo("FREQ=WEEKLY;UNTIL=20260101T000000Z")
assertThat(both.repaired).isTrue()
}
@Test
fun `a COUNT the UNTIL check never sees is left alone`() {
// An UNTIL dropped for its shape doesn't take the COUNT down with it.
val staleUntil = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=2026-01-01")
assertThat(staleUntil.rule).isEqualTo("FREQ=WEEKLY;COUNT=10")
assertThat(staleUntil.repaired).isTrue()
}
@Test
fun `a part without a value separator is reported as repaired`() {
val noEquals = sanitizeRrule("FREQ=WEEKLY;BYDAY")
assertThat(noEquals.rule).isEqualTo("FREQ=WEEKLY")
assertThat(noEquals.repaired).isTrue()
}
@Test
fun `a trailing separator is normalisation, not a repair`() {
val trailing = sanitizeRrule("FREQ=DAILY;")
assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
assertThat(trailing.repaired).isFalse()
}
}
@@ -149,4 +149,55 @@ class IcsWriterTest {
// Stable across calls — a re-export of the same row yields the same UID.
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000))
}
@Test
fun `a timed series writes its exclusions as UTC stamps`() {
val event = IcsEvent(
uid = "u9@calendula",
summary = "Weekly",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 13, 30),
isAllDay = false,
zoneId = "UTC",
recurrenceRule = "FREQ=WEEKLY",
exDates = listOf("20260625T130000Z", "20260702T130000Z"),
)
assertThat(lines(listOf(event)))
.contains("EXDATE:20260625T130000Z,20260702T130000Z")
}
@Test
fun `an all-day series marks its exclusions VALUE=DATE`() {
val start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC)
val event = IcsEvent(
uid = "u10@calendula",
summary = "Holiday",
start = start,
end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
isAllDay = true,
zoneId = "UTC",
recurrenceRule = "FREQ=YEARLY",
exDates = listOf("20270618"),
)
// RFC 5545 ties EXDATE's value type to DTSTART's; a bare day code
// without VALUE=DATE reads as a malformed DATE-TIME.
assertThat(lines(listOf(event))).contains("EXDATE;VALUE=DATE:20270618")
}
@Test
fun `a one-off event never writes an EXDATE`() {
val event = IcsEvent(
uid = "u11@calendula",
summary = "Standup",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 13, 30),
isAllDay = false,
zoneId = "UTC",
exDates = listOf("20260625T130000Z"),
)
assertThat(lines(listOf(event)).none { it.startsWith("EXDATE") }).isTrue()
}
}
@@ -286,9 +286,53 @@ class RescheduleViewModelTest {
vm.move(toWednesday())
advanceUntilIdle()
val abandoned = vm.abandoned.value
assertThat(vm.undo(undo)).isFalse()
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(1)
// Nothing ticks: the refusal started no journey of its own, and the
// counter is global — a tick here would release the copy the drop that
// *is* in flight is holding, before the grid has drawn it.
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
fun `an undo that fails lets the held chip go`(@TempDir tempDir: Path) = runTest(dispatcher) {
// undoStarted has already sent the chip back on its journey by the time
// the inverse write is attempted. Without a tick here nothing ever seats
// it, and the overlay waits out its whole settle timeout for a chip that
// is never coming.
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandoned.value
fake.writeError = SecurityException("permission revoked")
assertThat(vm.undo(undo)).isTrue()
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
}
@Test
fun `an undo that lands leaves the abandoned count alone`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandoned.value
vm.undo(undo)
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
@@ -494,4 +538,61 @@ class RescheduleViewModelTest {
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
@Test
fun `a drop that lands nothing says so, since move() already answered yes`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
// Refused in prepare: nowhere to move to.
assertThat(vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
),
)).isTrue()
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(1)
// Refused by the provider, after the write was attempted.
fake.writeError = SecurityException("revoked")
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandoned.value).isEqualTo(2)
}
@Test
fun `a scope dialog dismissed leaves nothing waiting on the write`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(0)
vm.cancelScope()
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(1)
}
@Test
fun `a written move never counts as abandoned`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
assertThat(vm.abandoned.value).isEqualTo(0)
}
}
@@ -0,0 +1,209 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.ui.unit.dp
import org.junit.jupiter.api.Test
/**
* Which edges a timed block is cut on, and how much of a dragged piece its day
* can show (#253).
*/
class TimedBlockShapeTest {
private val zone = TimeZone.UTC
private val day1 = LocalDate(2026, 9, 5)
private val day2 = day1.plus(1, DateTimeUnit.DAY)
private val day3 = day2.plus(1, DateTimeUnit.DAY)
private fun event(from: LocalDateTimeIsh, to: LocalDateTimeIsh) = EventInstance(
instanceId = 1L, eventId = 1L, calendarId = 1L, title = "E",
start = from.date.atTime(from.hour, 0).toInstant(zone),
end = to.date.atTime(to.hour, 0).toInstant(zone),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
data class LocalDateTimeIsh(val date: LocalDate, val hour: Int)
private fun at(date: LocalDate, hour: Int) = LocalDateTimeIsh(date, hour)
@Test
fun `a same-day block is cut on neither edge`() {
val ev = event(at(day1, 9), at(day1, 10))
val block = layoutDay(listOf(ev), day1, zone).single()
assertThat(block.continuesBefore(day1, zone)).isFalse()
assertThat(block.continuesAfter(day1, zone)).isFalse()
}
@Test
fun `an overnight event is cut at the foot of its first day and the head of its second`() {
val ev = event(at(day1, 20), at(day2, 8))
val head = layoutDay(listOf(ev), day1, zone).single()
assertThat(head.continuesBefore(day1, zone)).isFalse()
assertThat(head.continuesAfter(day1, zone)).isTrue()
val tail = layoutDay(listOf(ev), day2, zone).single()
assertThat(tail.continuesBefore(day2, zone)).isTrue()
assertThat(tail.continuesAfter(day2, zone)).isFalse()
}
@Test
fun `a day wholly inside a long event is cut on both edges`() {
val ev = event(at(day1, 20), at(day3, 8))
val middle = layoutDay(listOf(ev), day2, zone).single()
assertThat(middle.continuesBefore(day2, zone)).isTrue()
assertThat(middle.continuesAfter(day2, zone)).isTrue()
}
@Test
fun `an event ending exactly at midnight is not cut`() {
val ev = event(at(day1, 20), at(day2, 0))
val block = layoutDay(listOf(ev), day1, zone).single()
assertThat(block.continuesAfter(day1, zone)).isFalse()
}
@Test
fun `only the cut edges lose their corners`() {
val square = 0.dp
assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).topStart)
.isEqualTo(CornerSize(square))
assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).bottomStart)
.isEqualTo(CornerSize(EVENT_CHIP_CORNER))
assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).topStart)
.isEqualTo(CornerSize(EVENT_CHIP_CORNER))
assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).bottomEnd)
.isEqualTo(CornerSize(square))
}
private val fourHours = 4 * 60
@Test
fun `an event that fits its day is drawn as one uncut piece`() {
val pieces = dragSlices(eventStartMin = 10 * 60, spanMin = fourHours)
assertThat(pieces).hasSize(1)
assertThat(pieces.single().dayOffset).isEqualTo(0)
assertThat(pieces.single().spanMin).isEqualTo(fourHours)
assertThat(pieces.single().continuesBefore).isFalse()
assertThat(pieces.single().continuesAfter).isFalse()
}
@Test
fun `dragging the start half re-splits both days as it moves`() {
// Held at 22:00: two hours on this day, two on the next.
assertThat(dragSlices(22 * 60, fourHours).map { it.spanMin })
.containsExactly(2 * 60, 2 * 60).inOrder()
// Carried up to 21:00 the first day takes three, so the second day's end
// comes up by exactly the hour the first day gained.
assertThat(dragSlices(21 * 60, fourHours).map { it.spanMin })
.containsExactly(3 * 60, 60).inOrder()
}
@Test
fun `dragging the end half splits the same way, one day back`() {
// The tail's top edge is at midnight and the event began two hours
// earlier, so it starts at -120 relative to the day the finger is on.
val pieces = dragSlices(eventStartMin = -2 * 60, spanMin = fourHours)
assertThat(pieces.map { it.dayOffset }).containsExactly(-1, 0).inOrder()
assertThat(pieces.map { it.spanMin }).containsExactly(2 * 60, 2 * 60).inOrder()
// The piece on the earlier day ends at midnight; the held one starts there.
assertThat(pieces.first().startMin).isEqualTo(22 * 60)
assertThat(pieces.first().continuesAfter).isTrue()
assertThat(pieces.last().startMin).isEqualTo(0)
assertThat(pieces.last().continuesBefore).isTrue()
}
@Test
fun `the earlier half shrinks away as the end half is pulled down its own day`() {
// Tail top dragged from midnight down to 01:00: one hour left behind.
assertThat(dragSlices(-2 * 60 + 60, fourHours).map { it.spanMin })
.containsExactly(60, 3 * 60).inOrder()
// At 02:00 the event no longer crosses midnight at all.
val whole = dragSlices(0, fourHours)
assertThat(whole).hasSize(1)
assertThat(whole.single().continuesBefore).isFalse()
}
@Test
fun `a day wholly inside a long event is cut on both sides`() {
val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 30 * 60)
assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
assertThat(pieces[1].spanMin).isEqualTo(MINUTES_PER_DAY)
assertThat(pieces[1].continuesBefore).isTrue()
assertThat(pieces[1].continuesAfter).isTrue()
}
@Test
fun `an event ending exactly at midnight takes no piece of the next day`() {
val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 2 * 60)
assertThat(pieces).hasSize(1)
assertThat(pieces.single().continuesAfter).isFalse()
}
@Test
fun `a zero-length event still has a piece to draw`() {
val pieces = dragSlices(eventStartMin = 9 * 60, spanMin = 0)
assertThat(pieces).hasSize(1)
assertThat(pieces.single().dayOffset).isEqualTo(0)
assertThat(pieces.single().startMin).isEqualTo(9 * 60)
assertThat(pieces.single().spanMin).isEqualTo(0)
}
@Test
fun `only the days there are columns for are sliced`() {
// A week's worth of event, previewed on a timeline showing three days
// from the one the finger is on.
val pieces = dragSlices(0, 7 * MINUTES_PER_DAY, within = 0..2)
assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
assertThat(pieces.last().continuesAfter).isTrue()
}
@Test
fun `an event outside the columns shown is sliced into nothing`() {
assertThat(dragSlices(-3 * MINUTES_PER_DAY, 60, within = 0..2)).isEmpty()
}
// --- the copy never blinks out from under the finger (#253) ---
@Test
fun `a short tail dragged to its floor still has a piece to draw`() {
// 23:50-00:05: on the tail's day the clip offset is 10 and the floor is
// one snap step above midnight, which puts the whole event on the day
// before. Day view has no column for that day, so dragSlices hands back
// nothing and the floating copy used to vanish mid-gesture.
val span = 15
val floor = dragFloorMin(clipOffsetMin = 10, eventSpanMin = span)
val eventStartMin = floor - 10
val visible = 0..0
assertThat(dragSlices(eventStartMin, span, visible)).isEmpty()
val edge = edgeDragSlice(eventStartMin, span, visible)
assertThat(edge).isNotNull()
assertThat(edge!!.dayOffset).isEqualTo(0)
assertThat(edge.startMin).isEqualTo(0)
assertThat(edge.continuesBefore).isTrue()
}
@Test
fun `an event a visible column does hold gets no edge piece`() {
assertThat(edgeDragSlice(9 * 60, 60, 0..0)).isNull()
}
}
@@ -0,0 +1,156 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* What a timeline drop asks for, for the head and the tail of an event that
* crosses midnight (#253). The tail's top edge is midnight rather than the
* event's start, so the drop has to take that offset back off.
*/
class TimelineDropTest {
private val zone = TimeZone.UTC
private val sat = LocalDate(2026, 9, 5)
private val sun = sat.plus(1, DateTimeUnit.DAY)
// Sat 5 Sep 20:00 to Sun 6 Sep 08:00.
private val overnight = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 1L,
title = "Night shift",
start = sat.atTime(20, 0).toInstant(zone),
end = sun.atTime(8, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
private fun blockOn(day: LocalDate): TimedBlock =
layoutDay(listOf(overnight), day, zone).single()
@Test
fun `the head block carries no clip offset`() {
val head = blockOn(sat)
assertThat(head.startMin).isEqualTo(20 * 60)
assertThat(head.clipOffsetMinutes(sat, zone)).isEqualTo(0)
}
@Test
fun `the tail block is offset by the part that already ran`() {
val tail = blockOn(sun)
assertThat(tail.startMin).isEqualTo(0)
// 20:00 to midnight is four hours of the event already gone.
assertThat(tail.clipOffsetMinutes(sun, zone)).isEqualTo(4 * 60)
}
@Test
fun `dropping the head asks for exactly where it landed`() {
val target = LocalDate(2026, 9, 2)
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone))
}
@Test
fun `dropping the tail moves the event by the days the tail travelled`() {
val tail = blockOn(sun)
val offset = tail.clipOffsetMinutes(sun, zone)
// Dragged three days back, same height in the column: Sun 6 -> Thu 3.
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset)
// The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 —
// the whole event moved three days, and no time of day was invented.
assertThat(drop.startInstant(zone))
.isEqualTo(LocalDate(2026, 9, 2).atTime(20, 0).toInstant(zone))
}
@Test
fun `dragging the tail down the column re-times the event across midnight`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// Tail top pulled from 00:00 to 03:00 on its own day.
val drop = TimelineDrop(overnight, sun, 3 * 60, offset)
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone))
}
@Test
fun `pulling the tail above its own midnight moves the event earlier`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// The tail's top edge dragged an hour above the midnight it rests on.
val drop = TimelineDrop(overnight, sun, -60, offset)
// Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier.
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone))
}
@Test
fun `a top edge dragged past the next midnight resolves onto the day after`() {
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone))
}
@Test
fun `a short tail can still be dragged a step earlier`() {
// 23:50 to 00:05: five minutes of tail, less than one snap step, which
// a floor pinned at midnight left with nowhere to go (#253).
assertThat(dragFloorMin(clipOffsetMin = 10, eventSpanMin = 15))
.isEqualTo(-DRAG_SNAP_MINUTES)
}
@Test
fun `a long tail may rise until its event ends just inside the day`() {
// Sat 20:00 to Sun 08:00, held by the Sunday half.
assertThat(dragFloorMin(clipOffsetMin = 4 * 60, eventSpanMin = 12 * 60))
.isEqualTo(DRAG_SNAP_MINUTES - 8 * 60)
}
@Test
fun `an unclipped block cannot be dragged above its own midnight`() {
assertThat(dragFloorMin(clipOffsetMin = 0, eventSpanMin = 60)).isEqualTo(0)
}
@Test
fun `a drop across a DST boundary keeps the time of day it was let go at`() {
// Europe/Berlin springs forward at 02:00 on 29 March 2026, between the
// event's start and the midnight its tail is cut at.
val berlin = TimeZone.of("Europe/Berlin")
val sun = LocalDate(2026, 3, 29)
val overnightIntoDst = EventInstance(
instanceId = 3L, eventId = 3L, calendarId = 1L, title = "Night shift",
start = LocalDate(2026, 3, 28).atTime(22, 0).toInstant(berlin),
end = sun.atTime(6, 0).toInstant(berlin),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single()
val offset = tail.clipOffsetMinutes(sun, berlin)
// Dropped back onto its own slot a week earlier, clear of the boundary.
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset)
assertThat(drop.startInstant(berlin))
.isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin))
}
@Test
fun `a block that is not clipped is unaffected wherever it sits`() {
val plain = EventInstance(
instanceId = 2L, eventId = 2L, calendarId = 1L, title = "Standup",
start = sat.atTime(9, 0).toInstant(zone),
end = sat.atTime(10, 0).toInstant(zone),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
val block = layoutDay(listOf(plain), sat, zone).single()
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
}
}
@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.content.ContextWrapper
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
/**
* Re-opening an occurrence must re-read it (#196): the view model outlives the
* sheet, so an edit that changed no time would otherwise show the pre-save row.
* The re-read stays silent — the loaded content must not blink back to the
* skeleton on the way.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class EventDetailViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L
private fun detail(description: String?) = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
isAllDay = false, color = 0xFF000000.toInt(), location = null,
),
description = description, organizer = null, attendees = emptyList(), rrule = null,
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
val prefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("detail_prefs.preferences_pb").toFile() },
),
)
val settings = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("detail_settings.preferences_pb").toFile() },
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher)
}
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
calendarsResult = listOf(
CalendarSource(
id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
),
)
eventDetailResult = { detail(description()) }
}
@Test
fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) {
var stored: String? = null
val vm = viewModel(tempDir, fakeSource { stored })
val collector = launch(Job()) { vm.state.collect {} }
vm.open(42L, beginMillis, endMillis)
advanceUntilIdle()
assertThat((vm.state.value as EventDetailUiState.Success).detail.description).isNull()
// The edit screen saved a description; the tapped occurrence is unchanged.
stored = "Bring the roadmap"
vm.open(42L, beginMillis, endMillis)
advanceUntilIdle()
assertThat((vm.state.value as EventDetailUiState.Success).detail.description)
.isEqualTo("Bring the roadmap")
collector.cancel()
}
@Test
fun `the re-read does not fall back to the skeleton`(@TempDir tempDir: Path) = runTest(dispatcher) {
val vm = viewModel(tempDir, fakeSource { null })
val seen = mutableListOf<EventDetailUiState>()
val collector = launch(Job()) { vm.state.collect { seen += it } }
vm.open(42L, beginMillis, endMillis)
advanceUntilIdle()
assertThat(vm.state.value).isInstanceOf(EventDetailUiState.Success::class.java)
seen.clear()
vm.open(42L, beginMillis, endMillis)
advanceUntilIdle()
assertThat(seen).doesNotContain(EventDetailUiState.Loading)
collector.cancel()
}
}
@@ -0,0 +1,89 @@
package de.jeanlucmakiola.calendula.ui.imports
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/** Which calendar a bulk import lands on before the user touches the picker. */
class ImportTargetTest {
private fun cal(id: Long, name: String, local: Boolean = false) = CalendarSource(
id = id,
displayName = name,
accountName = "acc@local",
accountType = if (local) "LOCAL" else "com.google",
color = 0,
isVisibleInSystem = true,
isLocal = local,
)
private val calendars = listOf(
cal(1L, "Work"),
cal(2L, "Personal", local = true),
cal(3L, "Birthdays", local = true),
)
@Test
fun `a calendar named like the file's wins`() {
assertThat(defaultImportTarget(calendars, "Birthdays")).isEqualTo(3L)
}
@Test
fun `the name match ignores case`() {
assertThat(defaultImportTarget(calendars, "birthdays")).isEqualTo(3L)
}
@Test
fun `an unmatched name falls back to the first local calendar`() {
assertThat(defaultImportTarget(calendars, "Holidays")).isEqualTo(2L)
}
@Test
fun `no name at all falls back to the first local calendar`() {
assertThat(defaultImportTarget(calendars, null)).isEqualTo(2L)
}
@Test
fun `with no local calendars the first of any kind is taken`() {
assertThat(defaultImportTarget(listOf(cal(1L, "Work")), null)).isEqualTo(1L)
}
@Test
fun `an empty list preselects nothing`() {
assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull()
}
private fun event(calendarName: String?) = ParsedIcsEvent(
uid = "u@x",
summary = "E",
start = Instant.fromEpochMilliseconds(0L),
end = Instant.fromEpochMilliseconds(3_600_000L),
isAllDay = false,
zoneId = "UTC",
calendarName = calendarName,
)
@Test
fun `a file whose events all name one calendar names it`() {
assertThat(fileCalendarName(List(3) { event("Birthdays") })).isEqualTo("Birthdays")
}
@Test
fun `disagreeing events name none`() {
assertThat(fileCalendarName(listOf(event("Birthdays"), event("Anniversaries")))).isNull()
}
@Test
fun `one tagged event among unnamed ones names none`() {
// CATEGORIES is a tag list everywhere but the Fossify family, so a lone
// tagged event must not decide where the whole file lands.
assertThat(fileCalendarName(listOf(event("Holiday")) + List(9) { event(null) })).isNull()
}
@Test
fun `a file that names nothing names none`() {
assertThat(fileCalendarName(List(3) { event(null) })).isNull()
}
}
@@ -0,0 +1,122 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* Which chip a tap in a month cell lands on (#187) — the geometry the tap layer
* uses to tell "open this event" from "open this day", given that the chips take
* no pointer input of their own.
*/
class ChipAtCellYTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
/** Band starts 40px down the cell; each lane is 20px tall. */
private val bandTop = 40f
private val laneHeight = 20f
/** July 2026 starts on a Wednesday, so this row — Jul 612 — sits wholly inside it. */
private fun rowOfJuly6(events: List<EventInstance>) =
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(zone),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = 0xFF2196F3.toInt(),
location = null,
)
private fun timed(date: LocalDate, hour: Int, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = date.atTime(hour, 0).toInstant(zone),
end = date.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFFF44336.toInt(),
location = null,
)
private fun MonthWeek.chipAt(col: Int, cellY: Float) =
chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight)
@Test
fun `a tap on a lane resolves to the chip seated there`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
val week = rowOfJuly6(listOf(bar, meeting))
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
assertThat(week.chipAt(col = 1, cellY = bandTop + 5f)?.eventId).isEqualTo(1L)
assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight + 5f)?.eventId).isEqualTo(2L)
}
@Test
fun `a multi-day bar answers on every column it covers`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val week = rowOfJuly6(listOf(bar))
(1..3).forEach { col ->
assertThat(week.chipAt(col = col, cellY = bandTop + 5f)?.eventId).isEqualTo(1L)
}
// Jul 10 is past the bar's last day.
assertThat(week.chipAt(col = 4, cellY = bandTop + 5f)).isNull()
}
@Test
fun `a tap above the band is the day number, not a chip`() {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
assertThat(week.chipAt(col = 1, cellY = bandTop - 1f)).isNull()
assertThat(week.chipAt(col = 1, cellY = 0f)).isNull()
}
@Test
fun `a tap on an empty lane of a day that has chips falls through to the day`() {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight * 2 + 5f)).isNull()
}
@Test
fun `a tap on the overflow row opens the day rather than a hidden event`() {
val events = (1..MAX_EVENT_ROWS + 2).map {
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
}
val week = rowOfJuly6(events)
// The dots sit one lane below the last one the row draws.
val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
}
@Test
fun `unmeasured geometry resolves to no chip`() {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight),
).isNull()
assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f),
).isNull()
}
}
@@ -0,0 +1,120 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* Where a week row draws a moved chip — what the settling copy glides onto
* (#68, #253).
*/
class ChipSeatTest {
private val monday = LocalDate(2026, 6, 8)
private val days = (0..6).map { monday.plus(it, DateTimeUnit.DAY) }
private fun event(id: Long, title: String = "E") = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = monday.atTime(9, 0).toInstant(TimeZone.UTC),
end = monday.atTime(10, 0).toInstant(TimeZone.UTC),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
/**
* A grid holding [seated] in lane 0 as one bar across [cols], and nothing
* else — the seating a [MonthWeek] gives a span.
*/
private fun row(seated: EventInstance, cols: IntRange) = MonthWeek(
days = days,
spans = listOf(
MonthSpan(
event = seated,
startCol = cols.first,
endCol = cols.last,
lane = 0,
continuesLeft = false,
continuesRight = false,
),
),
timedByDay = emptyMap(),
countByDay = emptyMap(),
)
/** A grid holding one single-day pill per day of [cols], all of [seated]. */
private fun pills(seated: EventInstance, cols: IntRange) = MonthWeek(
days = days,
spans = emptyList(),
timedByDay = cols.associate { days[it] to listOf(seated) },
countByDay = emptyMap(),
)
private fun seat(week: MonthWeek, event: EventInstance, date: LocalDate) = chipSeat(
days = days,
laneCount = 3,
chipAt = { col, lane -> week.chipAt(col, lane, 3) },
chipStart = { col, lane -> week.chipStartCol(col, lane) },
event = event,
date = date,
)
@Test
fun `a single-day chip is seated in its own column`() {
val ev = event(1L)
assertThat(seat(row(ev, 3..3), ev, days[3])).isEqualTo(3 to 0)
}
@Test
fun `a bar is seated from its first column, not the day it was dropped on`() {
val ev = event(1L)
// Covers WedThu; dropped on the Thursday it now runs into.
assertThat(seat(row(ev, 2..3), ev, days[3])).isEqualTo(2 to 0)
}
@Test
fun `a bar carried in from the previous week is seated at column zero`() {
val ev = event(1L)
assertThat(seat(row(ev, 0..4), ev, days[4])).isEqualTo(0 to 0)
}
@Test
fun `a row that does not hold the event seats nothing`() {
val ev = event(1L)
assertThat(seat(row(ev, 2..3), ev, days[5])).isNull()
assertThat(seat(row(ev, 2..3), ev, LocalDate(2026, 7, 1))).isNull()
}
@Test
fun `a re-read chip is matched by title once its event id has changed`() {
// A single-occurrence move writes a new event id; the title survives.
val moved = event(1L, title = "Standup")
val reRead = event(9L, title = "Standup")
assertThat(seat(row(reRead, 3..3), moved, days[3])).isEqualTo(3 to 0)
}
@Test
fun `two untitled events are not the same event`() {
val moved = event(1L, title = "")
val other = event(2L, title = "")
assertThat(isSameEvent(other, moved)).isFalse()
assertThat(seat(row(other, 3..3), moved, days[3])).isNull()
}
@Test
fun `a daily series seats each occurrence in its own column`() {
// Every column holds an occurrence of the same event id, so a walk left
// by identity would run to the start of the row (#253).
val ev = event(1L, title = "Standup")
assertThat(seat(pills(ev, 0..6), ev, days[4])).isEqualTo(4 to 0)
}
}
@@ -44,6 +44,19 @@ class MonthLayoutTest {
location = null,
)
/** A timed event running from [date] 20:00 into the next day at [endHour]. */
private fun overnight(date: LocalDate, endHour: Int = 8) = EventInstance(
instanceId = 7L,
eventId = 7L,
calendarId = 1L,
title = "Night shift",
start = at(date, 20),
end = at(date.plus(1, DateTimeUnit.DAY), endHour),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
/** All-day events live at UTC midnights with an *exclusive* end. */
private fun allDay(
from: LocalDate,
@@ -162,6 +175,43 @@ class MonthLayoutTest {
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
}
@Test
fun `an event crossing midnight is one bar, not two pills, on both sides of a week seam`() {
// Sun 14 Jun 20:00 to Mon 15 Jun 08:00 — the seam of a Monday-anchored
// grid: one day in this row, one in the next. Counting per row made each
// a standalone pill with nothing saying they were one event (#253).
val overnight = overnight(LocalDate(2026, 6, 14))
val head = layoutCalendarWeek(weekOf8th, listOf(overnight), zone)
assertThat(head.timedByDay.values.flatten()).isEmpty()
val headSpan = head.spans.single()
assertThat(headSpan.startCol).isEqualTo(6)
assertThat(headSpan.endCol).isEqualTo(6)
assertThat(headSpan.continuesLeft).isFalse()
assertThat(headSpan.continuesRight).isTrue()
val tailRow = layoutCalendarWeek(
weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
listOf(overnight),
zone,
)
assertThat(tailRow.timedByDay.values.flatten()).isEmpty()
val tailSpan = tailRow.spans.single()
assertThat(tailSpan.startCol).isEqualTo(0)
assertThat(tailSpan.endCol).isEqualTo(0)
assertThat(tailSpan.continuesLeft).isTrue()
assertThat(tailSpan.continuesRight).isFalse()
}
@Test
fun `an event ending exactly at midnight stays a single-day pill`() {
val untilMidnight = overnight(LocalDate(2026, 6, 10), endHour = 0)
val week = layoutCalendarWeek(weekOf8th, listOf(untilMidnight), zone)
assertThat(week.spans).isEmpty()
assertThat(week.timedByDay[LocalDate(2026, 6, 10)]).hasSize(1)
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
}
@Test
fun `countByDay totals bars and pills on each date`() {
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
@@ -0,0 +1,189 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Intent
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* The rollover alarm is what makes the widgets stop highlighting yesterday
* (#228), so the "when is the next local midnight" arithmetic is the one piece
* worth pinning down — especially where midnight is not 00:00.
*/
class WidgetRolloverSchedulerTest {
private val berlin = TimeZone.of("Europe/Berlin")
private fun at(local: String, zone: TimeZone): Instant =
LocalDateTime.parse(local).toInstant(zone)
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
// --- the ordinary day ----------------------------------------------------
@Test
fun `midday rolls over at the coming midnight`() {
val next = nextRollover("2026-08-27T12:00:00")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `a second before midnight still targets tonight, not tomorrow night`() {
val next = nextRollover("2026-08-27T23:59:59")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `at midnight exactly the target is the next day, never the current instant`() {
// Re-arming after a firing must move a whole day on, or the widget wakes
// itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
assertThat(next - now).isGreaterThan(Duration.ZERO)
}
@Test
fun `re-arming from the slack instant itself moves a full day on`() {
// What actually happens in practice: the receiver runs at midnight + slack.
val now = at("2026-08-28T00:00:05", berlin)
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
.isEqualTo(at("2026-08-29T00:00:05", berlin))
}
@Test
fun `the result is always in the future for every minute of a day`() {
val zone = berlin
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
// in the past, i.e. an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes
}
}
// --- daylight saving -----------------------------------------------------
@Test
fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next - now).isLessThan(24.hours)
}
@Test
fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
// "now + 24h" would land at 23:00 on the 26th and never roll over.
val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned as the accepted trade: the widget runs an hour
// ahead until the next redraw. No live zone does this since 2019.
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
)
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
}
@Test
fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
}
// --- timezone changes ----------------------------------------------------
@Test
fun `the same instant rolls over at different times in different zones`() {
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
// follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
assertThat(tokyoNext).isNotEqualTo(berlinNext)
assertThat(tokyoNext).isLessThan(berlinNext)
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
}
@Test
fun `a half-hour offset zone still lands on its own midnight`() {
val kathmandu = TimeZone.of("Asia/Kathmandu")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2026-08-27T12:00:00", kathmandu), kathmandu,
)
val local = next.toLocalDateTime(kathmandu)
assertThat(local.date.toString()).isEqualTo("2026-08-28")
assertThat(local.hour).isEqualTo(0)
}
// --- the wiring ----------------------------------------------------------
@Test
fun `the receiver actually handles the action the alarm is sent with`() {
// The single point where the whole fix would die silently: the alarm
// fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
}
@Test
fun `a data change is handled, and re-arms only on the throttle`() {
// PROVIDER_CHANGED fires on every calendar write — each event of a bulk
// import, each drop of a drag — so it is not an unconditional re-arm.
// It is not dropped either: the alarm is inexact and the system may lose
// it, and this is the only signal that keeps arriving to notice.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
.doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
}
@Test
fun `everything that loses the alarm re-arms it`() {
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
WidgetUpdateReceiver.ACTION_ROLLOVER,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
}
}
@@ -0,0 +1,64 @@
BEGIN:VCALENDAR
PRODID:-//Fossify//NONSGML Event Calendar//EN
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Anna Schmidt
UID:101
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Birthdays
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:19900412
DTEND;VALUE=DATE:19900412
X-FOSSIFY-MISSING-YEAR:0
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
BEGIN:VEVENT
SUMMARY:Opa
UID:102
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Birthdays
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:19700603
DTEND;VALUE=DATE:19700603
X-FOSSIFY-MISSING-YEAR:1
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
BEGIN:VEVENT
SUMMARY:Hochzeitstag
UID:103
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Anniversaries
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:20050917
DTEND;VALUE=DATE:20050917
X-FOSSIFY-MISSING-YEAR:0
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
END:VCALENDAR
@@ -0,0 +1,25 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:Fossify Calendar Holiday Generator
METHOD:PUBLISH
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd
SUMMARY:Neujahr
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800101
DTEND;VALUE=DATE:19800102
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
BEGIN:VEVENT
UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2
SUMMARY:Heilige Drei Könige
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800106
DTEND;VALUE=DATE:19800107
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR