2.17.0: reminders Calendula delivers itself, one visibility model, and a Settings you can navigate (#108)
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Failing after 6m29s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/108
This commit is contained in:
Jean-Luc Makiola
2026-07-30 22:14:57 +02:00
parent d5c53df6b5
commit e8657117d6
106 changed files with 7239 additions and 3423 deletions

View File

@@ -54,7 +54,8 @@ class AllDayReminderEncodingTest {
for (time in listOf(0, nineAm, 20 * 60)) {
for (semantic in listOf(0, 1_440, 2_880, 10_080)) {
val raw = toProviderAllDayMinutes(semantic, date, berlin, time)
assertThat(fromProviderAllDayMinutes(raw, date, berlin)).isEqualTo(semantic)
assertThat(fromProviderAllDayMinutes(raw, date, berlin, time))
.isEqualTo(semantic)
}
}
}
@@ -64,9 +65,31 @@ class AllDayReminderEncodingTest {
fun `pre-feature rows (raw multiple of 1440) still decode to whole days`() {
// Reminders written before this feature stored raw N*1440 (fired at UTC
// midnight). They must still read back as "N days before".
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin)).isEqualTo(2_880)
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin, nineAm)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin, nineAm)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin, nineAm)).isEqualTo(2_880)
}
@Test
fun `an offset that encodes to a multiple still decodes to its own lead time`() {
// New York at 20:00 is the collision: "1 day before" encodes to a plain 0,
// which read at face value would say "at time of event". The screen has to
// show what the reminder does, which is fire the evening before.
val newYork = ZoneId.of("America/New_York")
val eightPm = 20 * 60
val raw = toProviderAllDayMinutes(1_440, summer, newYork, eightPm)
assertThat(raw).isEqualTo(0)
assertThat(fromProviderAllDayMinutes(raw, summer, newYork, eightPm)).isEqualTo(1_440)
}
@Test
fun `a foreign multiple west of UTC keeps its face-value lead time`() {
// No encoded hour in this row, and its instant lands on the evening two
// days out in New York — the local-date reading would say "2 days before".
val newYork = ZoneId.of("America/New_York")
assertThat(fromProviderAllDayMinutes(1_440, summer, newYork, nineAm)).isEqualTo(1_440)
}
@Test
@@ -74,8 +97,8 @@ class AllDayReminderEncodingTest {
val atNine = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
val atEight = toProviderAllDayMinutes(1_440, summer, berlin, 8 * 60)
assertThat(atNine).isNotEqualTo(atEight)
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin, nineAm)).isEqualTo(1_440)
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin, nineAm)).isEqualTo(1_440)
}
@Test

View File

