2.19.0 — drag to reschedule, bulk delete from search, onboarding wizard (#172)
All checks were successful
All checks were successful
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/172
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user