2.19.0 — drag to reschedule, bulk delete from search, onboarding wizard (#172)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 14m58s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m2s

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/172
This commit is contained in:
Jean-Luc Makiola
2026-08-10 18:40:55 +02:00
parent ca826737ed
commit 93b9aa8f34
90 changed files with 7699 additions and 911 deletions

View File

@@ -11,6 +11,7 @@ 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.SearchCandidate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
@@ -451,15 +452,15 @@ class CalendarRepositoryImplTest {
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
searchResult = {
listOf(
makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Switched off", calendarId = 2L),
makeEvent(12L, "Hidden", calendarId = 3L),
SearchCandidate(makeEvent(10L, "Shown", calendarId = 1L)),
SearchCandidate(makeEvent(11L, "Switched off", calendarId = 2L)),
SearchCandidate(makeEvent(12L, "Hidden", calendarId = 3L)),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
assertThat(repo.searchEvents("e").map { it.title }).containsExactly("Shown")
assertThat(repo.searchEvents("e").map { it.event.title }).containsExactly("Shown")
}
@Test

View File

@@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -285,6 +286,88 @@ class EventWriteMapperTest {
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
}
@Test
fun `a weekday move must carry the rule with the anchor, or the series stays put`() {
// The anchor moves by the same shift as the occurrence, while RRULE is
// written verbatim: without realignRecurrence nothing moves.
val series = instantAt("2026-01-05T09:00", "Europe/Berlin")
val original = form(
start = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)),
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
)
val values = update(original, moved, series)
val anchor = java.time.Instant.ofEpochMilli(values[CalendarContract.Events.DTSTART] as Long)
.atZone(java.time.ZoneId.of("Europe/Berlin"))
assertThat(anchor.dayOfWeek).isEqualTo(java.time.DayOfWeek.WEDNESDAY)
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY;BYDAY=WE")
}
@Test
fun `a recurring move keeps the series' length across a DST boundary`() {
// The occurrence being dragged is in CET; the series anchor is in CEST.
val series = instantAt("2026-07-15T09:00", "Europe/Berlin")
val original = form(
start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)),
)
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
.isEqualTo("P5400S")
}
@Test
fun `shifting a one-off event and back again restores the original values`() {
val original = form()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)),
)
val forward = update(original, moved)
val back = update(moved, original)
assertThat(back[CalendarContract.Events.DTSTART])
.isEqualTo(original.toWriteTimes(berlin).dtStartMillis)
assertThat(back[CalendarContract.Events.DTEND])
.isEqualTo(original.toWriteTimes(berlin).dtEndMillis)
assertThat(forward[CalendarContract.Events.DTSTART])
.isNotEqualTo(back[CalendarContract.Events.DTSTART])
}
@Test
fun `undoing a whole-series move lands the anchor back where it started`() {
// What makes undo safe for "all events": the shift is applied to the
// anchor the provider currently holds, so re-issuing it with the two
// forms swapped applies the exact inverse to the moved anchor.
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = form(
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
)
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
assertThat(movedAnchor).isNotEqualTo(series)
val restored = update(moved, original, movedAnchor)
assertThat(restored[CalendarContract.Events.DTSTART]).isEqualTo(series)
assertThat(restored[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
}
@Test
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")

View File

@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
@@ -19,7 +20,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
@@ -29,6 +30,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var existingUidsResult: Set<String> = emptySet()
/** Set to make the next write call throw. */
var writeError: Exception? = null
/** Deletes let through before [writeError] applies, for part-way failures. */
var deletesBeforeError: Int = 0
private var deletes = 0
/** Id returned by the next [insertEvent]. */
var nextInsertId: Long = 100L
@@ -71,7 +76,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query)
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
@@ -196,7 +201,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
}
override fun deleteEvent(eventId: Long) {
writeError?.let { throw it }
writeError?.let { if (deletes++ >= deletesBeforeError) throw it }
deletedEventIds += eventId
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
}

View File

@@ -16,7 +16,11 @@ class SearchMapperTest {
eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null,
rrule: String? = null,
rdate: String? = null,
): MapColumnReader = MapColumnReader(
SearchProjection.IDX_RRULE to rrule,
SearchProjection.IDX_RDATE to rdate,
SearchProjection.IDX_ID to id,
SearchProjection.IDX_CALENDAR_ID to calendarId,
SearchProjection.IDX_TITLE to title,
@@ -42,4 +46,16 @@ class SearchMapperTest {
fun `absent dtstart drops the search hit`() {
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
}
@Test
fun `a rule or an rdate marks the hit recurring (issue #80)`() {
assertThat(searchReader().toSearchResult()!!.isRecurring).isFalse()
assertThat(searchReader(rrule = "").toSearchResult()!!.isRecurring).isFalse()
assertThat(
searchReader(rrule = "FREQ=WEEKLY").toSearchResult()!!.isRecurring,
).isTrue()
assertThat(
searchReader(rdate = "20260101T000000Z").toSearchResult()!!.isRecurring,
).isTrue()
}
}

View File

@@ -337,6 +337,32 @@ class SettingsPrefsTest {
assertThat(prefs.reminderOnboardingDone.first()).isTrue()
}
@Test
fun `the wizard arms on a first run`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.armOnboardingWizard()
assertThat(prefs.onboardingWizardArmed.first()).isTrue()
}
@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))
prefs.setReminderOnboardingDone()
prefs.armOnboardingWizard()
assertThat(prefs.onboardingWizardArmed.first()).isFalse()
}
@Test
fun `closing the wizard disarms it`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.armOnboardingWizard()
prefs.finishOnboardingWizard()
assertThat(prefs.onboardingDoneShown.first()).isTrue()
assertThat(prefs.onboardingWizardArmed.first()).isFalse()
}
@Test
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))

