Share test event builders and repository wiring (#408)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped

Step 3 of the simplification review. Tests only, no production code changes.

- `TestEvents.kt`: defaulted `testEvent` / `allDayEvent` / `timedEvent` / `testDetail` builders replace the full `EventInstance(...)` / `EventDetail(...)` spellings and the per-file `allDay`/`timed` helpers. Helpers with test-specific titles, colours or zones stay as one-line wrappers, so every built event is unchanged.
- `TestRepositories.kt`: `testDataStore` / `testPrefs` / `testSettings` / `testRepository` plus a JUnit 5 `MainDispatcherExtension` replace the repeated DataStore, repository and setMain/resetMain wiring.

Same 1302 tests before and after; widget tests untouched.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/408
This commit is contained in:
Jean-Luc Makiola
2026-10-15 16:37:46 +02:00
co-authored by makiolaj
parent 8bd2ac7a9a
commit d8e3250af9
31 changed files with 465 additions and 643 deletions
@@ -0,0 +1,118 @@
package de.jeanlucmakiola.calendula
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
/** An [EventInstance] with every field defaulted; [eventId] follows [id]. */
fun testEvent(
id: Long = 1L,
eventId: Long = id,
calendarId: Long = 1L,
title: String = "Event",
start: Instant = Instant.fromEpochMilliseconds(0),
end: Instant = start,
isAllDay: Boolean = false,
color: Int = 0,
location: String? = null,
isRecurring: Boolean = false,
response: EventResponse = EventResponse.Going,
): EventInstance = EventInstance(
instanceId = id,
eventId = eventId,
calendarId = calendarId,
title = title,
start = start,
end = end,
isAllDay = isAllDay,
color = color,
location = location,
isRecurring = isRecurring,
response = response,
)
/** An all-day event over [from]..[toInclusive], at midnights in [zone] with an exclusive end. */
fun allDayEvent(
from: LocalDate,
toInclusive: LocalDate = from,
id: Long = 1L,
title: String = "Event",
color: Int = 0,
zone: TimeZone = TimeZone.UTC,
calendarId: Long = 1L,
): EventInstance = testEvent(
id = id,
calendarId = calendarId,
title = title,
start = from.atTime(0, 0).toInstant(zone),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = color,
)
/** A timed event from [date] [startHour]:[minute] to [endDate] [endHour]:[minute] in [zone]. */
fun timedEvent(
date: LocalDate,
startHour: Int,
endHour: Int = startHour + 1,
zone: TimeZone = TimeZone.UTC,
id: Long = 1L,
title: String = "Event",
color: Int = 0,
endDate: LocalDate = date,
minute: Int = 0,
calendarId: Long = 1L,
): EventInstance = testEvent(
id = id,
calendarId = calendarId,
title = title,
start = date.atTime(startHour, minute).toInstant(zone),
end = endDate.atTime(endHour, minute).toInstant(zone),
color = color,
)
/** An [EventDetail] around [instance] with every other field defaulted. */
fun testDetail(
instance: EventInstance = testEvent(),
description: String? = null,
organizer: String? = null,
attendees: List<Attendee> = emptyList(),
rrule: String? = null,
reminders: List<Reminder> = emptyList(),
status: EventStatus = EventStatus.Confirmed,
availability: Availability = Availability.Busy,
accessLevel: AccessLevel = AccessLevel.Default,
eventTimezone: String? = null,
selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
eventColor: Int? = null,
eventColorKey: String? = null,
isException: Boolean = false,
): EventDetail = EventDetail(
instance = instance,
description = description,
organizer = organizer,
attendees = attendees,
rrule = rrule,
reminders = reminders,
status = status,
availability = availability,
accessLevel = accessLevel,
eventTimezone = eventTimezone,
selfStatus = selfStatus,
eventColor = eventColor,
eventColorKey = eventColorKey,
isException = isException,
)
@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
import java.nio.file.Path
/** A Preferences DataStore in [dir]; [scope] pins its writes, e.g. to a test dispatcher. */
fun testDataStore(dir: Path, name: String, scope: CoroutineScope? = null): DataStore<Preferences> {
val file = { dir.resolve("$name.preferences_pb").toFile() }
return if (scope == null) {
PreferenceDataStoreFactory.create(produceFile = file)
} else {
PreferenceDataStoreFactory.create(scope = scope, produceFile = file)
}
}
fun testPrefs(dir: Path, scope: CoroutineScope? = null): CalendarPrefs =
CalendarPrefs(testDataStore(dir, "test_prefs", scope))
fun testSettings(dir: Path, scope: CoroutineScope? = null): SettingsPrefs =
SettingsPrefs(testDataStore(dir, "test_settings", scope))
/** A [CalendarRepositoryImpl] over [source], with fresh prefs in [dir] unless given. */
fun testRepository(
source: CalendarDataSource,
dir: Path,
io: CoroutineDispatcher,
prefs: CalendarPrefs = testPrefs(dir),
settings: SettingsPrefs = testSettings(dir),
): CalendarRepositoryImpl = CalendarRepositoryImpl(source, prefs, settings, io)
/** Installs [dispatcher] as `Dispatchers.Main` around each test. */
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherExtension(
val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(context: ExtensionContext) = Dispatchers.setMain(dispatcher)
override fun afterEach(context: ExtensionContext) = Dispatchers.resetMain()
}
@@ -1,18 +1,17 @@
package de.jeanlucmakiola.calendula.data.calendar
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
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
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
@@ -30,21 +29,6 @@ import java.nio.file.Path
@OptIn(ExperimentalCoroutinesApi::class)
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() },
)
private fun makeCal(
id: Long,
name: String = "Cal $id",
@@ -56,12 +40,12 @@ class CalendarRepositoryImplTest {
id: Long,
title: String = "E $id",
calendarId: Long = 1L,
): EventInstance = EventInstance(
instanceId = id, eventId = id, calendarId = calendarId,
): EventInstance = testEvent(
id = id, calendarId = calendarId,
title = title,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false, color = 0xFF000000.toInt(), location = null,
color = 0xFF000000.toInt(),
)
@Test
@@ -69,7 +53,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
val first = awaitItem()
@@ -83,7 +67,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L))
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
assertThat(awaitItem().map { it.id }).containsExactly(1L)
@@ -100,7 +84,7 @@ class CalendarRepositoryImplTest {
fun `instances re-emit after a write the provider never notified about`(@TempDir tempDir: Path) = runTest {
var current = listOf(makeEvent(10L))
val fake = FakeCalendarDataSource().apply { instancesResult = { _, _ -> current } }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -121,7 +105,7 @@ class CalendarRepositoryImplTest {
instancesResult = { _, _ -> current }
writeError = WriteFailedException("boom")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -146,7 +130,7 @@ class CalendarRepositoryImplTest {
listOf(makeEvent(10L))
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
repo.instances(range).test {
@@ -162,7 +146,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -174,7 +158,7 @@ class CalendarRepositoryImplTest {
@Test
fun `instances drops events whose calendar the user hid`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
val prefs = testPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
@@ -184,7 +168,7 @@ class CalendarRepositoryImplTest {
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), prefs = prefs)
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -195,7 +179,7 @@ class CalendarRepositoryImplTest {
@Test
fun `instances re-emits when the hidden set changes`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
val prefs = testPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
@@ -204,7 +188,7 @@ class CalendarRepositoryImplTest {
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), prefs = prefs)
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -230,7 +214,7 @@ class CalendarRepositoryImplTest {
)
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -243,7 +227,7 @@ class CalendarRepositoryImplTest {
fun `instances applies the union of hidden and system-invisible calendars`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
val prefs = testPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L, visible = false))
@@ -255,7 +239,7 @@ class CalendarRepositoryImplTest {
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), prefs = prefs)
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -277,7 +261,7 @@ class CalendarRepositoryImplTest {
)
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -302,7 +286,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L))
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.setCalendarsVisible(listOf(1L, 3L), false)
@@ -316,7 +300,7 @@ class CalendarRepositoryImplTest {
// 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 prefs = testPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
canWrite = false
calendarsResult = listOf(makeCal(1L), makeCal(2L))
@@ -327,7 +311,7 @@ class CalendarRepositoryImplTest {
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), prefs = prefs)
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
@@ -346,12 +330,12 @@ class CalendarRepositoryImplTest {
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 prefs = testPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
canWrite = false
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), prefs = prefs)
repo.calendars().test {
assertThat(awaitItem().map { it.isVisibleInSystem }).containsExactly(true, true)
@@ -367,13 +351,13 @@ class CalendarRepositoryImplTest {
fun `switching a calendar back on without write permission retires its entry`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
val prefs = testPrefs(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)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined, prefs = prefs)
repo.setCalendarsVisible(listOf(2L), true)
@@ -384,12 +368,12 @@ class CalendarRepositoryImplTest {
fun `a provider write clears anything still pending for that calendar`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
val prefs = testPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined, prefs = prefs)
repo.setCalendarsVisible(listOf(2L), false)
@@ -405,7 +389,7 @@ class CalendarRepositoryImplTest {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
instancesResult = { _, _ -> listOf(makeEvent(10L, "A", calendarId = 1L)) }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.calendars().test {
@@ -431,9 +415,9 @@ class CalendarRepositoryImplTest {
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 prefs = testPrefs(tempDir)
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(makeCal(1L)) }
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), prefs = prefs)
repo.calendars().test {
assertThat(awaitItem().map { it.id }).containsExactly(1L)
@@ -453,7 +437,7 @@ class CalendarRepositoryImplTest {
// 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)
val prefs = testPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
@@ -461,7 +445,7 @@ class CalendarRepositoryImplTest {
listOf(makeEvent(10L, "A", calendarId = 1L), makeEvent(11L, "B", calendarId = 2L))
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), prefs = prefs)
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
// Warm the snapshot the way an open view would.
@@ -487,7 +471,7 @@ class CalendarRepositoryImplTest {
fun `searchEvents drops results from calendars that are off or hidden`(
@TempDir tempDir: Path,
) = runTest {
val prefs = newPrefs(tempDir)
val prefs = testPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(3L))
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
@@ -499,7 +483,7 @@ class CalendarRepositoryImplTest {
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined, prefs = prefs)
assertThat(repo.searchEvents("e").map { it.event.title }).containsExactly("Shown")
}
@@ -507,7 +491,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), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
title = "Stand-up",
@@ -526,9 +510,9 @@ class CalendarRepositoryImplTest {
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val settings = newSettings(tempDir)
val settings = testSettings(tempDir)
settings.setAllDayReminderTimeMinutes(8 * 60) // 08:00
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), settings, Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined, settings = settings)
val form = EventForm(
calendarId = 1L,
isAllDay = true,
@@ -546,7 +530,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("insert event")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
@@ -564,7 +548,7 @@ class CalendarRepositoryImplTest {
@Test
fun `updateEvent forwards id and both forms`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
@@ -581,7 +565,7 @@ class CalendarRepositoryImplTest {
@Test
fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Stand-up",
@@ -603,7 +587,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("insert moved event into calendar id=3")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
@@ -623,7 +607,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("update event id=42")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
@@ -641,7 +625,7 @@ class CalendarRepositoryImplTest {
@Test
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.deleteEvent(eventId = 42L)
@@ -652,7 +636,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), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
@@ -663,7 +647,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), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.deleteEventFromOccurrence(eventId = 42L, beginMillis = 1_000L)
@@ -675,7 +659,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), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
title = "Moved",
@@ -698,7 +682,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), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val original = EventForm(
calendarId = 1L,
title = "Weekly",
@@ -725,7 +709,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("delete event id=42")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
try {
repo.deleteEvent(eventId = 42L)
@@ -738,7 +722,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), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val id = repo.createLocalCalendar(
displayName = "Home",
@@ -755,7 +739,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), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.updateCalendar(
id = 5L,
@@ -772,7 +756,7 @@ class CalendarRepositoryImplTest {
@Test
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.deleteCalendar(id = 7L)
@@ -784,7 +768,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("create local calendar 'Home'")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
try {
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
@@ -799,7 +783,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { null }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
try {
repo.eventDetail(eventId = 999L)
@@ -818,7 +802,7 @@ class CalendarRepositoryImplTest {
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
assertThat(repo.eventColorPalette(7L))
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
@@ -832,7 +816,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
existingUidsResult = setOf("dup@x")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val events = listOf(
parsedEvent("dup@x"), // already present → skipped
parsedEvent("new@x"), // inserted
@@ -854,7 +838,7 @@ class CalendarRepositoryImplTest {
// A foreign export can hold a row the provider throws on outright; that
// must cost the user one event, not the whole file (Codeberg #225).
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val events = listOf(
parsedEvent("a@x", summary = "good"),
parsedEvent("b@x", summary = "bad"),
@@ -881,7 +865,7 @@ class CalendarRepositoryImplTest {
importsBeforeError = 2
writeError = SecurityException("WRITE_CALENDAR revoked")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val summary = repo.importEvents(
targetCalendarId = 3L,
@@ -906,7 +890,7 @@ class CalendarRepositoryImplTest {
importsBeforeError = 1
writeError = SecurityException("WRITE_CALENDAR revoked")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val summary = repo.importEvents(
targetCalendarId = 3L,
@@ -927,7 +911,7 @@ class CalendarRepositoryImplTest {
val fake = FakeCalendarDataSource().apply {
publishedEventColorsResult = { lookups++; emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
@@ -947,7 +931,7 @@ class CalendarRepositoryImplTest {
publishedEventColorsResult = { published }
eventColorPaletteResult = { emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.importEvents(3L, listOf(parsedEvent("a@x")))
@@ -959,7 +943,7 @@ class CalendarRepositoryImplTest {
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.exportEvents(calendarIds = setOf(1L, 4L))
@@ -971,7 +955,7 @@ class CalendarRepositoryImplTest {
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.exportEvents()
@@ -1,12 +1,10 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.provider.CalendarContract
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 de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
import de.jeanlucmakiola.calendula.testSettings
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
@@ -37,13 +35,6 @@ class SpecialDatesSyncEngineTest {
}
}
private fun prefs(tempDir: Path): SettingsPrefs {
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
)
return SettingsPrefs(store)
}
private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
@@ -53,7 +44,7 @@ class SpecialDatesSyncEngineTest {
calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
enabled: Boolean = true,
): Pair<SpecialDatesSyncEngine, SettingsPrefs> {
val settings = prefs(tempDir)
val settings = testSettings(tempDir)
if (enabled) settings.setSpecialDatesEnabled(true)
return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
}
@@ -1,9 +1,8 @@
package de.jeanlucmakiola.calendula.data.prefs
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 de.jeanlucmakiola.calendula.testDataStore
import de.jeanlucmakiola.calendula.testPrefs
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@@ -12,20 +11,15 @@ import java.nio.file.Path
class CalendarPrefsTest {
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
)
@Test
fun `hiddenCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
val prefs = testPrefs(tempDir)
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
}
@Test
fun `setHiddenCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_prefs")
val prefs = CalendarPrefs(store)
prefs.setHiddenCalendarIds(setOf(1L, 42L, 7L))
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
@@ -33,7 +27,7 @@ class CalendarPrefsTest {
@Test
fun `setting empty set clears storage`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
val prefs = testPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(1L))
prefs.setHiddenCalendarIds(emptySet())
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
@@ -41,7 +35,7 @@ class CalendarPrefsTest {
@Test
fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_prefs")
val prefs = CalendarPrefs(store)
store.updateData { p ->
val mutable = p.toMutablePreferences()
@@ -57,7 +51,7 @@ class CalendarPrefsTest {
) = 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 store = testDataStore(tempDir, "test_prefs")
val prefs = CalendarPrefs(store)
store.updateData { p ->
p.toMutablePreferences().apply { this[CalendarPrefs.DISABLED_IDS_KEY] = "2,9" }
@@ -69,13 +63,13 @@ class CalendarPrefsTest {
fun `the pending disabled set is empty when nothing was ever stored`(
@TempDir tempDir: Path,
) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
val prefs = testPrefs(tempDir)
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
}
@Test
fun `pending ids are added and dropped one at a time`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
val prefs = testPrefs(tempDir)
prefs.addPendingDisabledCalendarIds(listOf(2L, 9L))
prefs.addPendingDisabledCalendarIds(listOf(4L))
@@ -88,7 +82,7 @@ class CalendarPrefsTest {
fun `draining the pending set leaves the hidden set alone`(
@TempDir tempDir: Path,
) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
val prefs = testPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(1L))
prefs.addPendingDisabledCalendarIds(setOf(2L))
@@ -104,7 +98,7 @@ class CalendarPrefsTest {
) = 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))
val prefs = testPrefs(tempDir)
assertThat(prefs.visibilityNoticePending.first()).isNull()
prefs.setVisibilityNoticePending(true)
@@ -1,10 +1,9 @@
package de.jeanlucmakiola.calendula.data.prefs
import de.jeanlucmakiola.calendula.testDataStore
import de.jeanlucmakiola.calendula.testSettings
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -27,14 +26,9 @@ import java.util.Locale
class SettingsPrefsTest {
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
)
@Test
fun `defaults are system theme, dynamic colour on, auto week start`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
assertThat(prefs.dynamicColor.first()).isTrue()
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
@@ -42,35 +36,35 @@ class SettingsPrefsTest {
@Test
fun `theme mode round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setThemeMode(ThemeMode.DARK)
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
}
@Test
fun `black theme mode round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setThemeMode(ThemeMode.BLACK)
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.BLACK)
}
@Test
fun `dynamic colour round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setDynamicColor(false)
assertThat(prefs.dynamicColor.first()).isFalse()
}
@Test
fun `week start round-trips any day`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.SATURDAY))
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.SATURDAY))
}
@Test
fun `legacy MONDAY week-start value migrates to Day`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
@@ -82,7 +76,7 @@ class SettingsPrefsTest {
@Test
fun `garbage week-start value falls back to Auto`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
@@ -94,7 +88,7 @@ class SettingsPrefsTest {
@Test
fun `time format defaults to auto and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.AUTO)
prefs.setTimeFormat(TimeFormatPref.TWELVE_HOUR)
assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.TWELVE_HOUR)
@@ -102,7 +96,7 @@ class SettingsPrefsTest {
@Test
fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.showHourGrid.first()).isFalse()
prefs.setShowHourGrid(true)
assertThat(prefs.showHourGrid.first()).isTrue()
@@ -112,7 +106,7 @@ class SettingsPrefsTest {
fun `timeline scale defaults to regular and round-trips`(@TempDir tempDir: Path) = runTest {
// Regular is the historical 56dp scale — an existing install that never
// opened the setting must keep the timeline it had (#56).
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Regular)
prefs.setTimelineScale(TimelineScale.FitDay)
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.FitDay)
@@ -122,7 +116,7 @@ class SettingsPrefsTest {
fun `a pinched timeline height round-trips`(@TempDir tempDir: Path) = runTest {
// The pinch settles on a height between the presets, so the preference
// has to store the number, not just a named step (#56).
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
val pinched = TimelineScale.custom(63.5f.dp)
prefs.setTimelineScale(pinched)
assertThat(prefs.timelineScale.first()).isEqualTo(pinched)
@@ -135,7 +129,7 @@ class SettingsPrefsTest {
@Test
fun `visible hours default off and round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = false, 7, 22))
prefs.setTimelineHours(TimelineHours(enabled = true, 6, 20))
assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 6, 20))
@@ -146,14 +140,14 @@ class SettingsPrefsTest {
@Test
fun `visible hours are stored valid`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setTimelineHours(TimelineHours(enabled = true, 23, 22))
assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 20, 22))
}
@Test
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.showWeekNumbers.first()).isFalse()
prefs.setShowWeekNumbers(true)
assertThat(prefs.showWeekNumbers.first()).isTrue()
@@ -161,7 +155,7 @@ class SettingsPrefsTest {
@Test
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.autofocusEventTitle.first()).isTrue()
prefs.setAutofocusEventTitle(false)
assertThat(prefs.autofocusEventTitle.first()).isFalse()
@@ -169,7 +163,7 @@ class SettingsPrefsTest {
@Test
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
@@ -181,7 +175,7 @@ class SettingsPrefsTest {
@Test
fun `month view style defaults to paged and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged)
prefs.setMonthViewStyle(MonthViewStyle.Split)
@@ -193,7 +187,7 @@ class SettingsPrefsTest {
@Test
fun `garbage stored month view style falls back to paged`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
@@ -205,7 +199,7 @@ class SettingsPrefsTest {
@Test
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
@@ -217,7 +211,7 @@ class SettingsPrefsTest {
@Test
fun `form fields default to location and description`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.defaultFormFields.first()).containsExactly(
EventFormField.Location,
EventFormField.Description,
@@ -226,7 +220,7 @@ class SettingsPrefsTest {
@Test
fun `form field toggle round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setFormFieldDefault(EventFormField.Reminders, enabled = true)
prefs.setFormFieldDefault(EventFormField.Location, enabled = false)
assertThat(prefs.defaultFormFields.first()).containsExactly(
@@ -237,14 +231,14 @@ class SettingsPrefsTest {
@Test
fun `disabling every form field persists as none, not factory default`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
EventFormField.entries.forEach { prefs.setFormFieldDefault(it, enabled = false) }
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 prefs = testSettings(tempDir)
val config = prefs.quickSwitchConfig.first()
assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS)
@@ -253,7 +247,7 @@ class SettingsPrefsTest {
@Test
fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
val config = QuickSwitchConfig(
order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day),
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
@@ -270,7 +264,7 @@ class SettingsPrefsTest {
@Test
fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
// Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent.
store.updateData { p ->
@@ -292,7 +286,7 @@ class SettingsPrefsTest {
@Test
fun `updateQuickSwitch transforms the stored value, so intents compose`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
// A toggle then a reorder, each expressed against the *stored* config —
// the reorder must not resurrect the just-disabled view (the stale-UI-
// snapshot race this API exists to prevent).
@@ -311,7 +305,7 @@ class SettingsPrefsTest {
@Test
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
prefs.setDrawerViewOrder(
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
@@ -324,7 +318,7 @@ class SettingsPrefsTest {
@Test
fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
@@ -339,7 +333,7 @@ class SettingsPrefsTest {
@Test
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
@@ -351,28 +345,28 @@ class SettingsPrefsTest {
@Test
fun `reminders default to enabled, onboarding to not done`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.remindersEnabled.first()).isTrue()
assertThat(prefs.reminderOnboardingDone.first()).isFalse()
}
@Test
fun `reminders toggle round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setRemindersEnabled(false)
assertThat(prefs.remindersEnabled.first()).isFalse()
}
@Test
fun `reminder onboarding completes one-way`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setReminderOnboardingDone()
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
}
@Test
fun `the wizard arms on a first run`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.armOnboardingWizard()
assertThat(prefs.onboardingWizardArmed.first()).isTrue()
}
@@ -380,7 +374,7 @@ class SettingsPrefsTest {
@Test
fun `the wizard does not arm once the reminder step is answered`(@TempDir tempDir: Path) = runTest {
// An existing install re-granting the permission is not re-onboarded.
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setReminderOnboardingDone()
prefs.armOnboardingWizard()
assertThat(prefs.onboardingWizardArmed.first()).isFalse()
@@ -388,7 +382,7 @@ class SettingsPrefsTest {
@Test
fun `closing the wizard disarms it`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.armOnboardingWizard()
prefs.finishOnboardingWizard()
@@ -398,13 +392,13 @@ class SettingsPrefsTest {
@Test
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
}
@Test
fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setDefaultReminderMinutes(listOf(30))
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
// Several lead times persist, normalised to distinct ascending order.
@@ -416,7 +410,7 @@ class SettingsPrefsTest {
@Test
fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
// The pre-multi-reminder format stored a bare integer string.
store.updateData { p ->
@@ -429,7 +423,7 @@ class SettingsPrefsTest {
@Test
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
@@ -441,7 +435,7 @@ class SettingsPrefsTest {
@Test
fun `per-calendar override round-trips minutes, none, and inherit`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(listOf(15, 10_080)))
@@ -462,7 +456,7 @@ class SettingsPrefsTest {
fun `garbage per-calendar override values drop the entry, the none sentinel stays explicit`(
@TempDir tempDir: Path,
) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
// Only "none" deliberately means an explicit no-reminder override; a
// value with no valid minutes is garbage and must drop its entry so the
@@ -480,7 +474,7 @@ class SettingsPrefsTest {
@Test
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
@@ -490,7 +484,7 @@ class SettingsPrefsTest {
@Test
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(listOf(15)))
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Minutes(listOf(1_440)))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
@@ -527,7 +521,7 @@ class SettingsPrefsTest {
@Test
fun `all-day reminder time defaults to 9am`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(540)
}
@@ -535,7 +529,7 @@ class SettingsPrefsTest {
fun `all-day reminder time round-trips and clamps to a valid minute-of-day`(
@TempDir tempDir: Path,
) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setAllDayReminderTimeMinutes(8 * 60 + 30)
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(510)
prefs.setAllDayReminderTimeMinutes(5_000)
@@ -546,7 +540,7 @@ class SettingsPrefsTest {
@Test
fun `snooze duration defaults to 10 minutes`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.snoozeMinutes.first()).isEqualTo(10)
}
@@ -554,7 +548,7 @@ class SettingsPrefsTest {
fun `snooze duration round-trips and clamps to at least one minute`(
@TempDir tempDir: Path,
) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setSnoozeMinutes(30)
assertThat(prefs.snoozeMinutes.first()).isEqualTo(30)
prefs.setSnoozeMinutes(0)
@@ -565,7 +559,7 @@ class SettingsPrefsTest {
fun `event duration defaults to an hour and clamps to a sane span`(
@TempDir tempDir: Path,
) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(60)
prefs.setDefaultEventDurationMinutes(480)
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(480)
@@ -579,7 +573,7 @@ class SettingsPrefsTest {
fun `per-calendar duration round-trips and clears back to inherit`(
@TempDir tempDir: Path,
) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.perCalendarEventDuration.first()).isEmpty()
prefs.setCalendarEventDuration(7L, 480)
prefs.setCalendarEventDuration(9L, 30)
@@ -592,7 +586,7 @@ class SettingsPrefsTest {
fun `garbage per-calendar duration entries are dropped, the rest survive`(
@TempDir tempDir: Path,
) = runTest {
val store = newDataStore(tempDir)
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
@@ -613,7 +607,7 @@ class SettingsPrefsTest {
@Test
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
// Existing installs have no stamp key — that must read as 0.
assertThat(prefs.brandFontStamp.first()).isEqualTo(0)
assertThat(prefs.plainFontStamp.first()).isEqualTo(0)
@@ -640,7 +634,7 @@ class SettingsPrefsTest {
@Test
fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.specialDatesEnabled.first()).isFalse()
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
assertThat(prefs.specialDatesCalendars.first()).isEmpty()
@@ -650,7 +644,7 @@ class SettingsPrefsTest {
@Test
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
assertThat(prefs.specialDatesTypes.first())
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
@@ -658,7 +652,7 @@ class SettingsPrefsTest {
@Test
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
assertThat(prefs.specialDatesCalendars.first())
@@ -672,7 +666,7 @@ class SettingsPrefsTest {
@Test
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
@@ -681,7 +675,7 @@ class SettingsPrefsTest {
@Test
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val prefs = testSettings(tempDir)
assertThat(prefs.specialDatesStatus.first())
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.testDetail
import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -120,20 +122,16 @@ class EventFormTest {
eventColor: Int? = null,
eventColorKey: String? = null,
eventTimezone: String? = null,
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 1L,
eventId = 1L,
): EventDetail = testDetail(
instance = testEvent(
calendarId = 7L,
title = title,
start = Instant.fromEpochMilliseconds(rowStart),
end = Instant.fromEpochMilliseconds(rowEnd),
isAllDay = isAllDay,
color = 0,
location = location,
),
description = description,
organizer = null,
attendees = attendees,
rrule = rrule,
reminders = reminders,
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
@@ -29,17 +30,8 @@ class EventInstanceSpanTest {
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,
)
private fun instance(start: Instant, end: Instant, isAllDay: Boolean) =
testEvent(start = start, end = end, isAllDay = isAllDay, color = 0xFF000000.toInt())
@Test
fun `all-day event keeps its date west of UTC`() {
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
@@ -27,15 +28,11 @@ class EventSearchTest {
): SearchCandidate {
val begin = Instant.parse(start)
return SearchCandidate(
event = EventInstance(
instanceId = nextId,
eventId = nextId++,
calendarId = 1L,
event = testEvent(
id = nextId++,
title = title,
start = begin,
end = begin.plus(kotlin.time.Duration.parse("1h")),
isAllDay = false,
color = 0,
location = location,
),
description = description,
@@ -46,16 +43,12 @@ class EventSearchTest {
private fun allDayCandidate(title: String, day: String): SearchCandidate {
val begin = Instant.parse("${day}T00:00:00Z")
return SearchCandidate(
event = EventInstance(
instanceId = nextId,
eventId = nextId++,
calendarId = 1L,
event = testEvent(
id = nextId++,
title = title,
start = begin,
end = begin.plus(kotlin.time.Duration.parse("24h")),
isAllDay = true,
color = 0,
location = null,
),
)
}
@@ -183,16 +176,12 @@ class EventSearchTest {
// Behind UTC an all-day event sits at midnight UTC, which is the previous
// month locally (#82) — the month has to come from the span's first day.
val newYear = SearchCandidate(
event = EventInstance(
instanceId = 99L,
eventId = 99L,
calendarId = 1L,
event = testEvent(
id = 99L,
title = "Test holiday",
start = Instant.parse("2027-02-01T00:00:00Z"),
end = Instant.parse("2027-02-02T00:00:00Z"),
isAllDay = true,
color = 0,
location = null,
),
)
@@ -1,9 +1,8 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.LocalDate
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/** The day each agenda list item belongs to, for the focus it hands back (#184). */
@@ -12,17 +11,7 @@ class AgendaItemDatesTest {
private val monday = LocalDate(2026, 9, 28)
private val tuesday = LocalDate(2026, 9, 29)
private fun event(id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "Event",
start = Instant.fromEpochMilliseconds(0),
end = Instant.fromEpochMilliseconds(0),
isAllDay = false,
color = 0xFF000000.toInt(),
location = null,
)
private fun event(id: Long) = testEvent(id = id, color = 0xFF000000.toInt())
@Test
fun `each day counts its header, events and gap`() {
@@ -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.testEvent
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
@@ -16,17 +17,8 @@ class AgendaTimeLabelTest {
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
LocalDateTime(y, mo, d, h, min).toInstant(zone)
private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) = EventInstance(
instanceId = 1,
eventId = 1,
calendarId = 1,
title = "e",
start = start,
end = end,
isAllDay = isAllDay,
color = 0,
location = null,
)
private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) =
testEvent(title = "e", start = start, end = end, isAllDay = isAllDay)
private fun labelOn(y: Int, mo: Int, d: Int, event: EventInstance) =
agendaTimeLabel(event, LocalDate(y, mo, d), zone)
@@ -3,6 +3,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 de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
@@ -26,17 +27,7 @@ class GroupAgendaDaysTest {
start: Instant,
end: Instant,
isAllDay: Boolean = false,
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1,
title = title,
start = start,
end = end,
isAllDay = isAllDay,
color = 0,
location = null,
)
) = testEvent(id = id, title = title, start = start, end = end, isAllDay = isAllDay)
private fun days(instances: List<EventInstance>) =
groupAgendaDays(anchor, windowEnd, instances, zone)
@@ -1,7 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
@@ -14,16 +14,11 @@ class IsPastOnTest {
private val now = Instant.parse("2026-10-08T12:00:00Z")
// All-day Oct 6–9, still running today.
private val summit = EventInstance(
instanceId = 1,
eventId = 1,
calendarId = 1,
private val summit = testEvent(
title = "Summit",
start = Instant.parse("2026-10-06T00:00:00Z"),
end = Instant.parse("2026-10-10T00:00:00Z"),
isAllDay = true,
color = 0,
location = null,
)
@Test
@@ -1,26 +1,15 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/** Which events a column's edge bars point at, and where a tap scrolls (#188). */
class OutOfViewEdgesTest {
private fun block(id: Long, startMin: Int, endMin: Int) = TimedBlock(
event = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "Event $id",
start = Instant.fromEpochMilliseconds(0),
end = Instant.fromEpochMilliseconds(0),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
),
event = testEvent(id = id, title = "Event $id", color = 0xFF112233.toInt()),
startMin = startMin,
endMin = endMin,
lane = 0,
@@ -1,35 +1,30 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.MainDispatcherExtension
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import kotlinx.coroutines.CoroutineDispatcher
import de.jeanlucmakiola.calendula.testDetail
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
@@ -41,10 +36,9 @@ import kotlin.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class RescheduleViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
@JvmField @RegisterExtension
val mainDispatcher = MainDispatcherExtension()
private val dispatcher = mainDispatcher.dispatcher
// Monday 2026-06-08, midday in the device zone — the zone the drop path
// reads dates back in. The BYDAY assertions depend on it being a Monday.
@@ -69,31 +63,23 @@ class RescheduleViewModelTest {
isAllDay: Boolean = false,
isException: Boolean = false,
anchorMillis: Long = beginMillis,
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
): EventDetail = testDetail(
instance = testEvent(
id = 42L, title = "Standup",
start = Instant.fromEpochMilliseconds(anchorMillis),
end = Instant.fromEpochMilliseconds(anchorMillis + (endMillis - beginMillis)),
isAllDay = isAllDay, color = 0xFF000000.toInt(), location = null,
isAllDay = isAllDay, color = 0xFF000000.toInt(),
),
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
rrule = rrule,
isException = isException,
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
val prefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("move_prefs.preferences_pb").toFile() },
),
val repo = testRepository(
fake, tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
val settings = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("move_settings.preferences_pb").toFile() },
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
}
@@ -1,15 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.timedEvent
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.ui.unit.dp
import org.junit.jupiter.api.Test
@@ -25,11 +23,8 @@ class TimedBlockShapeTest {
private val day2 = day1.plus(1, DateTimeUnit.DAY)
private val day3 = day2.plus(1, DateTimeUnit.DAY)
private fun event(from: LocalDateTimeIsh, to: LocalDateTimeIsh) = EventInstance(
instanceId = 1L, eventId = 1L, calendarId = 1L, title = "E",
start = from.date.atTime(from.hour, 0).toInstant(zone),
end = to.date.atTime(to.hour, 0).toInstant(zone),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
private fun event(from: LocalDateTimeIsh, to: LocalDateTimeIsh) = timedEvent(
from.date, from.hour, to.hour, zone = zone, title = "E", color = 0xFF112233.toInt(), endDate = to.date,
)
data class LocalDateTimeIsh(val date: LocalDate, val hour: Int)
@@ -1,8 +1,8 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
import de.jeanlucmakiola.calendula.timedEvent
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import de.jeanlucmakiola.calendula.ui.week.minutesPast
@@ -28,16 +28,8 @@ class TimelineDropTest {
private val sun = sat.plus(1, DateTimeUnit.DAY)
// Sat 5 Sep 20:00 to Sun 6 Sep 08:00.
private val overnight = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 1L,
title = "Night shift",
start = sat.atTime(20, 0).toInstant(zone),
end = sun.atTime(8, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
private val overnight = timedEvent(
sat, 20, 8, zone = zone, title = "Night shift", color = 0xFF112233.toInt(), endDate = sun,
)
private fun blockOn(day: LocalDate): TimedBlock =
@@ -131,11 +123,9 @@ class TimelineDropTest {
// event's start and the midnight its tail is cut at.
val berlin = TimeZone.of("Europe/Berlin")
val sun = LocalDate(2026, 3, 29)
val overnightIntoDst = EventInstance(
instanceId = 3L, eventId = 3L, calendarId = 1L, title = "Night shift",
start = LocalDate(2026, 3, 28).atTime(22, 0).toInstant(berlin),
end = sun.atTime(6, 0).toInstant(berlin),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
val overnightIntoDst = timedEvent(
LocalDate(2026, 3, 28), 22, 6, zone = berlin, id = 3L, title = "Night shift",
color = 0xFF112233.toInt(), endDate = sun,
)
val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single()
val offset = tail.clipOffsetMinutes(sun, berlin)
@@ -148,12 +138,7 @@ class TimelineDropTest {
@Test
fun `a block that is not clipped is unaffected wherever it sits`() {
val plain = EventInstance(
instanceId = 2L, eventId = 2L, calendarId = 1L, title = "Standup",
start = sat.atTime(9, 0).toInstant(zone),
end = sat.atTime(10, 0).toInstant(zone),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
val plain = timedEvent(sat, 9, zone = zone, id = 2L, title = "Standup", color = 0xFF112233.toInt())
val block = layoutDay(listOf(plain), sat, zone).single()
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
}
@@ -1,23 +1,19 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ViewMorphKeyTest {
private fun instance(instanceId: Long, eventId: Long = 7, startMillis: Long = 1_000L) = EventInstance(
instanceId = instanceId,
private fun instance(instanceId: Long, eventId: Long = 7, startMillis: Long = 1_000L) = testEvent(
id = instanceId,
eventId = eventId,
calendarId = 1,
title = "Standup",
start = Instant.fromEpochMilliseconds(startMillis),
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
isAllDay = false,
color = 0,
location = null,
)
private val day = LocalDate(2026, 9, 25)
@@ -1,30 +1,24 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.content.ContextWrapper
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.MainDispatcherExtension
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import de.jeanlucmakiola.calendula.testDetail
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
@@ -38,38 +32,29 @@ import kotlin.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class EventDetailViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
@JvmField @RegisterExtension
val mainDispatcher = MainDispatcherExtension()
private val dispatcher = mainDispatcher.dispatcher
private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L
private fun detail(description: String?) = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
private fun detail(description: String?) = testDetail(
instance = testEvent(
id = 42L, title = "Standup",
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
isAllDay = false, color = 0xFF000000.toInt(), location = null,
color = 0xFF000000.toInt(),
),
description = description, organizer = null, attendees = emptyList(), rrule = null,
description = description,
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
val prefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("detail_prefs.preferences_pb").toFile() },
),
val repo = testRepository(
fake, tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
val settings = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("detail_settings.preferences_pb").toFile() },
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher)
}
@@ -1,10 +1,8 @@
package de.jeanlucmakiola.calendula.ui.edit
import android.content.ContextWrapper
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.MainDispatcherExtension
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
@@ -14,31 +12,30 @@ import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.testDetail
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.Dispatchers
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
@@ -51,10 +48,9 @@ import kotlin.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class EventEditViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
@JvmField @RegisterExtension
val mainDispatcher = MainDispatcherExtension()
private val dispatcher = mainDispatcher.dispatcher
private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L
@@ -74,32 +70,22 @@ class EventEditViewModelTest {
rrule: String? = null,
start: Instant = Instant.fromEpochMilliseconds(beginMillis),
end: Instant = Instant.fromEpochMilliseconds(endMillis),
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
): EventDetail = testDetail(
instance = testEvent(
id = 42L, calendarId = calendarId, title = "Standup",
start = start,
end = end,
isAllDay = false, color = 0xFF000000.toInt(), location = null,
color = 0xFF000000.toInt(),
),
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
rrule = rrule,
)
// Pin the DataStore's scope to the test dispatcher so a settings write (e.g.
// last-used calendar) completes under advanceUntilIdle instead of on a real
// IO thread the virtual clock can't observe.
private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() },
),
)
private fun prefs(tempDir: Path): CalendarPrefs = testPrefs(tempDir, CoroutineScope(dispatcher))
private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() },
),
)
private fun settings(tempDir: Path): SettingsPrefs = testSettings(tempDir, CoroutineScope(dispatcher))
private fun viewModel(
tempDir: Path,
@@ -113,7 +99,7 @@ class EventEditViewModelTest {
io: CoroutineDispatcher = dispatcher,
): EventEditViewModel {
val p = prefs(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, io)
val repo = testRepository(fake, tempDir, io, p, s)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher, zones)
}
@@ -1,16 +1,14 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.allDayEvent
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import de.jeanlucmakiola.calendula.timedEvent
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
@@ -31,29 +29,11 @@ class ChipAtCellYTest {
private fun rowOfJuly6(events: List<EventInstance>) =
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(zone),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = 0xFF2196F3.toInt(),
location = null,
)
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long) =
allDayEvent(from, toInclusive, id = id, title = "A$id", color = 0xFF2196F3.toInt(), zone = zone)
private fun timed(date: LocalDate, hour: Int, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = date.atTime(hour, 0).toInstant(zone),
end = date.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFFF44336.toInt(),
location = null,
)
private fun timed(date: LocalDate, hour: Int, id: Long) =
timedEvent(date, hour, zone = zone, id = id, title = "T$id", color = 0xFFF44336.toInt())
private fun MonthWeek.chipAt(col: Int, cellY: Float) =
chipAtCellY(
@@ -2,12 +2,10 @@ package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.timedEvent
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
@@ -19,17 +17,8 @@ class ChipSeatTest {
private val monday = LocalDate(2026, 6, 8)
private val days = (0..6).map { monday.plus(it, DateTimeUnit.DAY) }
private fun event(id: Long, title: String = "E") = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = monday.atTime(9, 0).toInstant(TimeZone.UTC),
end = monday.atTime(10, 0).toInstant(TimeZone.UTC),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
private fun event(id: Long, title: String = "E") =
timedEvent(monday, 9, id = id, title = title, color = 0xFF112233.toInt())
/**
* A grid holding [seated] in lane 0 as one bar across [cols], and nothing
@@ -1,16 +1,14 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.allDayEvent
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import de.jeanlucmakiola.calendula.timedEvent
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
@@ -22,29 +20,11 @@ class ClipWeekToMonthTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A",
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = 0xFF445566.toInt(),
location = null,
)
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) =
allDayEvent(from, toInclusive, id = id, title = "A", color = 0xFF445566.toInt())
private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T",
start = date.atTime(hour, 0).toInstant(zone),
end = date.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
private fun timed(date: LocalDate, hour: Int, id: Long = 2L) =
timedEvent(date, hour, zone = zone, id = id, title = "T", color = 0xFF112233.toInt())
/** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 – Jul 5. */
private fun firstRowOfJuly(events: List<EventInstance>) =
@@ -1,17 +1,17 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.allDayEvent
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.timedEvent
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
@@ -32,32 +32,15 @@ class DayIndexTest {
private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance {
val id = ++nextId
return EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = from.atTime(startHour, 0).toInstant(zone),
end = to.atTime(endHour, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
return timedEvent(
from, startHour, endHour, zone = zone, id = id, title = "T$id",
color = 0xFF112233.toInt(), endDate = to,
)
}
private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance {
val id = ++nextId
return EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = 0xFF445566.toInt(),
location = null,
)
return allDayEvent(from, toInclusive, id = id, title = "A$id", color = 0xFF445566.toInt())
}
/** Every shape the layout distinguishes, plus the boundary cases around them. */
@@ -1,16 +1,14 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.allDayEvent
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import de.jeanlucmakiola.calendula.timedEvent
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
@@ -27,29 +25,10 @@ class LaneEventsTest {
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) =
EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(zone),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = color,
location = null,
)
allDayEvent(from, toInclusive, id = id, title = "A$id", color = color, zone = zone)
private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = date.atTime(hour, 0).toInstant(zone),
end = date.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = color,
location = null,
)
private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) =
timedEvent(date, hour, zone = zone, id = id, title = "T$id", color = color)
@Test
fun `a bar keeps its lane and the day's timed events fill what's left`() {
@@ -1,14 +1,11 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import de.jeanlucmakiola.calendula.allDayEvent
import de.jeanlucmakiola.calendula.timedEvent
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import java.util.Locale
@@ -20,29 +17,10 @@ class MonthChipTimeTest {
private val locale = Locale.UK
private val day = LocalDate(2026, Month.SEPTEMBER, 2)
private fun timed(hour: Int, minute: Int) = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 1L,
title = "Standup",
start = day.atTime(hour, minute).toInstant(zone),
end = day.atTime(hour + 1, minute).toInstant(zone),
isAllDay = false,
color = 0,
location = null,
)
private fun timed(hour: Int, minute: Int) =
timedEvent(day, hour, zone = zone, title = "Standup", minute = minute)
private fun allDay() = EventInstance(
instanceId = 2L,
eventId = 2L,
calendarId = 1L,
title = "Holiday",
start = day.atTime(0, 0).toInstant(zone),
end = day.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = 0,
location = null,
)
private fun allDay() = allDayEvent(day, id = 2L, title = "Holiday", zone = zone)
@Test
fun `a timed chip shows its start in the 24-hour convention`() {
@@ -2,14 +2,12 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.timedEvent
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
@@ -25,17 +23,8 @@ class MonthLaneCapTest {
/** Cell chrome above the band: 6 + 22 + 4. */
private val header = 32.dp
private fun timed(day: LocalDate, hour: Int, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = day.atTime(hour, 0).toInstant(zone),
end = day.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFFF44336.toInt(),
location = null,
)
private fun timed(day: LocalDate, hour: Int, id: Long) =
timedEvent(day, hour, zone = zone, id = id, title = "T$id", color = 0xFFF44336.toInt())
/** Jul 6-12, wholly inside July 2026. */
private fun week(eventsOnMonday: Int) = layoutMonthWeeks(
@@ -1,8 +1,9 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.allDayEvent
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.timedEvent
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -33,29 +34,12 @@ class MonthLayoutTest {
endHour: Int,
id: Long = 1L,
title: String = "E",
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = at(date, startHour),
end = at(date, endHour),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
) = timedEvent(date, startHour, endHour, zone = zone, id = id, title = title, color = 0xFF112233.toInt())
/** A timed event running from [date] 20:00 into the next day at [endHour]. */
private fun overnight(date: LocalDate, endHour: Int = 8) = EventInstance(
instanceId = 7L,
eventId = 7L,
calendarId = 1L,
title = "Night shift",
start = at(date, 20),
end = at(date.plus(1, DateTimeUnit.DAY), endHour),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
private fun overnight(date: LocalDate, endHour: Int = 8) = timedEvent(
date, 20, endHour, zone = zone, id = 7L, title = "Night shift",
color = 0xFF112233.toInt(), endDate = date.plus(1, DateTimeUnit.DAY),
)
/** All-day events live at UTC midnights with an *exclusive* end. */
@@ -64,17 +48,7 @@ class MonthLayoutTest {
toInclusive: LocalDate = from,
id: Long = 100L,
title: String = "A",
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = 0xFF445566.toInt(),
location = null,
)
) = allDayEvent(from, toInclusive, id = id, title = title, color = 0xFF445566.toInt())
@Test
fun `startOfGridWeek snaps back to the configured week start`() {
@@ -1,29 +1,23 @@
package de.jeanlucmakiola.calendula.ui.search
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.MainDispatcherExtension
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
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.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import kotlinx.coroutines.CoroutineDispatcher
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
@@ -35,10 +29,9 @@ import kotlin.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
@JvmField @RegisterExtension
val mainDispatcher = MainDispatcherExtension()
private val dispatcher = mainDispatcher.dispatcher
private val begin = 1_800_000_000_000L
@@ -53,28 +46,20 @@ class SearchViewModelTest {
recurring: Boolean = false,
startMillis: Long = begin,
) = SearchCandidate(
EventInstance(
instanceId = id, eventId = id, calendarId = calendarId, title = "Standup $id",
testEvent(
id = id, calendarId = calendarId, title = "Standup $id",
start = Instant.fromEpochMilliseconds(startMillis),
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
isAllDay = false, color = 0xFF000000.toInt(), location = null, isRecurring = recurring,
color = 0xFF000000.toInt(), isRecurring = recurring,
),
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): SearchViewModel {
val prefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("search_prefs.preferences_pb").toFile() },
),
val repo = testRepository(
fake, tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
val settings = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("search_settings.preferences_pb").toFile() },
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
return SearchViewModel(repo, dispatcher)
}
@@ -1,11 +1,10 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/** The timeline's columns, standing still and mid view switch (#184). */
@@ -93,15 +92,5 @@ class ColumnGeometryTest {
)
}
private fun event(id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "Event",
start = Instant.fromEpochMilliseconds(0),
end = Instant.fromEpochMilliseconds(0),
isAllDay = true,
color = 0xFF000000.toInt(),
location = null,
)
private fun event(id: Long) = testEvent(id = id, isAllDay = true, color = 0xFF000000.toInt())
}
@@ -1,7 +1,7 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -30,16 +30,8 @@ class WeekLayoutTest {
allDay: Boolean = false,
id: Long = 1L,
title: String = "E",
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = start,
end = end,
isAllDay = allDay,
color = 0xFF112233.toInt(),
location = null,
) = testEvent(
id = id, title = title, start = start, end = end, isAllDay = allDay, color = 0xFF112233.toInt(),
)
@Test