@@ -15,6 +15,7 @@ class CalendarMapperTest {
visible: Int = 1,
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
description: String? = null,
syncEvents: Int? = 1,
): MapColumnReader = MapColumnReader(
CalendarProjection.IDX_ID to id,
CalendarProjection.IDX_DISPLAY_NAME to displayName,
@@ -24,6 +25,7 @@ class CalendarMapperTest {
CalendarProjection.IDX_VISIBLE to visible,
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
CalendarProjection.IDX_DESCRIPTION to description,
CalendarProjection.IDX_SYNC_EVENTS to syncEvents,
)
@Test
@@ -49,6 +51,18 @@ class CalendarMapperTest {
)
}
@Test
fun `sync_events 0 marks the calendar as not syncing its events`() {
assertThat(reader(syncEvents = 0).toCalendarSource().syncsEvents).isFalse()
}
@Test
fun `a NULL sync_events column is treated as syncing`() {
// The harmless default: it only ever holds the visibility migration back
// from switching a calendar on.
assertThat(reader(syncEvents = null).toCalendarSource().syncsEvents).isTrue()
}
@Test
fun `null displayName falls back to placeholder`() {
val src = reader(displayName = null).toCalendarSource()

View File

@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -41,8 +42,12 @@ class CalendarRepositoryImplTest {
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
)
private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource =
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
private fun makeCal(
id: Long,
name: String = "Cal $id",
visible: Boolean = true,
): CalendarSource =
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), visible)
private fun makeEvent(
id: Long,
@@ -171,37 +176,40 @@ class CalendarRepositoryImplTest {
}
@Test
fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
prefs.setDisabledCalendarIds(setOf(2L))
fun `instances drops events whose calendar is hidden at system level`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false))
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "Enabled", calendarId = 1L),
makeEvent(11L, "Disabled", calendarId = 2L),
makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Switched off", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("Enabled")
assertThat(awaitItem().map { it.title }).containsExactly("Shown")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest {
fun `instances applies the union of hidden and system-invisible calendars`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(2L))
prefs.setDisabledCalendarIds(setOf(3L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L, visible = false))
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Hidden", calendarId = 2L),
makeEvent(12L, "Disabled", calendarId = 3L),
makeEvent(12L, "Switched off", calendarId = 3L),
)
}
}
@@ -215,9 +223,61 @@ class CalendarRepositoryImplTest {
}
@Test
fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest {
fun `instances re-emit after a calendar is switched off in the provider`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "A", calendarId = 1L),
makeEvent(11L, "B", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
// The write itself is what the provider notifies about; the observer
// tick is what makes the views re-query.
repo.setCalendarsVisible(listOf(2L), false)
fake.tick()
assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `setCalendarsVisible addresses each calendar on its own`(
@TempDir tempDir: Path,
) = runTest {
// An _id IN (…) batch would skip the provider's own reminder-alarm
// reschedule, so every calendar must be written by appended id.
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L))
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.setCalendarsVisible(listOf(1L, 3L), false)
assertThat(fake.visibilityWrites).containsExactly(1L to false, 3L to false).inOrder()
}
@Test
fun `without write permission the switch is kept app-side and still filters`(
@TempDir tempDir: Path,
) = runTest {
// READ granted, WRITE denied: the provider flag can't be written, so the
// choice is parked in the pending set — and honoured from there, or the
// user's switched-off calendars would come back on upgrade (#75).
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
canWrite = false
calendarsResult = listOf(makeCal(1L), makeCal(2L))
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "A", calendarId = 1L),
@@ -231,11 +291,175 @@ class CalendarRepositoryImplTest {
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
prefs.setDisabledCalendarIds(setOf(2L))
repo.setCalendarsVisible(listOf(2L), false)
assertThat(awaitItem().map { it.title }).containsExactly("A")
assertThat(fake.visibilityWrites).isEmpty()
assertThat(prefs.pendingDisabledCalendarIds.first()).containsExactly(2L)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `calendars reports a pending switch-off as off`(@TempDir tempDir: Path) = runTest {
// Otherwise the Settings switch would snap straight back on for a
// read-only install, and the pickers would keep offering the calendar.
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
canWrite = false
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
assertThat(awaitItem().map { it.isVisibleInSystem }).containsExactly(true, true)
repo.setCalendarsVisible(listOf(2L), false)
assertThat(awaitItem().single { it.id == 2L }.isVisibleInSystem).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `switching a calendar back on without write permission retires its entry`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
canWrite = false
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
repo.setCalendarsVisible(listOf(2L), true)
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
}
@Test
fun `a provider write clears anything still pending for that calendar`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
repo.setCalendarsVisible(listOf(2L), false)
assertThat(fake.visibilityWrites).containsExactly(2L to false)
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
}
@Test
fun `one tick costs one calendar query however many collectors there are`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
instancesResult = { _, _ -> listOf(makeEvent(10L, "A", calendarId = 1L)) }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.calendars().test {
awaitItem()
repo.instances(range).test {
awaitItem()
// Both flows listed/filtered off the same snapshot.
assertThat(fake.calendarQueries).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
// The next tick invalidates it — one fresh read, not one per flow.
// (The list has to change: an identical one is collapsed.)
fake.calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L))
fake.tick()
awaitItem()
assertThat(fake.calendarQueries).isEqualTo(2)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `calendars does not re-emit an unchanged list`(@TempDir tempDir: Path) = runTest {
// The store is shared with SettingsPrefs, so an unrelated write would
// otherwise re-run every view's combine for an identical list.
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(makeCal(1L)) }
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
assertThat(awaitItem().map { it.id }).containsExactly(1L)
prefs.setLastUsedCalendarId(1L)
fake.tick()
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `a flushed switch-off never reads as on again before the provider ticks`(
@TempDir tempDir: Path,
) = runTest {
// The reconciler's shape: write VISIBLE = 0, then release the id
// app-side. The provider's notification arrives afterwards, so the
// release must not be read against the pre-write snapshot.
val prefs = newPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
instancesResult = { _, _ ->
listOf(makeEvent(10L, "A", calendarId = 1L), makeEvent(11L, "B", calendarId = 2L))
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
// Warm the snapshot the way an open view would.
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents()
}
fake.setCalendarVisible(2L, false) // no tick(): the observer hasn't fired yet
prefs.removePendingDisabledCalendarIds(setOf(2L))
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents()
}
repo.calendars().test {
assertThat(awaitItem().single { it.id == 2L }.isVisibleInSystem).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `searchEvents drops results from calendars that are off or hidden`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(3L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
searchResult = {
listOf(
makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Switched off", calendarId = 2L),
makeEvent(12L, "Hidden", calendarId = 3L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
assertThat(repo.searchEvents("e").map { it.title }).containsExactly("Shown")
}
@Test

View File

@@ -79,7 +79,8 @@ class EventDetailMapperTest {
private fun MapColumnReader.toDetail(
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
reminders: List<Reminder> = emptyList(),
) = toEventDetailCore(attendees, reminders)
allDayReminderTimeMinutes: Int = 9 * 60,
) = toEventDetailCore(attendees, reminders, allDayReminderTimeMinutes)
@Test
fun `happy path detail maps all fields and embeds matching EventInstance`() {

View File

@@ -61,11 +61,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
private val listeners = mutableListOf<() -> Unit>()
override fun calendars(): List<CalendarSource> = calendarsResult
/** How often [calendars] was queried — the repository shares one read per tick. */
var calendarQueries = 0
private set
override fun calendars(): List<CalendarSource> {
calendarQueries++
return calendarsResult
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
@@ -95,6 +103,27 @@ internal class FakeCalendarDataSource : CalendarDataSource {
updatedCalendars += UpdatedCalendar(id, displayName, color, description)
}
/** (id, visible) pairs passed to [setCalendarVisible], in call order. */
val visibilityWrites = mutableListOf<Pair<Long, Boolean>>()
override fun setCalendarVisible(id: Long, visible: Boolean) {
writeError?.let { throw it }
visibilityWrites += id to visible
// Reflect the write so a follow-up [calendars] read sees it, the way the
// provider would once its notification has re-triggered the query.
calendarsResult = calendarsResult.map {
if (it.id == id) it.copy(isVisibleInSystem = visible) else it
}
}
/** Whether the fake holds `WRITE_CALENDAR`; false models a read-only grant. */
var canWrite: Boolean = true
override fun canWriteCalendars(): Boolean = canWrite
override fun isCalendarVisible(id: Long): Boolean? =
calendarsResult.firstOrNull { it.id == id }?.isVisibleInSystem
override fun deleteCalendar(id: Long) {
writeError?.let { throw it }
deletedCalendarIds += id

View File

@@ -52,32 +52,65 @@ class CalendarPrefsTest {
}
@Test
fun `disabledCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
fun `the pending disabled set reads back what an older version stored`(
@TempDir tempDir: Path,
) = runTest {
// Same key as the retired app-local "disabled calendars" model: an
// upgrade inherits that set as switch-offs still owed to the provider.
val store = newDataStore(tempDir)
val prefs = CalendarPrefs(store)
store.updateData { p ->
p.toMutablePreferences().apply { this[CalendarPrefs.DISABLED_IDS_KEY] = "2,9" }
}
assertThat(prefs.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 9L))
}
@Test
fun `setDisabledCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
fun `the pending disabled set is empty when nothing was ever stored`(
@TempDir tempDir: Path,
) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L, 42L, 7L))
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
}
@Test
fun `setting empty disabled set clears storage`(@TempDir tempDir: Path) = runTest {
fun `pending ids are added and dropped one at a time`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L))
prefs.setDisabledCalendarIds(emptySet())
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
prefs.addPendingDisabledCalendarIds(listOf(2L, 9L))
prefs.addPendingDisabledCalendarIds(listOf(4L))
prefs.removePendingDisabledCalendarIds(setOf(9L))
assertThat(prefs.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 4L))
}
@Test
fun `hidden and disabled sets are stored independently`(@TempDir tempDir: Path) = runTest {
fun `draining the pending set leaves the hidden set alone`(
@TempDir tempDir: Path,
) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setHiddenCalendarIds(setOf(1L))
prefs.setDisabledCalendarIds(setOf(2L))
prefs.addPendingDisabledCalendarIds(setOf(2L))
prefs.removePendingDisabledCalendarIds(setOf(2L))
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(2L))
}
@Test
fun `the visibility notice is unevaluated until it is written`(
@TempDir tempDir: Path,
) = runTest {
// Null is what makes the evaluation one-shot: "no" is stored just as
// firmly as "yes", so the notice can't resurface on a later launch.
val prefs = CalendarPrefs(newDataStore(tempDir))
assertThat(prefs.visibilityNoticePending.first()).isNull()
prefs.setVisibilityNoticePending(true)
assertThat(prefs.visibilityNoticePending.first()).isTrue()
prefs.setVisibilityNoticePending(false)
assertThat(prefs.visibilityNoticePending.first()).isFalse()
}
}

View File