View File

@@ -94,6 +94,26 @@ class CalendarRowStateTest {
assertThat(calendar.isEventTarget).isFalse()
}
@Test
fun `a managed calendar's events are editable but never movable`() {
// Their date is owned by the contacts sync, so a drag would be undone.
val managed = cal().copy(isManaged = true)
assertThat(managed.allowsEventMove).isFalse()
}
@Test
fun `a read-only calendar's events are not movable`() {
assertThat(cal().copy(canModifyContents = false).allowsEventMove).isFalse()
}
@Test
fun `a switched-off calendar's events stay movable, unlike a new-event target`() {
// The predicate is deliberately not isEventTarget.
val hidden = cal().copy(isVisibleInSystem = false)
assertThat(hidden.isEventTarget).isFalse()
assertThat(hidden.allowsEventMove).isTrue()
}
@Test
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
val ordered = listOf(

View File

@@ -0,0 +1,315 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/**
* Search matching and how the results are laid out (#80 follow-up): a calendar
* of months rather than a ranking, with description-only hits held back.
*/
class EventSearchTest {
private val zone = TimeZone.UTC
/** 2027-01-15T08:00Z — mid-month and mid-day, so both sides of each are testable. */
private val now = Instant.parse("2027-01-15T08:00:00Z")
private val todayStart = Instant.parse("2027-01-15T00:00:00Z")
private var nextId = 1L
private fun candidate(
title: String,
start: String = "2027-01-20T09:00:00Z",
location: String? = null,
description: String? = null,
): SearchCandidate {
val begin = Instant.parse(start)
return SearchCandidate(
event = EventInstance(
instanceId = nextId,
eventId = nextId++,
calendarId = 1L,
title = title,
start = begin,
end = begin.plus(kotlin.time.Duration.parse("1h")),
isAllDay = false,
color = 0,
location = location,
),
description = description,
)
}
/** [day] is the event's own date; all-day events sit at UTC midnight. */
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,
title = title,
start = begin,
end = begin.plus(kotlin.time.Duration.parse("24h")),
isAllDay = true,
color = 0,
location = null,
),
)
}
private fun search(vararg candidates: SearchCandidate, query: String): SearchResults =
EventSearch.search(candidates.toList(), query, now, todayStart, zone)
private fun datedTitles(results: SearchResults): List<String> =
results.months.flatMap { month -> month.hits.map { it.event.title } }
@Test
fun `hits are grouped into months, this one first, then ahead, then back`() {
val thisMonth = candidate("Test A", start = "2027-01-20T09:00:00Z")
val nextMonth = candidate("Test B", start = "2027-02-03T09:00:00Z")
val lastMonth = candidate("Test C", start = "2026-12-10T09:00:00Z")
val yearBefore = candidate("Test D", start = "2026-03-10T09:00:00Z")
val results = search(yearBefore, nextMonth, lastMonth, thisMonth, query = "test")
assertThat(results.months.map { it.year to it.monthNumber })
.containsExactly(2027 to 1, 2027 to 2, 2026 to 12, 2026 to 3)
.inOrder()
assertThat(datedTitles(results)).containsExactly("Test A", "Test B", "Test C", "Test D")
.inOrder()
}
@Test
fun `inside a month what is still to come comes before what has passed`() {
// Both sides of today share one January card rather than two headers.
val earlierThisMonth = candidate("Test past", start = "2027-01-05T09:00:00Z")
val laterThisMonth = candidate("Test soon", start = "2027-01-20T09:00:00Z")
val results = search(earlierThisMonth, laterThisMonth, query = "test")
assertThat(results.months).hasSize(1)
assertThat(datedTitles(results)).containsExactly("Test soon", "Test past").inOrder()
}
@Test
fun `an event still running today keeps its place among the upcoming ones`() {
// Reported: "test" at 19:00 having passed must not fall behind "test2" at
// 23:00 — the day is the boundary, not the moment.
val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z")
val laterToday = candidate("test2", start = "2027-01-15T20:00:00Z")
val results = search(laterToday, earlierToday, query = "test")
assertThat(datedTitles(results)).containsExactly("test", "test2").inOrder()
}
@Test
fun `a match tomorrow comes before one from yesterday`() {
val yesterday = candidate("test", start = "2027-01-14T09:00:00Z")
val tomorrow = candidate("test2", start = "2027-01-16T09:00:00Z")
val results = search(yesterday, tomorrow, query = "test")
assertThat(datedTitles(results)).containsExactly("test2", "test").inOrder()
}
@Test
fun `a description-only hit is held back from the dated months`() {
// The original complaint: "protestantischer" in a holiday's notes landed
// among the events actually named Test.
val holiday = candidate(
"Reformationstag",
start = "2027-01-16T09:00:00Z",
description = "Feiertag der protestantischer Kirchen",
)
val real = candidate("Test", start = "2027-06-01T09:00:00Z")
val results = search(holiday, real, query = "test")
assertThat(datedTitles(results)).containsExactly("Test")
assertThat(results.inDescriptions.map { it.event.title }).containsExactly("Reformationstag")
}
@Test
fun `a location match stays in the dated months`() {
val located = candidate("Frühstück", location = "Büro")
val results = search(located, query = "büro")
assertThat(datedTitles(results)).containsExactly("Frühstück")
assertThat(results.inDescriptions).isEmpty()
}
@Test
fun `an event named for one token stays dated even if another is in the notes`() {
val mixed = candidate("Zahnarzt", description = "Termin bestätigen")
val results = search(mixed, query = "zahnarzt termin")
assertThat(datedTitles(results)).containsExactly("Zahnarzt")
assertThat(results.inDescriptions).isEmpty()
}
@Test
fun `every token has to match, in any field and any order`() {
val both = candidate("Zahnarzt", description = "Termin bestätigen")
val onlyOne = candidate("Zahnarzt")
val results = search(both, onlyOne, query = "termin zahnarzt")
assertThat(results.allHits.map { it.event.eventId }).containsExactly(both.event.eventId)
}
@Test
fun `a mid-word match is still found, so compounds keep working`() {
val compound = candidate("Zahnarzttermin")
assertThat(datedTitles(search(compound, query = "termin"))).containsExactly("Zahnarzttermin")
}
@Test
fun `case folds beyond ASCII`() {
assertThat(datedTitles(search(candidate("ÄRZTE Termin"), query = "ärzte")))
.containsExactly("ÄRZTE Termin")
assertThat(datedTitles(search(candidate("ärzte"), query = "ÄRZTE")))
.containsExactly("ärzte")
}
@Test
fun `an all-day event is filed by its own day, not by UTC`() {
// 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,
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,
),
)
val results = EventSearch.search(
listOf(newYear),
"test",
now,
todayStart,
TimeZone.of("America/New_York"),
)
assertThat(results.months.single().let { it.year to it.monthNumber }).isEqualTo(2027 to 2)
}
@Test
fun `an all-day event that has passed does not rank as still to come`() {
// Ahead of UTC its UTC-midnight end lands after the local day started, so
// comparing raw instants floated yesterday's holiday above today's events.
val berlin = TimeZone.of("Europe/Berlin")
val yesterday = allDayCandidate("Test holiday", "2027-01-14")
val tomorrow = candidate("Test soon", start = "2027-01-16T09:00:00Z")
val results = EventSearch.search(
listOf(yesterday, tomorrow),
"test",
Instant.parse("2027-01-15T07:00:00Z"),
Instant.parse("2027-01-14T23:00:00Z"),
berlin,
)
assertThat(datedTitles(results)).containsExactly("Test soon", "Test holiday").inOrder()
assertThat(results.allHits.last().isPast).isTrue()
}
@Test
fun `an all-day event is not past until its own day is over`() {
// 21:00 in Los Angeles, where the event's UTC-midnight end is already gone.
val results = EventSearch.search(
listOf(allDayCandidate("Test holiday", "2027-01-15")),
"test",
Instant.parse("2027-01-16T05:00:00Z"),
Instant.parse("2027-01-15T08:00:00Z"),
TimeZone.of("America/Los_Angeles"),
)
assertThat(results.allHits.single().isPast).isFalse()
}
@Test
fun `an untitled event's placeholder is not something the query can match`() {
val untitled = candidate("(Ohne Titel)", description = "Titel folgt noch").copy(title = null)
val results = search(untitled, query = "titel")
assertThat(datedTitles(results)).isEmpty()
assertThat(results.inDescriptions.single().titleSpans).isEmpty()
}
@Test
fun `a hit that has already finished is marked past, including earlier today`() {
val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z")
val laterToday = candidate("test", start = "2027-01-15T20:00:00Z")
val hits = search(earlierToday, laterToday, query = "test")
.allHits
.associateBy { it.event.eventId }
assertThat(hits.getValue(earlierToday.event.eventId).isPast).isTrue()
assertThat(hits.getValue(laterToday.event.eventId).isPast).isFalse()
}
@Test
fun `the title carries the spans to highlight, merged where they overlap`() {
val hit = search(candidate("Test the tester"), query = "test").allHits.single()
assertThat(hit.titleSpans).containsExactly(MatchSpan(0, 4), MatchSpan(9, 13)).inOrder()
}
@Test
fun `a description snippet is excerpted around its match`() {
val long = "x".repeat(300) + " geheimwort " + "y".repeat(300)
val snippet = search(candidate("Notiz", description = long), query = "geheimwort")
.allHits
.single()
.descriptionSnippet
requireNotNull(snippet)
assertThat(snippet.text).contains("geheimwort")
assertThat(snippet.text.length).isLessThan(long.length)
assertThat(snippet.text).startsWith("")
val span = snippet.spans.single()
assertThat(snippet.text.substring(span.start, span.end)).isEqualTo("geheimwort")
}
@Test
fun `a title-only match carries no description snippet`() {
val hit = search(candidate("Test", description = "nothing relevant here"), query = "test")
.allHits
.single()
assertThat(hit.descriptionSnippet).isNull()
}
@Test
fun `a multi-line description matches and snippets as one line`() {
val snippet = search(
candidate("Notiz", description = "erste Zeile\n\n zweite Zeile"),
query = "zweite",
).allHits.single().descriptionSnippet
requireNotNull(snippet)
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
}
@Test
fun `a blank query matches nothing`() {
assertThat(search(candidate("Test"), query = " ").isEmpty).isTrue()
}
}

