feat(reminders): configurable all-day reminder fire time
All checks were successful
CI / ci (push) Successful in 3m37s
All checks were successful
CI / ci (push) Successful in 3m37s
All-day events live at UTC midnight, so a raw "1 day before" reminder fires at an off hour (02:00 local in CEST) rather than the morning. Add a global "all-day reminder time" setting (default 09:00) and encode it into the provider MINUTES offset so the reminder lands at the chosen wall-clock time the day before instead. - AllDayReminderEncoding: pure to/from provider-minutes helpers, keeping the form/UI/diff in whole-day "semantic" minutes and converting only at the Reminders read/write boundary (insertEvent, reconcileReminders, EventDetailMapper). Covers DST, negative offsets, and pre-existing rows. - SettingsPrefs.allDayReminderTimeMinutes (default 540) threaded from the repository into the data-source write paths. - Settings: a time-picker row, plus a shared TimePickerAlert lifted from the event editor. - Fix the time picker's 12/24-hour detection: honour an explicit system override, else fall back to the device locale rather than the app's per-app language, so it matches the rest of the device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
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
|
||||
|
||||
class AllDayReminderEncodingTest {
|
||||
|
||||
private val berlin: ZoneId = ZoneId.of("Europe/Berlin")
|
||||
private val nineAm = 9 * 60
|
||||
private val summer = LocalDate.of(2026, 6, 20) // CEST, UTC+2
|
||||
private val winter = LocalDate.of(2026, 1, 20) // CET, UTC+1
|
||||
|
||||
/** The instant the provider would actually fire: DTSTART(UTC midnight) − raw. */
|
||||
private fun actualFire(rawMinutes: Int, startDate: LocalDate): Long =
|
||||
startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() -
|
||||
rawMinutes * 60_000L
|
||||
|
||||
/** The wall-clock instant we intend: [time] local, [daysBefore] days before [startDate]. */
|
||||
private fun intendedFire(startDate: LocalDate, daysBefore: Int, timeMinutes: Int): Long =
|
||||
startDate.minusDays(daysBefore.toLong())
|
||||
.atTime(LocalTime.of(timeMinutes / 60, timeMinutes % 60))
|
||||
.atZone(berlin).toInstant().toEpochMilli()
|
||||
|
||||
@Test
|
||||
fun `one day before at 9am fires at 9am local the day before (summer)`() {
|
||||
val raw = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
|
||||
assertThat(actualFire(raw, summer)).isEqualTo(intendedFire(summer, 1, nineAm))
|
||||
// 09:00 CEST is 07:00Z, 7h later than the bare midnight offset: 1440 − 420.
|
||||
assertThat(raw).isEqualTo(1_020)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one day before at 9am fires at 9am local the day before (winter)`() {
|
||||
val raw = toProviderAllDayMinutes(1_440, winter, berlin, nineAm)
|
||||
assertThat(actualFire(raw, winter)).isEqualTo(intendedFire(winter, 1, nineAm))
|
||||
// 09:00 CET is 08:00Z, 8h later than midnight: 1440 − 480.
|
||||
assertThat(raw).isEqualTo(960)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `at time of event encodes a negative offset firing 9am on the day (summer)`() {
|
||||
val raw = toProviderAllDayMinutes(0, summer, berlin, nineAm)
|
||||
assertThat(raw).isLessThan(0) // fires after DTSTART; must not be clamped
|
||||
assertThat(actualFire(raw, summer)).isEqualTo(intendedFire(summer, 0, nineAm))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips for whole-day lead times across both seasons`() {
|
||||
for (date in listOf(summer, winter)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoding is independent of the time-of-day used to encode`() {
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative semantic minutes (provider-default sentinel) pass through`() {
|
||||
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
|
||||
}
|
||||
|
||||
@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
|
||||
// offset tuned for a CET anchor fires an hour off once the series crosses
|
||||
// into CEST. Bounded to ±1h; documented, not fixed.
|
||||
val raw = toProviderAllDayMinutes(1_440, winter, berlin, nineAm)
|
||||
val summerOccurrence = LocalDate.of(2026, 7, 20)
|
||||
val fire = actualFire(raw, summerOccurrence).let(java.time.Instant::ofEpochMilli)
|
||||
.atZone(berlin).toLocalTime()
|
||||
assertThat(fire).isEqualTo(LocalTime.of(10, 0)) // 09:00 intended, +1h in CEST
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -28,6 +29,13 @@ class CalendarRepositoryImplTest {
|
||||
private fun newPrefs(tempDir: Path): CalendarPrefs =
|
||||
CalendarPrefs(newDataStore(tempDir))
|
||||
|
||||
private fun newSettings(tempDir: Path): SettingsPrefs =
|
||||
SettingsPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { tempDir.resolve("repo_test_settings.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
||||
@@ -53,7 +61,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
repo.calendars().test {
|
||||
val first = awaitItem()
|
||||
@@ -67,7 +75,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
repo.calendars().test {
|
||||
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
||||
@@ -91,7 +99,7 @@ class CalendarRepositoryImplTest {
|
||||
listOf(makeEvent(10L))
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
|
||||
repo.instances(range).test {
|
||||
@@ -107,7 +115,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(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 {
|
||||
@@ -129,7 +137,7 @@ class CalendarRepositoryImplTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
@@ -149,7 +157,7 @@ class CalendarRepositoryImplTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
@@ -165,7 +173,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
@@ -179,12 +187,32 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.insertedForms).containsExactly(form)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createEvent passes the configured all-day reminder time to the data source`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val settings = newSettings(tempDir)
|
||||
settings.setAllDayReminderTimeMinutes(8 * 60) // 08:00
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), settings, Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
isAllDay = true,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(0, 0)),
|
||||
)
|
||||
|
||||
repo.createEvent(form)
|
||||
|
||||
assertThat(fake.allDayReminderTimes).containsExactly(480)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
writeError = WriteFailedException("insert event")
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
@@ -202,7 +230,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
@@ -221,7 +249,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
writeError = WriteFailedException("update event id=42")
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
@@ -239,7 +267,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.deleteEvent(eventId = 42L)
|
||||
|
||||
@@ -250,7 +278,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||
|
||||
@@ -261,7 +289,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `deleteEventFromOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||
|
||||
@@ -273,7 +301,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `updateOccurrence forwards target and form, returns the exception id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 88L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Moved",
|
||||
@@ -291,7 +319,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `updateEventFromOccurrence forwards target and forms, returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 99L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Weekly",
|
||||
@@ -318,7 +346,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
writeError = WriteFailedException("delete event id=42")
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
try {
|
||||
repo.deleteEvent(eventId = 42L)
|
||||
@@ -331,7 +359,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `createLocalCalendar delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextCalendarId = 501L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
val id = repo.createLocalCalendar(
|
||||
displayName = "Home",
|
||||
@@ -348,7 +376,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `updateCalendar forwards id, name, color and description`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.updateCalendar(
|
||||
id = 5L,
|
||||
@@ -365,7 +393,7 @@ class CalendarRepositoryImplTest {
|
||||
@Test
|
||||
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.deleteCalendar(id = 7L)
|
||||
|
||||
@@ -377,7 +405,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
writeError = WriteFailedException("create local calendar 'Home'")
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
try {
|
||||
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
|
||||
@@ -392,7 +420,7 @@ class CalendarRepositoryImplTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { null }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
try {
|
||||
repo.eventDetail(eventId = 999L)
|
||||
@@ -411,7 +439,7 @@ class CalendarRepositoryImplTest {
|
||||
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
assertThat(repo.eventColorPalette(7L))
|
||||
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
||||
|
||||
@@ -66,20 +66,36 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
deletedCalendarIds += id
|
||||
}
|
||||
|
||||
override fun insertEvent(form: EventForm): Long {
|
||||
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
||||
val allDayReminderTimes = mutableListOf<Int>()
|
||||
|
||||
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||
writeError?.let { throw it }
|
||||
insertedForms += form
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
override fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) {
|
||||
override fun updateEvent(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
) {
|
||||
writeError?.let { throw it }
|
||||
updatedEvents += Triple(eventId, original, updated)
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
}
|
||||
|
||||
override fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long {
|
||||
override fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
form: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
writeError?.let { throw it }
|
||||
updatedOccurrences += Triple(eventId, beginMillis, form)
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
@@ -88,9 +104,11 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
writeError?.let { throw it }
|
||||
updatedFromOccurrences += Triple(eventId, beginMillis, updated)
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,118 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setDefaultReminderMinutes(30)
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
|
||||
prefs.setDefaultReminderMinutes(null)
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar override round-trips minutes, none, and inherit`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
||||
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, 15, 9L, null)
|
||||
}
|
||||
|
||||
// Inherit drops the override entirely (absent != null value).
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, 15)
|
||||
assertThat(map.containsKey(9L)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||
prefs.setDefaultAllDayReminderMinutes(1_440)
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
|
||||
prefs.setDefaultAllDayReminderMinutes(null)
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
|
||||
// Clearing the all-day override leaves the timed one untouched.
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveDefaultReminder picks the kind-matching override or global`() {
|
||||
val timed = mapOf(7L to 15, 9L to null)
|
||||
val allDay = mapOf(7L to 2_880)
|
||||
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
|
||||
timedGlobal = 30,
|
||||
allDayGlobal = 1_440,
|
||||
timedOverrides = timed,
|
||||
allDayOverrides = allDay,
|
||||
calendarId = calendarId,
|
||||
isAllDay = isAllDay,
|
||||
)
|
||||
// Timed: minutes override, explicit none, inherit global, no calendar.
|
||||
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
|
||||
assertThat(resolve(9L, isAllDay = false)).isNull()
|
||||
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
|
||||
assertThat(resolve(null, isAllDay = false)).isEqualTo(30)
|
||||
// All-day: its own override wins; absent → all-day global; a timed-only
|
||||
// override (cal 9) does not bleed into all-day.
|
||||
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
|
||||
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
|
||||
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day reminder time defaults to 9am`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(540)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day reminder time round-trips and clamps to a valid minute-of-day`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setAllDayReminderTimeMinutes(8 * 60 + 30)
|
||||
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(510)
|
||||
prefs.setAllDayReminderTimeMinutes(5_000)
|
||||
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(1_439)
|
||||
prefs.setAllDayReminderTimeMinutes(-10)
|
||||
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||
|
||||
Reference in New Issue
Block a user