@@ -1,71 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class PostableAlertsTest {
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
alertId = alertId,
eventId = alertId * 10,
calendarId = calendarId,
beginMillis = 0L,
endMillis = 0L,
title = "Event $alertId",
location = null,
isAllDay = false,
)
@Test
fun `keeps alerts when no calendar is disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
assertThat(postable).isEqualTo(due)
}
@Test
fun `drops alerts for a disabled calendar`() {
val keep = alert(1, calendarId = 100)
val drop = alert(2, calendarId = 200)
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
assertThat(postable).containsExactly(keep)
}
@Test
fun `drops every alert when all their calendars are disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
assertThat(postable).isEmpty()
}
@Test
fun `keeps multiple alerts from the same enabled calendar`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
assertThat(postable).isEqualTo(due)
}
@Test
fun `alert with unknown calendar id 0 is never treated as disabled`() {
// Pre-upgrade snooze PendingIntents carry no calendar id (defaults to 0L).
val preUpgrade = alert(1, calendarId = 0L)
assertThat(preUpgrade.isForDisabledCalendar(disabledCalendarIds = setOf(0L))).isFalse()
assertThat(postableAlerts(listOf(preUpgrade), disabledCalendarIds = setOf(0L)))
.containsExactly(preUpgrade)
}
@Test
fun `isForDisabledCalendar matches only the disabled ids`() {
assertThat(alert(1, calendarId = 200).isForDisabledCalendar(setOf(200))).isTrue()
assertThat(alert(1, calendarId = 100).isForDisabledCalendar(setOf(200))).isFalse()
}
}

View File

@@ -1,129 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class SuppressedReminderStoreTest {
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
)
private fun alert(
alertId: Long,
calendarId: Long,
endMillis: Long = Long.MAX_VALUE,
title: String = "Event $alertId",
location: String? = null,
) = ReminderAlert(
alertId = alertId,
eventId = alertId * 10,
calendarId = calendarId,
beginMillis = 0L,
endMillis = endMillis,
title = title,
location = location,
isAllDay = false,
)
@Test
fun `encode then decode round-trips every field including delimiters`() {
val original = alert(
alertId = 7,
calendarId = 42,
endMillis = 123_456_789L,
// Free-text with the field separator and other awkward characters.
title = "Lunch | with | Alice",
location = "Café, 3rd floor | room B",
).copy(beginMillis = 100L, isAllDay = true)
val decoded = decodeStashEntry(encodeStashEntry(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `decode returns null for a malformed entry`() {
assertThat(decodeStashEntry("not-a-valid-entry")).isNull()
}
@Test
fun `null location round-trips`() {
val original = alert(1, calendarId = 1, location = null)
assertThat(decodeStashEntry(encodeStashEntry(original))).isEqualTo(original)
}
@Test
fun `recoverFor returns and removes only the re-enabled calendars`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
val keep = alert(1, calendarId = 100)
val recoverA = alert(2, calendarId = 200)
val recoverB = alert(3, calendarId = 200)
store.stash(listOf(keep, recoverA, recoverB), nowMillis = 0L)
val recovered = store.recoverFor(setOf(200L), nowMillis = 0L)
assertThat(recovered).containsExactly(recoverA, recoverB)
// The still-disabled calendar's alert stays stashed; the recovered ones are gone.
assertThat(store.recoverFor(setOf(100L, 200L), nowMillis = 0L)).containsExactly(keep)
}
@Test
fun `stash drops alerts whose event already ended`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
val past = alert(1, calendarId = 100, endMillis = 500L)
val future = alert(2, calendarId = 100, endMillis = 2_000L)
store.stash(listOf(past, future), nowMillis = 1_000L)
assertThat(store.recoverFor(setOf(100L), nowMillis = 1_000L)).containsExactly(future)
}
@Test
fun `purgeExpired removes only entries past their event end`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
// Stash both before "now" so neither is dropped on write, then advance time.
store.stash(
listOf(
alert(1, calendarId = 100, endMillis = 500L),
alert(2, calendarId = 100, endMillis = 2_000L),
),
nowMillis = 0L,
)
store.purgeExpired(nowMillis = 1_000L)
assertThat(store.recoverFor(setOf(100L), nowMillis = 0L).map { it.alertId })
.containsExactly(2L)
}
@Test
fun `stash replaces an existing entry with the same alert id`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
store.stash(listOf(alert(1, calendarId = 100, title = "old")), nowMillis = 0L)
store.stash(listOf(alert(1, calendarId = 100, title = "new")), nowMillis = 0L)
val recovered = store.recoverFor(setOf(100L), nowMillis = 0L)
assertThat(recovered).hasSize(1)
assertThat(recovered.single().title).isEqualTo("new")
}
@Test
fun `isRelevantAt is true up to the event end and false after`() {
val a = alert(1, calendarId = 1, endMillis = 1_000L)
assertThat(a.isRelevantAt(999L)).isTrue()
assertThat(a.isRelevantAt(1_000L)).isTrue()
assertThat(a.isRelevantAt(1_001L)).isFalse()
}
@TempDir
lateinit var tempDir: Path
}

View File