View File

@@ -0,0 +1,155 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
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.Test
class EventShiftTest {
private val berlin = TimeZone.of("Europe/Berlin")
private val newYork = TimeZone.of("America/New_York")
private fun form(
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
timezone: String? = null,
isAllDay: Boolean = false,
) = EventForm(
calendarId = 1L,
title = "Standup",
isAllDay = isAllDay,
start = start,
end = end,
timezone = timezone,
)
@Test
fun `shifting to a new start keeps the length`() {
val original = form()
val target = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)).toInstant(berlin)
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
}
@Test
fun `a pinned event is re-derived in its own zone, not the device's`() {
// 09:00 in New York, opened on a Berlin device.
val original = form(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
timezone = "America/New_York",
)
// The grid says "15:30 Berlin", which is 09:30 in New York.
val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
val moved = original.shiftedTo(target, berlin)
assertThat(moved.timezone).isEqualTo("America/New_York")
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
}
@Test
fun `the instant duration survives a shift across spring forward`() {
// 2026-03-29 02:00 CET is when Berlin skips to 03:00.
val original = form(
start = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(23, 0)),
end = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)),
)
val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)).toInstant(berlin)
val moved = original.shiftedTo(target, berlin)
// Two real hours from 01:00 CET lands at 04:00 CEST: the hour in
// between does not exist.
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin))
}
@Test
fun `a drop into the spring-forward gap resolves forward by the missing hour`() {
val original = form()
// 02:30 does not exist on 2026-03-29 in Berlin.
val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)).toInstant(berlin)
val moved = original.shiftedTo(target, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
}
@Test
fun `a zero-distance drop leaves the form untouched`() {
val original = form()
assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original)
}
@Test
fun `an unparseable pinned zone falls back to the device, like the write path`() {
val original = form(timezone = "Mars/Olympus")
val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)).toInstant(newYork)
val moved = original.shiftedTo(target, newYork)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
}
@Test
fun `an all-day event ignores shiftedTo and moves by whole days instead`() {
val allDay = form(isAllDay = true)
val target = LocalDateTime(LocalDate(2026, 7, 1), LocalTime(3, 0)).toInstant(berlin)
assertThat(allDay.shiftedTo(target, berlin)).isEqualTo(allDay)
val moved = allDay.shiftedByDays(3, berlin)
assertThat(moved.start.date).isEqualTo(LocalDate(2026, 6, 14))
assertThat(moved.end.date).isEqualTo(LocalDate(2026, 6, 14))
// The placeholder times exist only for a switch back to timed; untouched.
assertThat(moved.start.time).isEqualTo(allDay.start.time)
}
@Test
fun `a multi-day event keeps its span when shifted by days`() {
val multiDay = form(
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(22, 0)),
end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(2, 0)),
)
val moved = multiDay.shiftedByDays(-2, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
}
@Test
fun `a timed shift onto a DST changeover keeps the real length, not the wall clock`() {
// 22:00 Sat -> 04:00 Sun is six real hours, onto the Sunday Berlin
// springs forward on. Keeping wall clock would write five.
val overnight = form(
start = LocalDateTime(LocalDate(2026, 3, 21), LocalTime(22, 0)),
end = LocalDateTime(LocalDate(2026, 3, 22), LocalTime(4, 0)),
)
val moved = overnight.shiftedByDays(7, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
// The wall-clock end therefore lands an hour later than a naive +7 days.
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
}
@Test
fun `shifting by no days is a no-op`() {
val original = form()
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
}
}

