diff --git a/.gitea/workflows/translations.yaml b/.gitea/workflows/translations.yaml index 0d2e8f8..c621258 100644 --- a/.gitea/workflows/translations.yaml +++ b/.gitea/workflows/translations.yaml @@ -3,13 +3,15 @@ name: Translations # Fast, SDK-free parity check for translation resources, so Weblate PRs (which # only touch values-*/strings.xml) get quick feedback without the full Android # build. The deeper checks still run in CI via lintDebug (ExtraTranslation). +# +# Runs on every PR (no path filter) so the required "Translations / check" +# status is always reported — like the `ci` job. A path-filtered workflow is +# skipped on unrelated PRs and never posts its status, which leaves that +# required check pending forever and blocks the merge of any code-only PR into a +# release/* branch. The check itself is cheap and simply passes when the +# committed translations are consistent, so always running it costs nothing. on: pull_request: - paths: - - 'app/src/main/res/values*/strings.xml' - - 'app/src/main/res/xml/locales_config.xml' - - 'scripts/check_translations.py' - - '.gitea/workflows/translations.yaml' concurrency: group: translations-${{ github.ref }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4762245..ce551f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- A custom snooze duration. The **Snooze duration** setting (Settings → + Notifications) gains a **Custom…** option next to the minute presets: pick any + amount and switch between minutes and hours, so a snoozed reminder comes back + after exactly the delay you want instead of only a preset one ([#40]). +- Move an event to another calendar. When editing an existing event, the + calendar row is now tappable — pick a different calendar and saving moves the + event across, instead of having to delete it and recreate it elsewhere. + Recurring series move as a whole, keeping their individually-edited and + cancelled occurrences, and any reminders and guests come along too. A calendar + can't simply be reassigned underneath an event, so Calendula recreates it on + the target and removes the original — the same approach other calendar apps + take. Thanks to @prismplex for the suggestion ([#39]). +- Open an event straight into the edit form from another app. Calendula already + answered the "new event" and "open this event" hand-offs from other apps and + widgets; it now also answers the "edit this event" one, so an assistant, task + app, or widget can send an existing event to Calendula and land on its edit + screen rather than the read-only details. A hand-off with no event attached + opens the same prefilled create form as "new event". Calendula also recognises + a couple more file labels the same calendar data arrives under (`.vcs` + vCalendar files and the `application/ics` type), so opening or sharing those + into Calendula works too. +- Keep today at the top of the agenda. A new **Always show today** setting + (Settings → Agenda, on by default) anchors today as the first entry in both the + Agenda screen and its home-screen widget even once nothing is left today — + under today's header a small "No more events today" card appears — so the first + events you see are clearly today's rather than a future day's. Turn it off to + keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]). + +### Fixed +- Reminders for events on another day no longer read as if they were today. A + reminder fired ahead of time — say, the day before — used to show only the + event's time, making it look like it was happening now. The notification now + says which day: **Tomorrow** or **Yesterday**, the weekday for another day this + week, or the date for anything further out. Thanks to @moonj for the report + ([#46]). + ## [2.14.1] — 2026-07-13 ### Fixed @@ -913,6 +950,10 @@ automatically, with zero telemetry and no internet permission. [#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37 +[#39]: https://codeberg.org/jlmakiola/calendula/issues/39 +[#35]: https://codeberg.org/jlmakiola/calendula/issues/35 +[#40]: https://codeberg.org/jlmakiola/calendula/issues/40 +[#46]: https://codeberg.org/jlmakiola/calendula/issues/46 [#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 47866af..930ab70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -97,32 +97,58 @@ - + - - + + + + + - + + + + (MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the + dir mime is AOSP's "edit a new event" — i.e. create — so it maps + to the same prefilled create form. --> + + + + + + + + + Tomorrow + Yesterday Month @@ -253,6 +256,7 @@ Today Tomorrow You\'re all caught up + No more events today Search @@ -319,6 +323,8 @@ How far ahead the Agenda screen lists events. Agenda widget range How far ahead the agenda home-screen widget lists events. + Always show today + Keep today at the top of the agenda and its widget, even once nothing is left today. Range bar Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session Today diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index ed8e728..14fa836 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -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 { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 7aa8d78..972b9d9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -409,4 +409,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") + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index d62ddae..dce1a32 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource { val insertedForms = mutableListOf() val updatedEvents = mutableListOf>() + data class MovedEvent( + val eventId: Long, + val targetCalendarId: Long, + val original: EventForm, + val updated: EventForm, + ) + val movedEvents = mutableListOf() val updatedOccurrences = mutableListOf>() val updatedFromOccurrences = mutableListOf>() val deletedEventIds = mutableListOf() @@ -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, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt index 4d3f6c6..c8014a1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test +import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId @@ -18,41 +19,110 @@ class ReminderTimeTextTest { private fun utcMidnight(date: LocalDate): Long = date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + /** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */ + private fun text( + beginMillis: Long, + endMillis: Long, + isAllDay: Boolean = false, + zone: ZoneId = berlin, + locale: Locale = Locale.GERMANY, + is24Hour: Boolean = true, + today: LocalDate? = null, + firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, + ): String = reminderTimeText( + beginMillis = beginMillis, + endMillis = endMillis, + isAllDay = isAllDay, + zone = zone, + locale = locale, + is24Hour = is24Hour, + today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(), + firstDayOfWeek = firstDayOfWeek, + tomorrowLabel = "Tomorrow", + yesterdayLabel = "Yesterday", + ) + @Test - fun `timed event on one day shows just the time range`() { - val text = reminderTimeText( + fun `timed event today shows just the time range`() { + val text = text( beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin), - isAllDay = false, - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, ) assertThat(text).isEqualTo("09:30 – 10:00") } @Test fun `12-hour preference renders an am-pm time range`() { - val text = reminderTimeText( + val text = text( beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin), - isAllDay = false, - zone = berlin, locale = Locale.US, is24Hour = false, ) assertThat(text).isEqualTo("2:00 PM – 3:00 PM") } + @Test + fun `tomorrow's event is prefixed with the tomorrow label`() { + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("Tomorrow, 09:30 – 10:00") + } + + @Test + fun `yesterday's event is prefixed with the yesterday label`() { + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("Yesterday, 09:30 – 10:00") + } + + @Test + fun `an event later this week is prefixed with the short weekday`() { + // 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week. + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("So., 09:30 – 10:00") + } + + @Test + fun `the week boundary honours the week-start setting`() { + // Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the + // Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week + // and must render as a date — the opposite of the Monday-start case above. + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + firstDayOfWeek = DayOfWeek.SUNDAY, + ) + assertThat(text).isEqualTo("14.06.2026, 09:30 – 10:00") + } + + @Test + fun `an event next week falls back to the exact date, not a weekday`() { + // 2026-06-15 is the following Monday — a weekday alone would be ambiguous. + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("15.06.2026, 09:30 – 10:00") + } + @Test fun `timed event crossing midnight includes both dates`() { - val text = reminderTimeText( + val text = text( beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin), - isAllDay = false, - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, ) assertThat(text).contains("11.06.2026") assertThat(text).contains("12.06.2026") @@ -61,28 +131,35 @@ class ReminderTimeTextTest { @Test fun `all-day single day shows one date, read in UTC`() { - val text = reminderTimeText( + val text = text( beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), endMillis = utcMidnight(LocalDate.of(2026, 6, 12)), isAllDay = true, // Zone must not matter for all-day events: UTC midnight is // 02:00 in Berlin — naive local reading would shift the day. - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, + today = LocalDate.of(2026, 6, 11), ) assertThat(text).isEqualTo("11.06.2026") } + @Test + fun `all-day event tomorrow still shows the exact date, no relative prefix`() { + val text = text( + beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)), + endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), + isAllDay = true, + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("12.06.2026") + } + @Test fun `all-day multi-day shows the last covered day, not the exclusive end`() { - val text = reminderTimeText( + val text = text( beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), isAllDay = true, - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, + today = LocalDate.of(2026, 6, 11), ) assertThat(text).isEqualTo("11.06.2026 – 12.06.2026") } @@ -90,13 +167,11 @@ class ReminderTimeTextTest { @Test fun `degenerate all-day range never renders an inverted span`() { val day = utcMidnight(LocalDate.of(2026, 6, 11)) - val text = reminderTimeText( + val text = text( beginMillis = day, endMillis = day, isAllDay = true, - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, + today = LocalDate.of(2026, 6, 11), ) assertThat(text).isEqualTo("11.06.2026") } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt new file mode 100644 index 0000000..968b3ce --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt @@ -0,0 +1,39 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +/** Covers the agenda widget's "always show today" anchor logic (#35). */ +class AnchorTodayTest { + + private val today = LocalDate(2026, 6, 17) + private val tomorrow = LocalDate(2026, 6, 18) + + @Test + fun `disabled leaves the days untouched even when today is absent`() { + val days = listOf(AgendaDay(tomorrow, emptyList())) + assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days) + } + + @Test + fun `enabled prepends an empty today when it is missing`() { + val days = listOf(AgendaDay(tomorrow, emptyList())) + val result = anchorTodayIfMissing(days, today, enabled = true) + assertThat(result).hasSize(2) + assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList())) + assertThat(result[1]).isEqualTo(days.first()) + } + + @Test + fun `enabled anchors today into an otherwise empty list`() { + val result = anchorTodayIfMissing(emptyList(), today, enabled = true) + assertThat(result).containsExactly(AgendaDay(today, emptyList())) + } + + @Test + fun `enabled is a no-op when today already has its own day`() { + val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList())) + assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt new file mode 100644 index 0000000..51a9819 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -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() + } +} diff --git a/floret-kit b/floret-kit index 78c5fd1..2124227 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 78c5fd1fd44a063e6de67c6209af1701c69fb316 +Subproject commit 2124227a7f462b46b5833250bc26d2ae6c7e2cd0