Merge remote-tracking branch 'origin/release/v2.13.0' into feat/custom-fonts

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
This commit is contained in:
2026-07-01 15:58:12 +02:00
36 changed files with 1151 additions and 228 deletions

View File

@@ -199,11 +199,12 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND])
.isEqualTo(1_781_164_800_000L + 5_400_000L)
// A single occurrence never carries its own rule.
// The occurrence's length travels as DURATION, never DTEND — the provider
// rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
// derives the end from DTSTART + DURATION, clearing the inherited RRULE.
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
}
@Test

View File

@@ -6,6 +6,9 @@ import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek
@@ -149,6 +152,78 @@ class SettingsPrefsTest {
assertThat(prefs.defaultFormFields.first()).isEmpty()
}
@Test
fun `quick-switch defaults to every view enabled in default order`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val config = prefs.quickSwitchConfig.first()
assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS)
assertThat(config.cycle).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
}
@Test
fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val config = QuickSwitchConfig(
order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day),
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
)
prefs.setQuickSwitchConfig(config)
val loaded = prefs.quickSwitchConfig.first()
assertThat(loaded.order).containsExactly(
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day,
).inOrder()
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
@Test
fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent.
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram"
m
}
val config = prefs.quickSwitchConfig.first()
// Stored order first (Day, Week), then the omitted views in default order.
assertThat(config.order).containsExactly(
CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
).inOrder()
// Day was explicitly disabled; the appended Month & Agenda default enabled.
assertThat(config.enabled).containsExactly(
CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
)
}
@Test
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
prefs.setDrawerViewOrder(
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
)
assertThat(prefs.drawerViewOrder.first()).containsExactly(
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
).inOrder()
}
@Test
fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week"
m
}
assertThat(prefs.drawerViewOrder.first()).containsExactly(
CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day,
).inOrder()
}
@Test
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
@@ -183,22 +258,38 @@ class SettingsPrefsTest {
}
@Test
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultReminderMinutes.first()).isNull()
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
}
@Test
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
fun `default reminder round-trips none, single, and multiple lead times`(@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()
prefs.setDefaultReminderMinutes(listOf(30))
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
// Several lead times persist, normalised to distinct ascending order.
prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30))
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
prefs.setDefaultReminderMinutes(emptyList())
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
}
@Test
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// The pre-multi-reminder format stored a bare integer string.
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30"
m
}
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
}
@Test
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
@@ -206,7 +297,7 @@ class SettingsPrefsTest {
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
m
}
assertThat(prefs.defaultReminderMinutes.first()).isNull()
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
}
@Test
@@ -214,16 +305,16 @@ class SettingsPrefsTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15, 9L, null)
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
}
// Inherit drops the override entirely (absent != null value).
// Inherit drops the override entirely (absent != an empty-list value).
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15)
assertThat(map).containsExactly(7L, listOf(15, 10_080))
assertThat(map.containsKey(9L)).isFalse()
}
}
@@ -231,48 +322,48 @@ class SettingsPrefsTest {
@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()
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
prefs.setDefaultAllDayReminderMinutes(emptyList())
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
}
@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)
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(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)
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(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)
val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
val allDay = mapOf(7L to listOf(2_880))
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
timedGlobal = 30,
allDayGlobal = 1_440,
timedGlobal = listOf(30),
allDayGlobal = listOf(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)
// Timed: multi-value override, explicit none, inherit global, no calendar.
assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
assertThat(resolve(9L, isAllDay = false)).isEmpty()
assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
assertThat(resolve(null, isAllDay = false)).containsExactly(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)
assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
}
@Test

View File

@@ -0,0 +1,55 @@
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)
}
}

View File

@@ -91,4 +91,33 @@ class ViewBackStackTest {
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
.containsExactly(CalendarView.Agenda)
}
@Test
fun `next cycles through the configured views in order, wrapping around`() {
val cycle = listOf(CalendarView.Month, CalendarView.Agenda)
assertThat(CalendarView.Month.next(cycle)).isEqualTo(CalendarView.Agenda)
// Wraps back to the first from the last.
assertThat(CalendarView.Agenda.next(cycle)).isEqualTo(CalendarView.Month)
}
@Test
fun `next from a view outside the cycle lands on the first enabled view`() {
// Day was disabled in the quick-switch cycle but is still the current view.
val cycle = listOf(CalendarView.Week, CalendarView.Month)
assertThat(CalendarView.Day.next(cycle)).isEqualTo(CalendarView.Week)
}
@Test
fun `next on an empty cycle stays put`() {
assertThat(CalendarView.Week.next(emptyList())).isEqualTo(CalendarView.Week)
}
@Test
fun `quick-switch cycle keeps enabled views in their configured order`() {
val config = QuickSwitchConfig(
order = listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Month, CalendarView.Week),
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
)
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
}