View File

@@ -0,0 +1,111 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class RecurrenceRealignTest {
private val monday = LocalDate(2026, 6, 8)
private val wednesday = LocalDate(2026, 6, 10)
@Test
fun `a weekly BYDAY rule follows the occurrence to its new weekday`() {
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO", monday, wednesday))
.isEqualTo("FREQ=WEEKLY;BYDAY=WE")
}
@Test
fun `unrelated parts survive the rewrite`() {
assertThat(
realignRecurrence("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;COUNT=10", monday, wednesday),
).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE;COUNT=10")
}
@Test
fun `a day-of-month rule is refused, because the anchor moves by days not dates`() {
// BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1:
// the rebuilt rule would say the 1st while the anchor became Jan 29 — a
// DTSTART that is not an instance of its own rule.
assertThat(realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday)).isNull()
assertThat(
realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)),
).isNull()
}
@Test
fun `BYDAY on a non-weekly rule is refused`() {
// parseSimpleRecurrence can't read it either, so the UNTIL guard
// downstream would be blind to it.
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull()
}
@Test
fun `a rule with no FREQ is refused`() {
assertThat(realignRecurrence("INTERVAL=2;BYDAY=MO", monday, wednesday)).isNull()
}
@Test
fun `a rule with no day-selecting part needs no rewrite`() {
assertThat(realignRecurrence("FREQ=DAILY;INTERVAL=3", monday, wednesday))
.isEqualTo("FREQ=DAILY;INTERVAL=3")
assertThat(realignRecurrence("FREQ=WEEKLY", monday, wednesday)).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `a rule the move does not disturb comes back verbatim`() {
val rule = "FREQ=WEEKLY;BYDAY=MO,WE,FR"
assertThat(realignRecurrence(rule, monday, monday)).isEqualTo(rule)
}
@Test
fun `a multi-day BYDAY cannot be resolved from one moved occurrence`() {
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO,WE", monday, wednesday)).isNull()
}
@Test
fun `an ordinal BYDAY cannot be resolved`() {
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=2MO", monday, wednesday)).isNull()
}
@Test
fun `a BYDAY that does not name the occurrence's own weekday is refused`() {
// The rule and DTSTART already disagree; guessing would make it worse.
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=TU", monday, wednesday)).isNull()
}
@Test
fun `parts we cannot reason about are refused rather than guessed`() {
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO;BYSETPOS=1", monday, wednesday))
.isNull()
assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", monday, wednesday)).isNull()
}
@Test
fun `everything realignable is also a rule the UNTIL guard can read`() {
// problems() checks UNTIL through parseSimpleRecurrence; a rule this
// realigns but that parser rejects would go unchecked.
val realignable = listOf(
"FREQ=WEEKLY;BYDAY=MO",
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z",
"FREQ=DAILY;COUNT=5",
)
realignable.forEach { rule ->
assertThat(realignRecurrence(rule, monday, wednesday)).isNotNull()
assertThat(parseSimpleRecurrence(realignRecurrence(rule, monday, wednesday)!!))
.isNotNull()
}
}
@Test
fun `a leading RRULE prefix is preserved`() {
assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday))
.isEqualTo("RRULE:FREQ=WEEKLY;BYDAY=WE")
}
@Test
fun `a malformed rule is refused`() {
assertThat(realignRecurrence("FREQ=WEEKLY;GARBAGE", monday, wednesday)).isNull()
assertThat(realignRecurrence("", monday, wednesday)).isNull()
}
}