@@ -0,0 +1,110 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CalendarRowStateTest {
private fun cal(
id: Long = 1L,
name: String = "Cal $id",
writable: Boolean = true,
syncsEvents: Boolean = true,
local: Boolean = false,
managed: Boolean = false,
) = CalendarSource(
id = id,
displayName = name,
accountName = "account",
accountType = if (local) "LOCAL" else "com.google",
color = 0,
isVisibleInSystem = true,
canModifyContents = writable,
isLocal = local,
syncsEvents = syncsEvents,
isManaged = managed,
)
@Test
fun `a plain writable calendar carries no state labels`() {
assertThat(cal().stateLabels()).isEmpty()
assertThat(cal().hasVisibilitySwitch).isTrue()
}
@Test
fun `a read-only calendar is labelled`() {
assertThat(cal(writable = false).stateLabels())
.containsExactly(CalendarStateLabel.READ_ONLY)
}
@Test
fun `a non-syncing account calendar is labelled and loses its switch`() {
val calendar = cal(syncsEvents = false)
assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.NOT_SYNCED)
assertThat(calendar.hasVisibilitySwitch).isFalse()
}
@Test
fun `both states can hold at once, read-only first`() {
assertThat(cal(writable = false, syncsEvents = false).stateLabels())
.containsExactly(CalendarStateLabel.READ_ONLY, CalendarStateLabel.NOT_SYNCED)
.inOrder()
}
@Test
fun `a managed special-dates mirror is labelled although it is writable`() {
// Writable, visible, syncing — nothing else on the row would hint at why
// it can't be picked as an event target.
val calendar = cal(local = true, managed = true)
assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.MANAGED)
assertThat(calendar.hasVisibilitySwitch).isTrue()
}
@Test
fun `a local calendar is never called not-synced`() {
// Nothing syncs a device-local calendar, so sync_events says nothing
// about it — and another app's local calendar can hold real events at 0.
val calendar = cal(syncsEvents = false, local = true)
assertThat(calendar.isNotSynced).isFalse()
assertThat(calendar.stateLabels()).isEmpty()
assertThat(calendar.hasVisibilitySwitch).isTrue()
}
@Test
fun `every named state keeps a calendar out of the pickers`() {
// The labels and the picker exclusion are the same set, stated twice —
// a labelled row the pickers still offered would make the footer's
// "manage your calendars to see why" a lie (#76).
assertThat(cal().isEventTarget).isTrue()
listOf(
cal(writable = false),
cal(syncsEvents = false),
cal(local = true, managed = true),
).forEach { calendar ->
assertThat(calendar.stateLabels()).isNotEmpty()
assertThat(calendar.isEventTarget).isFalse()
}
}
@Test
fun `a switched-off calendar is no target although it carries no label`() {
// The switch is right there on the row, so the state speaks for itself.
val calendar = cal().copy(isVisibleInSystem = false)
assertThat(calendar.stateLabels()).isEmpty()
assertThat(calendar.isEventTarget).isFalse()
}
@Test
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
val ordered = listOf(
cal(id = 1L, name = "Anna", syncsEvents = false),
cal(id = 2L, name = "Bert"),
cal(id = 3L, name = "Cleo", syncsEvents = false),
cal(id = 4L, name = "Dana"),
).orderedForManager()
assertThat(ordered.map { it.displayName })
.containsExactly("Bert", "Dana", "Anna", "Cleo")
.inOrder()
}
}

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* Draining the app's pending "switched off" set into the system's
* `Calendars.VISIBLE` (#75) — including the set an upgrade inherits from the
* retired app-local visibility model.
*/
class CalendarVisibilityPlanTest {
private fun cal(
id: Long,
visible: Boolean = true,
local: Boolean = false,
syncsEvents: Boolean = true,
): CalendarSource = CalendarSource(
id = id,
displayName = "Cal $id",
accountName = "acc@local",
accountType = if (local) "LOCAL" else "com.google",
color = 0,
isVisibleInSystem = visible,
isLocal = local,
syncsEvents = syncsEvents,
)
@Test
fun `a pending calendar is switched off at system level`() {
val plan = calendarVisibilityPlan(listOf(cal(1L, visible = true)), setOf(1L))
assertThat(plan.hide).containsExactly(1L)
assertThat(plan.settled).isEmpty()
}
@Test
fun `a calendar hidden at system level is never switched on`() {
// The plan only hides: switching it on would un-hide the calendar in
// every other calendar app and start firing its reminders.
val plan = calendarVisibilityPlan(listOf(cal(1L, visible = false)), emptySet())
assertThat(plan.isEmpty).isTrue()
}
@Test
fun `a not-synced calendar is switched off like any other`() {
val plan = calendarVisibilityPlan(
listOf(cal(1L, visible = true, syncsEvents = false)),
setOf(1L),
)
assertThat(plan.hide).containsExactly(1L)
}
@Test
fun `a pending calendar already switched off is settled without a write`() {
val plan = calendarVisibilityPlan(listOf(cal(1L, visible = false)), setOf(1L))
assertThat(plan.hide).isEmpty()
assertThat(plan.settled).containsExactly(1L)
}
@Test
fun `a pending id for a calendar that no longer exists is settled`() {
val plan = calendarVisibilityPlan(listOf(cal(1L)), setOf(99L))
assertThat(plan.hide).isEmpty()
assertThat(plan.settled).containsExactly(99L)
}
@Test
fun `an empty pending set writes nothing`() {
val plan = calendarVisibilityPlan(listOf(cal(1L), cal(2L, visible = false)), emptySet())
assertThat(plan.isEmpty).isTrue()
}
@Test
fun `a mixed device splits into writes and settled ids`() {
val plan = calendarVisibilityPlan(
listOf(
cal(1L, visible = true), // not pending → untouched
cal(2L, visible = false), // hidden elsewhere → untouched
cal(3L, visible = true), // pending → hide
cal(4L, visible = false), // pending, already off → settled
),
pendingDisabledIds = setOf(3L, 4L, 77L),
)
assertThat(plan.hide).containsExactly(3L)
assertThat(plan.settled).containsExactly(4L, 77L)
}
@Test
fun `a calendar hidden outside the app arms the notice`() {
assertThat(
hasSystemHiddenCalendars(listOf(cal(1L), cal(2L, visible = false)), emptySet()),
).isTrue()
}
@Test
fun `a calendar we are about to hide ourselves does not arm the notice`() {
// It is off because the user switched it off here — nothing to explain.
assertThat(
hasSystemHiddenCalendars(listOf(cal(1L, visible = false)), setOf(1L)),
).isFalse()
}
@Test
fun `an all-visible device does not arm the notice`() {
assertThat(hasSystemHiddenCalendars(listOf(cal(1L), cal(2L)), emptySet())).isFalse()
}
@Test
fun `a device-local calendar is treated like any other`() {
// Its sync_events flag says nothing about whether it holds events, so
// neither the plan nor the notice may reason about it.
val plan = calendarVisibilityPlan(
listOf(cal(1L, visible = true, local = true, syncsEvents = false)),
setOf(1L),
)
assertThat(plan.hide).containsExactly(1L)
assertThat(
hasSystemHiddenCalendars(
listOf(cal(2L, visible = false, local = true, syncsEvents = false)),
emptySet(),
),
).isTrue()
}
}

View File

@@ -0,0 +1,93 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/**
* The all-day day-boundary rule every surface shares. All-day events are stored
* at UTC midnights with an exclusive end, so their dates must be resolved in UTC
* whatever the device zone is — reading them in the device zone names the wrong
* day on both sides of the meridian (#65 east, #82 west).
*/
class EventInstanceSpanTest {
private val berlin = TimeZone.of("Europe/Berlin") // UTC+2 in July
private val newYork = TimeZone.of("America/New_York") // UTC-4 in July
/** 19 July 2026, all day: UTC midnight to the exclusive next UTC midnight. */
private fun allDayJul19(): EventInstance = instance(
start = utc(2026, 7, 19),
end = utc(2026, 7, 20),
isAllDay = true,
)
private fun utc(y: Int, mo: Int, d: Int, h: Int = 0): Instant =
LocalDateTime(y, mo, d, h, 0).toInstant(TimeZone.UTC)
private fun instance(start: Instant, end: Instant, isAllDay: Boolean) = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 1L,
title = "Event",
start = start,
end = end,
isAllDay = isAllDay,
color = 0xFF000000.toInt(),
location = null,
)
@Test
fun `all-day event keeps its date west of UTC`() {
// Regression for #82: 00:00 UTC on the 19th is 20:00 on the 18th in New
// York, so resolving in the device zone would name the 18th.
val event = allDayJul19()
assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spansMultipleDays(newYork)).isFalse()
}
@Test
fun `all-day event keeps its date east of UTC`() {
// Regression for #65: the exclusive end dips past local midnight in
// Berlin, which would leak the event onto the 20th.
val event = allDayJul19()
assertThat(event.spanFirstDay(berlin)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spanLastDay(berlin)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spansMultipleDays(berlin)).isFalse()
}
@Test
fun `multi-day all-day event ends on its last covered day`() {
val event = instance(utc(2026, 7, 19), utc(2026, 7, 22), isAllDay = true)
assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 21))
assertThat(event.spansMultipleDays(newYork)).isTrue()
}
@Test
fun `timed event resolves in the device zone`() {
// 23:30 UTC on the 19th is already the 20th in Berlin and still the 19th
// in New York — a timed event follows the device zone, unlike all-day.
val event = instance(utc(2026, 7, 19, 23), utc(2026, 7, 20, 1), isAllDay = false)
assertThat(event.spanFirstDay(berlin)).isEqualTo(LocalDate(2026, 7, 20))
assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
}
@Test
fun `zero-length event occupies its start day`() {
val event = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 12), isAllDay = false)
assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
}
@Test
fun `dateZone pins all-day events to UTC and leaves timed events alone`() {
assertThat(allDayJul19().dateZone(newYork)).isEqualTo(TimeZone.UTC)
val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false)
assertThat(timed.dateZone(newYork)).isEqualTo(newYork)
}
}

