feat(edit): move an event to another calendar (#39)
All checks were successful
CI / ci (pull_request) Successful in 8m40s
All checks were successful
CI / ci (pull_request) Successful in 8m40s
The calendar row in the editor is now tappable when editing an existing event: picking a different calendar moves the event there on save, rather than forcing a delete-and-recreate. CALENDAR_ID is sync-adapter-owned and can't be updated in place, so the move is copy+delete: the master row is re-inserted on the target calendar (preserving UID_2445 so backup dedup and sync identity survive), its reminders and editable guests are copied, and — for a recurring series — every exception is replayed against the new master (modified occurrences via CONTENT_EXCEPTION_URI, cancellations as STATUS_CANCELED). The user's field edits are then applied with the normal series update. Everything on the new side is built before the source is deleted (post-before-delete), with a rollback of the copy on any failure, so a move is all-or-nothing. A calendar change forces whole-series scope, so it skips the recurring scope dialog. Managed special-dates calendars stay locked. Colour is not carried across (a raw/keyed colour may be invalid on the target account), matching the existing calendar-switch behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -312,6 +312,46 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||
)
|
||||
val updated = original.copy(calendarId = 3L)
|
||||
|
||||
val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated)
|
||||
|
||||
assertThat(newId).isEqualTo(77L)
|
||||
assertThat(fake.movedEvents).containsExactly(
|
||||
FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
writeError = WriteFailedException("insert moved event into calendar id=3")
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
)
|
||||
|
||||
try {
|
||||
repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L))
|
||||
error("Expected WriteFailedException")
|
||||
} catch (expected: WriteFailedException) {
|
||||
assertThat(expected.message).contains("3")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
|
||||
@@ -298,4 +298,114 @@ class EventWriteMapperTest {
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||
}
|
||||
|
||||
// --- buildMovedMasterValues (calendar move: verbatim series copy) ---
|
||||
|
||||
private fun masterSnapshot(
|
||||
isAllDay: Boolean = false,
|
||||
dtStartMillis: Long = 1_781_164_800_000L,
|
||||
dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L,
|
||||
duration: String? = null,
|
||||
rrule: String? = null,
|
||||
rdate: String? = null,
|
||||
exdate: String? = null,
|
||||
) = MasterEventSnapshot(
|
||||
title = "Standup",
|
||||
isAllDay = isAllDay,
|
||||
dtStartMillis = dtStartMillis,
|
||||
dtEndMillis = dtEndMillis,
|
||||
duration = duration,
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
exdate = exdate,
|
||||
timezone = "Europe/Berlin",
|
||||
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
||||
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
||||
status = CalendarContract.Events.STATUS_CONFIRMED,
|
||||
location = "Room 1",
|
||||
description = "",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() {
|
||||
val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula")
|
||||
assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L)
|
||||
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
// Empty description clears explicitly; a raw/keyed colour is never copied
|
||||
// (may be invalid on the target account — the copy inherits its colour).
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() {
|
||||
val values = buildMovedMasterValues(
|
||||
masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"),
|
||||
targetCalendarId = 9L,
|
||||
uid = "u@calendula",
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z")
|
||||
// Recurring rows never carry DTEND (the provider's invariant).
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moved series without a stored duration derives it from DTEND`() {
|
||||
val values = buildMovedMasterValues(
|
||||
masterSnapshot(duration = null, rrule = "FREQ=DAILY"),
|
||||
targetCalendarId = 9L,
|
||||
uid = "u@calendula",
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
}
|
||||
|
||||
// --- buildCopiedExceptionValues (calendar move: exception replay) ---
|
||||
|
||||
private fun exceptionSnapshot(
|
||||
isCancelled: Boolean = false,
|
||||
duration: String? = null,
|
||||
// Occurrence starts one hour into the day and runs 30 minutes.
|
||||
dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L,
|
||||
) = ExceptionRowSnapshot(
|
||||
exceptionEventId = 42L,
|
||||
originalInstanceMillis = 1_781_164_800_000L,
|
||||
isCancelled = isCancelled,
|
||||
status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED,
|
||||
title = "Moved occurrence",
|
||||
isAllDay = false,
|
||||
dtStartMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
dtEndMillis = dtEndMillis,
|
||||
duration = duration,
|
||||
timezone = "Europe/Berlin",
|
||||
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
||||
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
||||
location = "",
|
||||
description = "Notes",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `copied modified occurrence carries the original instance and DURATION not DTEND`() {
|
||||
val values = buildCopiedExceptionValues(exceptionSnapshot())
|
||||
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
||||
.isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L)
|
||||
// 30-minute occurrence derived from DTEND, written as DURATION only.
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copied modified occurrence keeps a stored duration verbatim`() {
|
||||
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
|
||||
val insertedForms = mutableListOf<EventForm>()
|
||||
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
||||
data class MovedEvent(
|
||||
val eventId: Long,
|
||||
val targetCalendarId: Long,
|
||||
val original: EventForm,
|
||||
val updated: EventForm,
|
||||
)
|
||||
val movedEvents = mutableListOf<MovedEvent>()
|
||||
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||
val deletedEventIds = mutableListOf<Long>()
|
||||
@@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
}
|
||||
|
||||
override fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
writeError?.let { throw it }
|
||||
movedEvents += MovedEvent(eventId, targetCalendarId, original, updated)
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
override fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.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 kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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.coroutines.Dispatchers
|
||||
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
|
||||
|
||||
/**
|
||||
* Focuses on the save-time branch that decides between an in-place update and a
|
||||
* calendar *move* (copy+delete). The provider-level move itself is verified
|
||||
* on-device; here the fake records which repository call the ViewModel chose.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EventEditViewModelTest {
|
||||
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
||||
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private val beginMillis = 1_781_164_800_000L
|
||||
private val endMillis = beginMillis + 3_600_000L
|
||||
|
||||
private fun cal(id: Long): CalendarSource = CalendarSource(
|
||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
||||
)
|
||||
|
||||
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
|
||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(endMillis),
|
||||
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||
),
|
||||
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
|
||||
)
|
||||
|
||||
// Pin the DataStore's scope to the test dispatcher so a settings write (e.g.
|
||||
// last-used calendar) completes under advanceUntilIdle instead of on a real
|
||||
// IO thread the virtual clock can't observe.
|
||||
private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
private fun viewModel(
|
||||
tempDir: Path,
|
||||
fake: FakeCalendarDataSource,
|
||||
): EventEditViewModel {
|
||||
val p = prefs(tempDir)
|
||||
val s = settings(tempDir)
|
||||
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
||||
return EventEditViewModel(repo, p, s, dispatcher)
|
||||
}
|
||||
|
||||
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
||||
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
|
||||
|
||||
@Test
|
||||
fun `changing the calendar routes the save through a move, not an update`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
nextInsertId = 99L
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
vm.setCalendar(2L)
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.movedEvents).hasSize(1)
|
||||
assertThat(fake.movedEvents.single().eventId).isEqualTo(42L)
|
||||
assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L)
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L))
|
||||
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
vm.setCalendar(2L)
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// No AwaitingScope park: a move is inherently whole-series.
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
||||
assertThat(fake.movedEvents).hasSize(1)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing a recurring event without moving still asks for the scope`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L))
|
||||
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
vm.setTitle("Renamed")
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope)
|
||||
assertThat(fake.movedEvents).isEmpty()
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user