View File

@@ -0,0 +1,497 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.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 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.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
/**
* The drop pipeline: which repository call a scope maps to, when a recurring drop
* has to ask first, and every case that must refuse to write.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RescheduleViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
// 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.
private val monday = LocalDate(2026, 6, 8)
private val beginMillis = LocalDateTime(monday, LocalTime(12, 0))
.toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
private val endMillis = beginMillis + 3_600_000L
private fun cal(
id: Long,
canModify: Boolean = true,
managed: Boolean = false,
): CalendarSource = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = canModify,
isManaged = managed,
)
private fun detail(
rrule: String? = null,
isAllDay: Boolean = false,
isException: Boolean = false,
anchorMillis: Long = beginMillis,
): EventDetail = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
start = Instant.fromEpochMilliseconds(anchorMillis),
end = Instant.fromEpochMilliseconds(anchorMillis + (endMillis - beginMillis)),
isAllDay = isAllDay, color = 0xFF000000.toInt(), location = null,
),
description = null, organizer = null, attendees = emptyList(), 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 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)
}
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
launch { vm.movableCalendarIds.collect {} }
/** A drop two days later, expressed the way the month grid expresses one. */
private fun toWednesday() = MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.ByDays(2),
)
/** A drop an hour later, expressed the way a timeline drag expresses one. */
private fun oneHourLater() = MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
)
@Test
fun `a one-off drop writes straight through with no scope prompt`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).hasSize(1)
val (id, original, updated) = fake.updatedEvents.single()
assertThat(id).isEqualTo(42L)
assertThat(updated.start).isNotEqualTo(original.start)
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
}
@Test
fun `a recurring drop parks for the scope instead of writing`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false))
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
@Test
fun `each scope maps to its own repository call`(@TempDir tempDir: Path) = runTest(dispatcher) {
val occurrence = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
viewModel(tempDir.resolve("a").also { it.toFile().mkdirs() }, occurrence).run {
move(oneHourLater())
moveWithScope(RecurringWriteScope.ThisEvent)
}
advanceUntilIdle()
assertThat(occurrence.updatedOccurrences).hasSize(1)
assertThat(occurrence.updatedOccurrences.single().second).isEqualTo(beginMillis)
val following = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
viewModel(tempDir.resolve("b").also { it.toFile().mkdirs() }, following).run {
move(oneHourLater())
moveWithScope(RecurringWriteScope.ThisAndFollowing)
}
advanceUntilIdle()
assertThat(following.updatedFromOccurrences).hasSize(1)
val series = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
viewModel(tempDir.resolve("c").also { it.toFile().mkdirs() }, series).run {
move(oneHourLater())
moveWithScope(RecurringWriteScope.AllEvents)
}
advanceUntilIdle()
assertThat(series.updatedEvents).hasSize(1)
}
@Test
fun `a weekly BYDAY rule is realigned when the whole series moves weekday`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
// Without this the anchor becomes a Wednesday while the rule still says
// Monday, and the series does not move at all.
assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=WE")
}
@Test
fun `a rule that cannot be realigned offers only the single occurrence`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
}
@Test
fun `a same-time drop within one day still needs no realignment`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
// The weekday is unchanged, so the ambiguous BYDAY is not in the way.
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false))
}
@Test
fun `a time-only drop that would carry the anchor past midnight offers only the occurrence`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// The anchor sits at 23:30 while the dragged occurrence is at midday —
// only possible when the row pins no zone, so the two resolve an hour
// apart. +1h leaves the occurrence on its day but rolls the anchor onto
// the next one, which would leave BYDAY naming the wrong weekday.
val anchor = LocalDateTime(LocalDate(2026, 6, 1), LocalTime(23, 30))
.toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO", anchorMillis = anchor) }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
}
@Test
fun `a second drop is refused while the first is still in flight`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
assertThat(vm.move(toWednesday())).isTrue()
advanceUntilIdle()
// Parked on the scope dialog, so the first drop still owns the pipeline.
assertThat(vm.move(oneHourLater())).isFalse()
advanceUntilIdle()
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `undo is refused while a write is running`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
// A second drop parks on its scope dialog, which holds the pipeline.
fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
vm.move(toWednesday())
advanceUntilIdle()
assertThat(vm.undo(undo)).isFalse()
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(1)
}
@Test
fun `an exception row is written as a plain event, never as a nested exception`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
// A sync adapter that left the series' rule on the override row.
eventDetailResult = { detail(rrule = "FREQ=WEEKLY", isException = true) }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).hasSize(1)
assertThat(fake.updatedOccurrences).isEmpty()
}
@Test
fun `a drop past the series' own UNTIL is refused, not written`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
// Midday UTC, so the device zone can't push the UNTIL date onto the
// day the event is being dragged to.
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd)
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `dragging a bounded series' last occurrence still allows moving just it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
advanceUntilIdle()
// An exception row is constrained by no UNTIL, so the scope stays on offer.
assertThat(vm.scopePrompt.value).isNotNull()
vm.moveWithScope(RecurringWriteScope.ThisEvent)
advanceUntilIdle()
assertThat(fake.updatedOccurrences).hasSize(1)
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
}
@Test
fun `a drag that changes both the day and the time cannot move the series`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
// Onto the next day *and* two hours later: a late enough anchor would
// cross two midnights under the same wall-clock shift.
vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
),
),
)
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
}
@Test
fun `a zero-distance drop writes nothing and says nothing`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
),
)
advanceUntilIdle()
assertThat(fake.updatedEvents).isEmpty()
assertThat(vm.outcome.value).isNull()
}
@Test
fun `a vanished event reports itself as gone`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone)
}
@Test
fun `a revoked write permission is reported apart from a plain failure`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail() }
writeError = SecurityException("revoked")
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
}
@Test
fun `undo writes the move back the other way`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo
assertThat(undo).isNotNull()
vm.undo(undo!!)
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(2)
val forward = fake.updatedEvents[0]
val back = fake.updatedEvents[1]
assertThat(back.second).isEqualTo(forward.third)
assertThat(back.third).isEqualTo(forward.second)
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
}
@Test
fun `an occurrence write offers no undo, since shifting back cannot restore it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
vm.moveWithScope(RecurringWriteScope.ThisEvent)
advanceUntilIdle()
assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull()
}
@Test
fun `only writable, unmanaged calendars can be dragged from`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(
cal(1L),
cal(2L, canModify = false),
cal(3L, managed = true),
)
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
advanceUntilIdle()
assertThat(vm.movableCalendarIds.value).containsExactly(1L)
job.cancel()
}
@Test
fun `cancelling the scope dialog writes nothing`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
vm.cancelScope()
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
}