View File

@@ -0,0 +1,194 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class RecurrenceOccurrencesTest {
private fun date(year: Int, month: Int, day: Int) = LocalDate(year, month, day)
@Test
fun `daily rule starts at the event's own date`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 1),
).inOrder()
}
@Test
fun `interval multiplies the period`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily, interval = 3)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 2),
date(2026, 8, 5),
).inOrder()
}
@Test
fun `weekly without weekday picks repeats the start's weekday`() {
// 30 Jul 2026 is a Thursday.
val occurrences = SimpleRecurrence(RecurrenceFreq.Weekly)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 6),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `weekly with picks fires on every chosen day, in weekday order`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 4)
// The Thursday start is an occurrence in its own right (RFC 5545 puts
// DTSTART in the set), then Friday 31 Jul and Mon/Fri of the next week.
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 3),
date(2026, 8, 7),
).inOrder()
}
@Test
fun `the start counts once even when it is also one of the picks`() {
// Thursday start with Thursday picked: the seeded DTSTART and the rule's
// own hit are the same date and must not both be listed.
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.THURSDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 6),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `weekly interval skips whole weeks, counted from Monday`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
interval = 2,
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.THURSDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
// Start week (27 Jul2 Aug) contributes only its Thursday; the next block
// is two weeks on, so 10 Aug and 13 Aug — not 3 Aug.
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 10),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `monthly skips months without the start day rather than clamping`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Monthly)
.upcomingOccurrences(date(2026, 1, 31), limit = 4)
// February, April and June have no 31st — the rule passes them by.
assertThat(occurrences).containsExactly(
date(2026, 1, 31),
date(2026, 3, 31),
date(2026, 5, 31),
date(2026, 7, 31),
).inOrder()
}
@Test
fun `yearly on 29 February only lands in leap years`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Yearly)
.upcomingOccurrences(date(2024, 2, 29), limit = 3)
assertThat(occurrences).containsExactly(
date(2024, 2, 29),
date(2028, 2, 29),
date(2032, 2, 29),
).inOrder()
}
@Test
fun `count limits the series and skipped periods don't consume one`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Monthly,
end = RecurrenceEnd.Count(3),
).upcomingOccurrences(date(2026, 1, 31), limit = 10)
assertThat(occurrences).containsExactly(
date(2026, 1, 31),
date(2026, 3, 31),
date(2026, 5, 31),
).inOrder()
}
@Test
fun `until is inclusive of its own date`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(date(2026, 8, 1)),
).upcomingOccurrences(date(2026, 7, 30), limit = 10)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 1),
).inOrder()
}
@Test
fun `an until before the start yields nothing instead of spinning`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(date(2026, 7, 1)),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).isEmpty()
}
@Test
fun `a run inside one year needs no year, one that leaves it does`() {
val start = date(2026, 7, 30)
val withinYear = SimpleRecurrence(RecurrenceFreq.Daily).upcomingOccurrences(start, limit = 3)
assertThat(occurrencesSpanYears(withinYear, start)).isFalse()
// Yearly: same day and month every time, so the year is the only thing
// telling the three dates apart.
val yearly = SimpleRecurrence(RecurrenceFreq.Yearly).upcomingOccurrences(start, limit = 3)
assertThat(occurrencesSpanYears(yearly, start)).isTrue()
// Monthly rolling past December.
val newYearStart = date(2026, 11, 30)
val overflowing = SimpleRecurrence(RecurrenceFreq.Monthly)
.upcomingOccurrences(newYearStart, limit = 3)
assertThat(overflowing).containsExactly(
date(2026, 11, 30),
date(2026, 12, 30),
date(2027, 1, 30),
).inOrder()
assertThat(occurrencesSpanYears(overflowing, newYearStart)).isTrue()
}
@Test
fun `limit and count are both respected, whichever is smaller`() {
val rule = SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Count(2))
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 5)).hasSize(2)
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 1)).hasSize(1)
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 0)).isEmpty()
}
}

View File