View File

@@ -27,6 +27,14 @@ class TimeFormatTest {
assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15")
}
@Test
fun `gutter time drops the meridiem and clamps to the day`() {
assertThat(formatGutterTime(9 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("09:15")
assertThat(formatGutterTime(13 * 60 + 45, is24Hour = false, Locale.US)).isEqualTo("1:45")
assertThat(formatGutterTime(0, is24Hour = false, Locale.US)).isEqualTo("12:00")
assertThat(formatGutterTime(1_440, is24Hour = true, Locale.US)).isEqualTo("23:59")
}
@Test
fun `hour label is zero-padded in 24h and compact am-pm in 12h`() {
assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")

View File

@@ -0,0 +1,246 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The wizard's step plan (#163). Two invariants carry the whole feature: an
* existing install must never be re-onboarded, and the counter must not
* renumber under the user as steps are completed.
*/
class OnboardingPlanTest {
private fun plan(
hasPermission: Boolean = false,
remindersDone: Boolean = false,
wizardArmed: Boolean = false,
backupDone: Boolean = false,
viewDone: Boolean = false,
monthStyleDone: Boolean = false,
backupApplies: Boolean? = null,
visibilityArmed: Boolean = false,
visibilityDone: Boolean = false,
doneShown: Boolean = false,
) = onboardingPlan(
hasPermission = hasPermission,
remindersDone = remindersDone,
wizardArmed = wizardArmed,
backupDone = backupDone,
viewDone = viewDone,
monthStyleDone = monthStyleDone,
backupApplies = backupApplies,
visibilityArmed = visibilityArmed,
visibilityDone = visibilityDone,
doneShown = doneShown,
)
@Test
fun `fresh install starts on the permission step`() {
val fresh = plan()
assertThat(fresh.current).isEqualTo(OnboardingStep.Permission)
assertThat(fresh.index).isEqualTo(1)
assertThat(fresh.showsProgress).isTrue()
}
@Test
fun `granting the permission does not renumber the steps behind it`() {
// The plan keeps completed steps, so the reminder step stays step 2 of
// the same flow rather than becoming step 1 of a shorter one.
val granted = plan(hasPermission = true, wizardArmed = true, backupApplies = true)
assertThat(granted.steps).containsExactly(
OnboardingStep.Permission,
OnboardingStep.Reminders,
OnboardingStep.Backup,
OnboardingStep.View,
OnboardingStep.MonthStyle,
OnboardingStep.Done,
).inOrder()
assertThat(granted.current).isEqualTo(OnboardingStep.Reminders)
assertThat(granted.index).isEqualTo(2)
}
@Test
fun `a synced calendar drops the backup step`() {
val synced = plan(
hasPermission = true,
wizardArmed = true,
remindersDone = true,
backupApplies = false,
)
assertThat(synced.steps).doesNotContain(OnboardingStep.Backup)
assertThat(synced.current).isEqualTo(OnboardingStep.View)
assertThat(synced.total).isEqualTo(5)
}
@Test
fun `the backup step is assumed until the calendars can be read`() {
// Before the grant the answer is unknowable, so the flow is planned at
// its longest — it may shrink afterwards, never grow.
assertThat(plan(backupApplies = null).steps).contains(OnboardingStep.Backup)
}
@Test
fun `an existing install is not re-onboarded`() {
val existing = plan(hasPermission = true, remindersDone = true)
assertThat(existing.steps).isEmpty()
assertThat(existing.current).isNull()
}
@Test
fun `an existing install owing only the reminder step gets no counter`() {
val existing = plan(hasPermission = true)
assertThat(existing.steps).containsExactly(OnboardingStep.Reminders)
assertThat(existing.current).isEqualTo(OnboardingStep.Reminders)
assertThat(existing.showsProgress).isFalse()
}
@Test
fun `re-granting the permission on an onboarded install skips the extra steps`() {
// Revoked and granted again: the reminder step is already answered, so
// this is not a fresh install and only the permission is owed.
val revoked = plan(remindersDone = true)
assertThat(revoked.steps).containsExactly(OnboardingStep.Permission)
assertThat(revoked.showsProgress).isFalse()
}
@Test
fun `re-granting after the wizard itself ran does not re-enter it`() {
// The closing screen clears the armed flag, so someone the wizard did
// onboard ends up where any other revoker does: one screen, no counter.
val revoked = plan(remindersDone = true, wizardArmed = false, doneShown = true)
assertThat(revoked.steps).containsExactly(OnboardingStep.Permission)
assertThat(revoked.showsProgress).isFalse()
}
@Test
fun `a read notice leaves nothing behind in a later plan`() {
// The notice is retired once read, so its step stops padding the count
// on a later re-grant.
val later = plan(remindersDone = true, visibilityArmed = false, visibilityDone = true)
assertThat(later.steps).containsExactly(OnboardingStep.Permission)
assertThat(later.showsProgress).isFalse()
}
@Test
fun `answering every step ends the flow`() {
val done = plan(
hasPermission = true,
remindersDone = true,
wizardArmed = true,
backupDone = true,
viewDone = true,
monthStyleDone = true,
backupApplies = true,
doneShown = true,
)
assertThat(done.current).isNull()
assertThat(done.index).isEqualTo(0)
}
@Test
fun `the month style is asked whatever view was chosen`() {
// Month is reachable from the drawer whatever opens first, and making
// the step conditional would move the counter on the step before it.
val afterView = plan(
hasPermission = true,
wizardArmed = true,
remindersDone = true,
backupDone = true,
viewDone = true,
backupApplies = true,
)
assertThat(afterView.current).isEqualTo(OnboardingStep.MonthStyle)
}
@Test
fun `back steps to the previous answered step`() {
val onView = plan(
hasPermission = true,
wizardArmed = true,
remindersDone = true,
backupDone = true,
backupApplies = true,
)
assertThat(onView.current).isEqualTo(OnboardingStep.View)
assertThat(onView.previous).isEqualTo(OnboardingStep.Backup)
assertThat(onView.canGoBack).isTrue()
}
@Test
fun `back is refused where the previous step is the system grant`() {
// The permission is the system's to give; there is nothing to return to.
val onReminders = plan(hasPermission = true, wizardArmed = true, backupApplies = true)
assertThat(onReminders.previous).isEqualTo(OnboardingStep.Permission)
assertThat(onReminders.canGoBack).isFalse()
}
@Test
fun `back is refused on the first step and once the flow is done`() {
assertThat(plan().canGoBack).isFalse()
val done = plan(
hasPermission = true,
remindersDone = true,
wizardArmed = true,
backupDone = true,
viewDone = true,
monthStyleDone = true,
backupApplies = true,
doneShown = true,
)
assertThat(done.previous).isNull()
assertThat(done.canGoBack).isFalse()
}
@Test
fun `the wizard ends on its closing screen`() {
val lastAnswer = plan(
hasPermission = true,
remindersDone = true,
wizardArmed = true,
backupDone = true,
viewDone = true,
monthStyleDone = true,
backupApplies = true,
)
assertThat(lastAnswer.current).isEqualTo(OnboardingStep.Done)
assertThat(lastAnswer.index).isEqualTo(lastAnswer.total)
}
@Test
fun `the visibility notice is asked before the closing screen`() {
val armed = plan(
hasPermission = true,
remindersDone = true,
wizardArmed = true,
backupDone = true,
viewDone = true,
monthStyleDone = true,
backupApplies = true,
visibilityArmed = true,
)
assertThat(armed.current).isEqualTo(OnboardingStep.Visibility)
assertThat(armed.steps.last()).isEqualTo(OnboardingStep.Done)
}
@Test
fun `an onboarded install owing only the notice gets one screen and no counter`() {
// The notice is the one step an existing install can still be given —
// and on its own it is not a wizard, so there is nothing to conclude.
val notice = plan(hasPermission = true, remindersDone = true, visibilityArmed = true)
assertThat(notice.steps).containsExactly(OnboardingStep.Visibility)
assertThat(notice.current).isEqualTo(OnboardingStep.Visibility)
assertThat(notice.showsProgress).isFalse()
assertThat(notice.canGoBack).isFalse()
}
@Test
fun `reading the notice ends an otherwise onboarded flow`() {
val read = plan(
hasPermission = true,
remindersDone = true,
visibilityArmed = true,
visibilityDone = true,
)
assertThat(read.current).isNull()
}
}

View File

@@ -0,0 +1,247 @@
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.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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
/**
* Selecting search results and deleting the batch (#80): which repository call
* each kind of hit routes to, and what a read-only calendar is allowed to join.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
private val begin = 1_800_000_000_000L
private fun cal(id: Long, canModify: Boolean = true) = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = canModify,
)
private fun hit(
id: Long,
calendarId: Long = 1L,
recurring: Boolean = false,
startMillis: Long = begin,
) = SearchCandidate(
EventInstance(
instanceId = id, eventId = 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,
),
)
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 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)
}
private fun CoroutineScope.activate(vm: SearchViewModel): Job = launch { vm.state.collect {} }
@Test
fun `a batch of one-offs deletes each event whole`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L), hit(2L), hit(3L)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.toggleSelection(1L)
vm.toggleSelection(3L)
assertThat(vm.selectionHasRecurring()).isFalse()
vm.deleteSelected(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.deletedEventIds).containsExactly(1L, 3L)
assertThat(fake.deletedOccurrences).isEmpty()
assertThat(vm.selection.value).isEmpty()
assertThat(vm.deleteState.value).isEqualTo(BulkDeleteUiState.Done(deleted = 2, failed = 0))
job.cancel()
}
@Test
fun `the batch scope reaches the recurring hits only`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L), hit(2L, recurring = true)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.selectAll()
assertThat(vm.selectionHasRecurring()).isTrue()
vm.deleteSelected(RecurringWriteScope.ThisEvent)
advanceUntilIdle()
// The one-off goes whole whatever the batch decided; only the series
// sees the scope, cancelling the occurrence the result row stands for.
assertThat(fake.deletedEventIds).containsExactly(1L)
assertThat(fake.deletedOccurrences).containsExactly(2L to begin)
job.cancel()
}
@Test
fun `this-and-following truncates the recurring hit from its shown occurrence`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(9L, recurring = true)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.toggleSelection(9L)
vm.deleteSelected(RecurringWriteScope.ThisAndFollowing)
advanceUntilIdle()
assertThat(fake.deletedFromOccurrences).containsExactly(9L to begin)
job.cancel()
}
@Test
fun `a read-only calendar's hit cannot be selected or deleted`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, canModify = false))
searchResult = { listOf(hit(1L, calendarId = 1L), hit(2L, calendarId = 2L)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
val results = vm.state.value as SearchUiState.Results
assertThat(results.isDeletable(results.events.single { it.eventId == 2L })).isFalse()
vm.selectAll()
assertThat(vm.selection.value).containsExactly(1L)
// Even a hand-toggled read-only row is dropped before the write.
vm.toggleSelection(2L)
vm.deleteSelected(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.deletedEventIds).containsExactly(1L)
job.cancel()
}
@Test
fun `a failing delete leaves the rest of the batch alone`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L), hit(2L)) }
writeError = IllegalStateException("provider said no")
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.selectAll()
vm.deleteSelected(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(vm.deleteState.value)
.isEqualTo(BulkDeleteUiState.Done(deleted = 0, failed = 2))
job.cancel()
}
@Test
fun `a permission revoked mid-batch still reports what already went`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L), hit(2L), hit(3L)) }
writeError = SecurityException("revoked")
deletesBeforeError = 1
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.selectAll()
vm.deleteSelected(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.deletedEventIds).hasSize(1)
assertThat(vm.deleteState.value)
.isEqualTo(BulkDeleteUiState.NeedsPermission(deleted = 1))
job.cancel()
}
@Test
fun `changing the query drops the selection`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.toggleSelection(1L)
assertThat(vm.selection.value).isNotEmpty()
vm.setQuery("retro")
assertThat(vm.selection.value).isEmpty()
job.cancel()
}
}