@@ -0,0 +1,526 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* The decision layer of in-house reminder delivery (#75). The watermark rules are
* the load-bearing part: they are what makes a dropped alarm recoverable and a
* double scan harmless, now that no provider row records "already fired".
*/
class ReminderPlanTest {
private val now = 1_700_000_000_000L
private val minute = 60_000L
private val day = 24 * 60 * minute
private fun instance(
eventId: Long,
beginMillis: Long = now + 30 * minute,
endMillis: Long = now + 90 * minute,
calendarId: Long = 7L,
isAllDay: Boolean = false,
) = ReminderEventInstance(
eventId = eventId,
calendarId = calendarId,
beginMillis = beginMillis,
endMillis = endMillis,
title = "Event $eventId",
location = null,
isAllDay = isAllDay,
)
/** [planReminders] with the fixtures' zone and all-day hour filled in. */
private fun plan(
instances: List<ReminderEventInstance>,
minutesByEvent: Map<Long, List<Int>>,
zone: ZoneId = berlin,
allDayTimeMinutes: Int = nineAm,
) = planReminders(instances, minutesByEvent, zone, allDayTimeMinutes)
@Test
fun `a reminder fires its offset before the occurrence begins`() {
val begin = now + 30 * minute
val planned = plan(
instances = listOf(instance(1L, beginMillis = begin)),
minutesByEvent = mapOf(1L to listOf(10)),
)
assertThat(planned.map { it.alarmMillis }).containsExactly(begin - 10 * minute)
}
// --- all-day: the hour the user picked, on every occurrence -------------
private val berlin = ZoneId.of("Europe/Berlin")
private val nineAm = 540
/** UTC midnight of [date] — how the provider stores an all-day occurrence. */
private fun allDayBegin(date: String): Long =
LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private fun firedAt(alarmMillis: Long, zone: ZoneId = berlin): ZonedDateTime =
java.time.Instant.ofEpochMilli(alarmMillis).atZone(zone)
/**
* The offset AllDayReminderEncoding would store for "[days] before, at
* [timeOfDayMinutes]" when sampled against [eventDate] — i.e. exactly the row
* the app writes today.
*/
private fun encodedAllDayMinutes(
eventDate: String,
days: Long,
timeOfDayMinutes: Int = nineAm,
zone: ZoneId = berlin,
): Int {
val date = LocalDate.parse(eventDate)
val utcMidnight = date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fire = date.minusDays(days)
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
.atZone(zone).toInstant().toEpochMilli()
return ((utcMidnight - fire) / minute).toInt()
}
private fun allDayAlarm(eventDate: String, rawMinutes: Int, zone: ZoneId = berlin): Long =
plan(
instances = listOf(
instance(1L, beginMillis = allDayBegin(eventDate), isAllDay = true),
),
minutesByEvent = mapOf(1L to listOf(rawMinutes)),
zone = zone,
allDayTimeMinutes = nineAm,
).single().alarmMillis
@Test
fun `an all-day reminder fires at the hour the setting names`() {
// Winter: Berlin is UTC+1. "1 day before" on the 15th means the 14th, 09:00.
val alarm = allDayAlarm("2026-01-15", encodedAllDayMinutes("2026-01-15", days = 1))
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
}
@Test
fun `a summer occurrence of a row written in winter is not an hour early`() {
// The drift this replaces: the offset was sampled at UTC+1, the occurrence
// falls at UTC+2, and firing at `begin - minutes` would land at 08:00.
val winterRow = encodedAllDayMinutes("2026-01-15", days = 1)
val alarm = allDayAlarm("2026-07-15", winterRow)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `a winter occurrence of a row written in summer is not an hour late`() {
// The same drift in the other direction: sampled at UTC+2, fires at UTC+1.
val summerRow = encodedAllDayMinutes("2026-07-15", days = 1)
val alarm = allDayAlarm("2026-01-15", summerRow)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
}
@Test
fun `every occurrence of a yearly all-day series fires at the same wall clock`() {
// A birthday's offset is sampled once; the series must not walk off it.
val row = encodedAllDayMinutes("2026-07-15", days = 1)
val fired = listOf("2026-07-15", "2027-01-15", "2027-07-15", "2028-01-15")
.map { firedAt(allDayAlarm(it, row)).toLocalTime() }
assertThat(fired.toSet()).containsExactly(LocalTime.of(9, 0))
}
@Test
fun `an all-day reminder on the day itself fires that morning`() {
// "At time of event" on an all-day event encodes to a negative offset.
val sameDay = encodedAllDayMinutes("2026-07-15", days = 0)
assertThat(sameDay).isLessThan(0)
val alarm = allDayAlarm("2026-07-15", sameDay)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-15").atTime(9, 0))
}
@Test
fun `a plain 1440 row from another calendar app means one day before`() {
// Foreign rows carry no encoded hour. Read at face value they would fire
// at UTC midnight — 02:00 local in summer Berlin.
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `a plain 1440 row west of UTC still means one day before`() {
// UTC midnight of the 15th is the evening of the 14th in New York, so a
// local-date reading of the offset would put this two days out.
val newYork = ZoneId.of("America/New_York")
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440, zone = newYork)
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `an encoded row west of UTC fires at the named hour`() {
val newYork = ZoneId.of("America/New_York")
val row = encodedAllDayMinutes("2026-07-15", days = 1, zone = newYork)
val alarm = allDayAlarm("2026-07-15", row, zone = newYork)
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `a zero-day row fires on the event's own date`() {
assertThat(
allDayLeadDays(
rawMinutes = 0,
startDate = LocalDate.parse("2026-07-15"),
zone = berlin,
allDayTimeMinutes = nineAm,
),
).isEqualTo(0L)
}
@Test
fun `our own row that encodes to a multiple is not read at face value`() {
// New York with the all-day hour at 20:00: "1 day before" encodes to a
// plain 0, because 20:00 EDT *is* UTC midnight. Read at face value it
// fired at 20:00 on the day of the event instead of the evening before.
val newYork = ZoneId.of("America/New_York")
val eightPm = 20 * 60
val row = encodedAllDayMinutes("2026-07-15", days = 1, timeOfDayMinutes = eightPm, zone = newYork)
assertThat(row % 1_440).isEqualTo(0)
val alarm = plan(
instances = listOf(
instance(1L, beginMillis = allDayBegin("2026-07-15"), isAllDay = true),
),
minutesByEvent = mapOf(1L to listOf(row)),
zone = newYork,
allDayTimeMinutes = eightPm,
).single().alarmMillis
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(20, 0))
}
@Test
fun `such a row keeps its lead time across the DST boundary that wrote it`() {
// Same row, sampled in EDT, on a winter occurrence: its instant lands an
// hour off the named hour, which still has to read as "one of ours".
val newYork = ZoneId.of("America/New_York")
val eightPm = 20 * 60
val summerRow = encodedAllDayMinutes("2026-07-15", days = 1, timeOfDayMinutes = eightPm, zone = newYork)
val alarm = plan(
instances = listOf(
instance(1L, beginMillis = allDayBegin("2027-01-15"), isAllDay = true),
),
minutesByEvent = mapOf(1L to listOf(summerRow)),
zone = newYork,
allDayTimeMinutes = eightPm,
).single().alarmMillis
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2027-01-14").atTime(20, 0))
}
@Test
fun `a timed reminder is exact across a DST boundary`() {
// Nothing to re-anchor: begin is an absolute instant either way.
val begin = LocalDate.parse("2026-03-29").atTime(14, 0)
.atZone(berlin).toInstant().toEpochMilli()
val planned = plan(
instances = listOf(instance(1L, beginMillis = begin, isAllDay = false)),
minutesByEvent = mapOf(1L to listOf(30)),
zone = berlin,
allDayTimeMinutes = nineAm,
)
assertThat(firedAt(planned.single().alarmMillis).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-03-29").atTime(13, 30))
}
@Test
fun `every occurrence of a series gets its own reminder`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + day),
instance(1L, beginMillis = now + 2 * day),
),
minutesByEvent = mapOf(1L to listOf(15)),
)
assertThat(planned.map { it.alarmMillis })
.containsExactly(now + day - 15 * minute, now + 2 * day - 15 * minute)
assertThat(planned.map { it.key }.toSet()).hasSize(2)
}
@Test
fun `duplicate reminder rows collapse to one`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = mapOf(1L to listOf(10, 10)),
)
assertThat(planned).hasSize(1)
}
@Test
fun `an event with no reminders plans nothing`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = emptyMap(),
)
assertThat(planned).isEmpty()
}
@Test
fun `the same reminder keeps its key across scans`() {
val plan = {
plan(listOf(instance(1L)), mapOf(1L to listOf(10))).single().key
}
assertThat(plan()).isEqualTo(plan())
}
@Test
fun `occurrences of one series get different keys`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + day),
instance(1L, beginMillis = now + 2 * day),
),
minutesByEvent = mapOf(1L to listOf(15)),
)
assertThat(planned[0].key).isNotEqualTo(planned[1].key)
}
@Test
fun `two reminders on one occurrence get different keys`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = mapOf(1L to listOf(10, 30)),
)
assertThat(planned[0].key).isNotEqualTo(planned[1].key)
}
@Test
fun `a reminder whose moment has passed since the last scan is due`() {
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 10 * minute, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).hasSize(1)
}
@Test
fun `a reminder already covered by the watermark does not fire twice`() {
// The scan runs again (a provider change, a reboot) after the alarm that
// already posted this one. Nothing records "fired" but the watermark.
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 4 * minute, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `a reminder exactly on the watermark does not fire again`() {
val begin = now + 5 * minute
val planned = plan(
instances = listOf(instance(1L, beginMillis = begin)),
minutesByEvent = mapOf(1L to listOf(10)),
)
val alarm = planned.single().alarmMillis
val schedule = scheduleReminders(
planned, lastFiredMillis = alarm, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `a missed alarm is caught up by a much later scan`() {
// The device was off over the reminder; the scan on boot must still post it
// while the event is ahead. This is what the provider path could never do.
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
minutesByEvent = mapOf(1L to listOf(60)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 5 * day, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).hasSize(1)
}
@Test
fun `a reminder for an occurrence that already ended is dropped`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now - 3 * 60 * minute, endMillis = now - 2 * 60 * minute),
),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 5 * day, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `an occurrence with no end falls back to its begin for relevance`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now - minute, endMillis = 0L),
),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `due reminders come out in occurrence order`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + 20 * minute),
instance(2L, beginMillis = now + 5 * minute),
),
minutesByEvent = mapOf(1L to listOf(30), 2L to listOf(30)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due.map { it.instance.eventId }).containsExactly(2L, 1L).inOrder()
}
@Test
fun `the next wake-up is the earliest reminder still ahead`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + 20 * minute),
instance(2L, beginMillis = now + 90 * minute),
),
minutesByEvent = mapOf(1L to listOf(5), 2L to listOf(5)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.nextAlarmMillis).isEqualTo(now + 15 * minute)
}
@Test
fun `with nothing pending the scan still re-runs at the horizon`() {
val schedule = scheduleReminders(
planned = emptyList(), lastFiredMillis = now, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.nextAlarmMillis).isEqualTo(now + day)
}
@Test
fun `a reminder beyond the horizon waits for the next scan`() {
// Capping keeps the rolling window honest: the far-off reminder is picked
// up by a later scan rather than pinned to an alarm we may never re-check.
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 30 * day)),
minutesByEvent = mapOf(1L to listOf(5)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.nextAlarmMillis).isEqualTo(now + day)
}
@Test
fun `the query horizon stretches past the longest reminder offset`() {
// A "2 weeks before" reminder has to be planned while its event is still
// outside the plain lookahead, or it fires late.
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = 20_160))
.isEqualTo(7 * day + 14 * day)
}
@Test
fun `the query horizon is capped against an outlier row`() {
// The longest offset comes from any row in the provider — an imported
// TRIGGER:-P100W would otherwise expand every series over two years, on
// every scan.
val hundredWeeks = 100 * 7 * 24 * 60
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = hundredWeeks))
.isEqualTo(7 * day + MAX_REMINDER_LEAD_MILLIS)
}
@Test
fun `a first-ever scan starts from now, not from the epoch`() {
// Otherwise an install — or the upgrade onto in-house delivery — treats
// every reminder ever set as overdue and posts the lot.
assertThat(reminderWatermark(lastScanMillis = null, nowMillis = now)).isEqualTo(now)
}
@Test
fun `an ordinary watermark is used as recorded`() {
assertThat(reminderWatermark(lastScanMillis = now - day, nowMillis = now))
.isEqualTo(now - day)
}
@Test
fun `a watermark left in the future by a clock change is clamped`() {
// Left alone it would silence every reminder until real time caught up.
assertThat(reminderWatermark(lastScanMillis = now + 5 * day, nowMillis = now))
.isEqualTo(now)
}
@Test
fun `a negative longest offset does not shrink the query horizon`() {
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = -420))
.isEqualTo(7 * day)
}
}

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.spansMultipleDays
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone

View File

@@ -45,9 +45,14 @@ class EventEditViewModelTest {
private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L
private fun cal(id: Long): CalendarSource = CalendarSource(
private fun cal(
id: Long,
visible: Boolean = true,
syncsEvents: Boolean = true,
): CalendarSource = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
syncsEvents = syncsEvents,
)
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
@@ -90,6 +95,66 @@ class EventEditViewModelTest {
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
@Test
fun `a calendar switched off in settings is not offered as a target`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, visible = false))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
job.cancel()
}
@Test
fun `a calendar whose account is not synced to this device is not a target`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// Writable and switched on, but the account keeps its events off the
// device: nothing saved here ever reaches it, and the provider drops the
// rows when the subscription comes back (#76).
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, syncsEvents = false))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
job.cancel()
}
@Test
fun `editing an event in a switched-off calendar keeps it in the picker`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// Otherwise the calendar row renders as the "no calendar" error and any
// pick routes the save through a move the user never asked for.
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, visible = false))
eventDetailResult = { detail(calendarId = 2L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L, 2L)
assertThat(vm.state.value?.form?.calendarId).isEqualTo(2L)
job.cancel()
}
@Test
fun `changing the calendar routes the save through a move, not an update`(
@TempDir tempDir: Path,

View File

@@ -28,7 +28,7 @@ class FilterGroupingTest {
cal(3, "Shared", "team@dav"),
)
val groups = groupByAccount(calendars, hidden = emptySet())
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
assertThat(groups[0].calendars.map { it.displayName })
@@ -43,7 +43,7 @@ class FilterGroupingTest {
cal(2, "Work", "alice@dav"),
)
val groups = groupByAccount(calendars, hidden = setOf(2L))
val groups = groupCalendarsForFilter(calendars, hidden = setOf(2L))
val rows = groups.single().calendars.associateBy { it.id }
assertThat(rows.getValue(1L).visible).isTrue()
@@ -52,10 +52,52 @@ class FilterGroupingTest {
@Test
fun `blank account name falls back to type`() {
val groups = groupByAccount(
val groups = groupCalendarsForFilter(
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
hidden = emptySet(),
)
assertThat(groups.single().account).isEqualTo("LOCAL")
}
/** #77: same address, two apps — two accounts, and they must read as two. */
@Test
fun `one name under two account types stays two groups`() {
val calendars = listOf(
cal(1, "Personal", "me@example.com", type = "com.google"),
cal(2, "Shared", "me@example.com", type = "bitfire.at.davdroid"),
)
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
assertThat(groups).hasSize(2)
assertThat(groups.map { it.accountType })
.containsExactly("com.google", "bitfire.at.davdroid").inOrder()
assertThat(groups.map { it.calendars.single().id }).containsExactly(1L, 2L).inOrder()
assertThat(groups.all { it.ambiguous }).isTrue()
}
@Test
fun `one name under one type is not ambiguous`() {
val groups = groupCalendarsForFilter(
listOf(cal(1, "Personal", "me@example.com")),
hidden = emptySet(),
)
assertThat(groups.single().ambiguous).isFalse()
}
/** Two different names from the same app are still two plain groups. */
@Test
fun `different names under one type are not ambiguous`() {
val groups = groupCalendarsForFilter(
listOf(
cal(1, "Personal", "alice@example.com"),
cal(2, "Shared", "bob@example.com"),
),
hidden = emptySet(),
)
assertThat(groups).hasSize(2)
assertThat(groups.none { it.ambiguous }).isTrue()
}
}

View File

@@ -122,6 +122,42 @@ class LaneEventsTest {
.containsNoneIn(seated)
}
@Test
fun `overflow is exactly what seating left behind`() {
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
val week = rowOfJuly6(events)
val day = LocalDate(2026, 7, 6)
val seated = week.laneEvents(col = 0, day = day, laneCap = 3)
val overflow = week.overflowEvents(col = 0, day = day, laneCap = 3)
assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder()
assertThat(seated + overflow).containsExactlyElementsIn(events)
assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day])
}
@Test
fun `a bar beyond the cap overflows on every day it covers`() {
val bars = (0 until 4).map {
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
}
val week = rowOfJuly6(bars)
val parked = week.spans.filter { it.lane >= 3 }.map { it.event }
(0..2).forEach { col ->
val day = LocalDate(2026, 7, 6 + col)
assertThat(week.overflowEvents(col, day, laneCap = 3))
.containsExactlyElementsIn(parked)
}
}
@Test
fun `a day that fits has no overflow`() {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 6), hour = 9, id = 1L)))
assertThat(week.overflowEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)).isEmpty()
}
private companion object {
const val BLUE = 0xFF3366CC.toInt()
const val RED = 0xFFCC3333.toInt()

View File

@@ -1,89 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class WidgetScaleTest {
@Test
fun `the on-device calibration points map to their tiers`() {
// The two sizes measured on-device: the compact widget stays COMPACT (the
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
// which read as too big on a phone (#51).
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
}
@Test
fun `a full-width widget at ordinary height still scales up`() {
// The regression the height cap used to cause: widening the widget without
// also making it unusually tall is *the* resize #51 reports, and it must
// reach the tier its width earned. Three cells tall is about 270dp.
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `width buckets into the four tiers`() {
// Tall enough that the height cap never binds, isolating the width rule.
val h = 500.dp
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
}
@Test
fun `extra height never raises the tier`() {
// Height decides how many rows are visible, not how big they are: a tall,
// narrow widget wants more events, not bigger text.
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
}
@Test
fun `only a genuinely squashed widget is stepped back down`() {
// Same (wide) width, shrinking height. The cap exists to stop oversized type
// surviving in a one- or two-row sliver — it must not fire at normal heights.
// Heights are gross; the cap works on height minus 60dp of chrome, so the
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
val wide = 378.dp
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
}
@Test
fun `the tier is monotonic in both axes`() {
// Growing a widget must never make its type smaller. Sweeps the whole
// plausible range rather than spot-checking, so a future threshold edit
// can't accidentally invert a step.
val widths = (110..900 step 7).map { it.dp }
val heights = (110..900 step 7).map { it.dp }
widths.forEach { w ->
heights.zipWithNext { shorter, taller ->
assertThat(scaleFor(DpSize(w, taller)))
.isAtLeast(scaleFor(DpSize(w, shorter)))
}
}
heights.forEach { h ->
widths.zipWithNext { narrower, wider ->
assertThat(scaleFor(DpSize(wider, h)))
.isAtLeast(scaleFor(DpSize(narrower, h)))
}
}
}
}

View File

@@ -1,11 +1,9 @@
package de.jeanlucmakiola.calendula.widget.agenda
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.widget.WidgetScale
import de.jeanlucmakiola.calendula.widget.scaleFor
import de.jeanlucmakiola.calendula.widget.WidgetSize
import org.junit.jupiter.api.Test
class AgendaScaleTest {
@@ -13,23 +11,10 @@ class AgendaScaleTest {
// --- the "default size is unchanged" regression guard ---------------------
@Test
fun `every default placement width stays COMPACT`() {
// Ties the guarantee to the provider XML's declared 3-cell default rather
// than to one measured launcher: the whole band a default placement can
// land in must bucket to COMPACT, or a freshly placed widget silently
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
val band = AGENDA_DEFAULT_WIDTH_BAND
var w = band.start
while (w <= band.endInclusive) {
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
w += 1.dp
}
}
@Test
fun `COMPACT metrics equal the widget's original constants`() {
// If this fails, a default-sized agenda widget no longer looks as it did.
val m = metricsFor(WidgetScale.COMPACT)
fun `SMALL metrics equal the widget's original constants`() {
// SMALL is the default, so if this fails an agenda widget whose owner never
// touched the size setting no longer looks as it did (#51).
val m = metricsFor(WidgetSize.SMALL)
assertThat(m.title).isEqualTo(16.sp)
assertThat(m.dayHeader).isEqualTo(13.sp)
assertThat(m.eventTitle).isEqualTo(14.sp)
@@ -54,10 +39,10 @@ class AgendaScaleTest {
// --- the ramp ------------------------------------------------------------
@Test
fun `sizes are non-decreasing across the tiers`() {
val tiers = WidgetScale.entries.map(::metricsFor)
fun `sizes are non-decreasing across the steps`() {
val steps = WidgetSize.entries.map(::metricsFor)
tiers.zipWithNext { small, big ->
steps.zipWithNext { small, big ->
assertThat(big.title.value).isAtLeast(small.title.value)
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
@@ -76,16 +61,16 @@ class AgendaScaleTest {
fun `the event title keeps its lead over the time line`() {
// The secondary line steps more slowly on purpose; if it ever caught up the
// row would lose its hierarchy.
WidgetScale.entries.map(::metricsFor).forEach { m ->
WidgetSize.entries.map(::metricsFor).forEach { m ->
assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
}
}
@Test
fun `no tier grows type more than half again over the baseline`() {
fun `no step grows type more than half again over the baseline`() {
// Guards against a future edit turning "more readable" into "absurd".
val base = metricsFor(WidgetScale.COMPACT)
val top = metricsFor(WidgetScale.XLARGE)
val base = metricsFor(WidgetSize.SMALL)
val top = metricsFor(WidgetSize.EXTRA_LARGE)
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
}
@@ -96,7 +81,7 @@ class AgendaScaleTest {
fun `the stripe tracks the system font scale`() {
// The stripe is Dp, the text beside it is sp: without this the two diverge
// at large accessibility font settings and the stripe under-runs the row.
val m = metricsFor(WidgetScale.COMPACT)
val m = metricsFor(WidgetSize.SMALL)
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
@@ -104,7 +89,7 @@ class AgendaScaleTest {
@Test
fun `scaling for the default font scale changes nothing`() {
val m = metricsFor(WidgetScale.LARGE)
val m = metricsFor(WidgetSize.LARGE)
assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
}
@@ -112,7 +97,7 @@ class AgendaScaleTest {
fun `font scaling leaves the sp sizes alone`() {
// Glance already applies the font scale to sp; scaling them here too would
// double-count it.
val m = metricsFor(WidgetScale.REGULAR)
val m = metricsFor(WidgetSize.MEDIUM)
val scaled = m.scaledForFont(1.3f)
assertThat(scaled.title).isEqualTo(m.title)
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)