From 1807f228e72ce8090657eb084ae14441402b9415 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 2 Nov 2026 20:31:21 +0100 Subject: [PATCH] Share one date span across every view (#457) (#466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching back to month view after going before 1 Jan 1900 in day or week view crashed with `IndexOutOfBoundsException: index: -1, size: 12`. Each view had its own range: the month grid and the date picker covered 1900–2100, while the week and day pagers reached roughly 1808–2191. The month view took the date it was handed without checking it. - A new `CalendarSpan` (1 Jan 1600 – 31 Dec 2399) is used by the month and dense-week grids, the week, multi-day and day pagers, and the date picker's `yearRange`. No view can hand another a date it can't show, and you can now go past 2100 and before 1900 everywhere. - Every write to the month view's current month goes through one clamp, and `yearMonthForIndex` can't throw on any index. This is a safety net for dates arriving from outside, such as another app opening Calendula via the time intent. - Tapping a greyed-out day past either end of the grid is ignored. The timeline pagers now number pages from the span's first day rather than around a fixed middle page, so page numbers change once. `PageNumberingTest` pins the new numbering. Closes #457 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/466 --- .../ui/common/CalendarDatePickerDialog.kt | 4 +- .../calendula/ui/common/CalendarSpan.kt | 14 ++++ .../calendula/ui/month/MonthScreen.kt | 11 ++- .../calendula/ui/month/MonthViewModel.kt | 84 ++++++++++++------- .../calendula/ui/week/WeekPaging.kt | 34 ++++---- .../ui/month/ContinuousMonthIndexTest.kt | 36 ++++++-- .../ui/month/MonthViewModelRangeTest.kt | 75 +++++++++++++++++ .../calendula/ui/week/PageNumberingTest.kt | 72 ++++++++-------- 8 files changed, 236 insertions(+), 94 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSpan.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModelRangeTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt index 9c6af33..e54004a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt @@ -30,7 +30,9 @@ fun CalendarDatePickerDialog( onDismiss: () -> Unit, ) { val state = rememberDatePickerState( - initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY, + initialSelectedDateMillis = initial.coerceIn(CalendarSpan.FIRST_DAY, CalendarSpan.LAST_DAY) + .toEpochDays() * MILLIS_PER_DAY, + yearRange = CalendarSpan.years, ) DatePickerDialog( onDismissRequest = onDismiss, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSpan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSpan.kt new file mode 100644 index 0000000..ef1dc7b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSpan.kt @@ -0,0 +1,14 @@ +package de.jeanlucmakiola.calendula.ui.common + +import kotlinx.datetime.LocalDate + +/** + * The dates every view can show and every date picker offers. One span for all + * of them, so no view can hand another a date it has no page for (#457). + */ +object CalendarSpan { + val FIRST_DAY: LocalDate = LocalDate(1600, 1, 1) + val LAST_DAY: LocalDate = LocalDate(2399, 12, 31) + + val years: IntRange get() = FIRST_DAY.year..LAST_DAY.year +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 72eff38..5b8970f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -363,7 +363,7 @@ fun MonthScreen( if (!scrolling) return@LaunchedEffect snapshotFlow { // Empty before the first layout pass — and reporting a default of 0 - // for it would claim January 1900 is on screen and drag the whole + // for it would claim the span's first month is on screen and drag the whole // window back there. val visible = listState.layoutInfo.visibleItemsInfo when { @@ -448,7 +448,8 @@ fun MonthScreen( } } // Drawer jump-to-date. - val jumpToDate: (LocalDate) -> Unit = { target -> + val jumpToDate: (LocalDate) -> Unit = { date -> + val target = coerceToMonthRange(date) if (scrolling) { val targetMonth = target.yearMonth pendingScrollMonth = targetMonth @@ -551,8 +552,10 @@ fun MonthScreen( // month, pager and all — straight away, or a swipe // still settling would land and take the tap back. onSelectDay = { date -> - viewModel.selectDate(date) - jumpToMonth(date.yearMonth) + if (isInMonthRange(date)) { + viewModel.selectDate(date) + jumpToMonth(date.yearMonth) + } }, onOpenDay = onOpenDay, onEventClick = onEventClick, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index e84d382..d2d153c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure +import de.jeanlucmakiola.calendula.ui.common.CalendarSpan import de.jeanlucmakiola.calendula.ui.common.PageStateCache import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.eventDisplayComparator @@ -95,7 +96,7 @@ class MonthViewModel @Inject constructor( val today: StateFlow = todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday) - private val _month = MutableStateFlow(todayDate.yearMonth) + private val _month = MutableStateFlow(yearMonthForIndex(monthIndexOf(todayDate.yearMonth))) val month: StateFlow = _month /** @@ -149,7 +150,7 @@ class MonthViewModel @Inject constructor( /** The pager came to rest on [page]; follow it unless it is already the anchor's. */ fun onPageSettled(page: Int) { if (monthIndexOf(_month.value) == page) return - _month.value = yearMonthForIndex(page) + setMonth(yearMonthForIndex(page)) // Moving the grid to another month restores its full agenda. _selectedDate.value = null } @@ -298,9 +299,11 @@ class MonthViewModel @Inject constructor( * silently list a date the grid isn't pointing at. */ fun selectDate(date: LocalDate) { + // A greyed-out day past either end of the grid has no month to follow to. + if (!isInMonthRange(date)) return val target = date.yearMonth _selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date - if (target != _month.value) _month.value = target + if (target != _month.value) setMonth(target) } fun clearSelection() { _selectedDate.value = null } @@ -309,16 +312,16 @@ class MonthViewModel @Inject constructor( fun stepSplitPane(days: Int) { val selected = _selectedDate.value if (selected == null) { - _month.value = yearMonthForIndex(monthIndexOf(_month.value) + days) + setMonth(yearMonthForIndex(monthIndexOf(_month.value) + days)) } else { - val next = selected.plus(days, DateTimeUnit.DAY) + val next = coerceToMonthRange(selected.plus(days, DateTimeUnit.DAY)) _selectedDate.value = next - _month.value = next.yearMonth + setMonth(next.yearMonth) } } fun goToToday() { - _month.value = todayDate.yearMonth + setMonth(todayDate.yearMonth) _selectedDate.value = null } @@ -330,7 +333,7 @@ class MonthViewModel @Inject constructor( private fun followToday(old: LocalDate, new: LocalDate) { if (viewStyle.value.isScrolling) return if (_month.value != old.yearMonth) return - _month.value = new.yearMonth + setMonth(new.yearMonth) _selectedDate.value = null } @@ -344,14 +347,24 @@ class MonthViewModel @Inject constructor( */ fun syncScrollMonth(ym: YearMonth) { if (_month.value == ym) return - _month.value = ym + setMonth(ym) _selectedDate.value = null } - /** Jump to the month containing [date] (drawer jump-to-date). */ + /** + * Jump to the month containing [date] (drawer jump-to-date, or the focus + * handed over by another view). The focus itself keeps a date outside the + * grid's span, so the day and week views return to it unchanged. + */ fun goToDate(date: LocalDate) { - _month.value = date.yearMonth - _selectedDate.value = date + val target = coerceToMonthRange(date) + setMonth(target.yearMonth) + _selectedDate.value = target + } + + /** Every write to [_month] goes through here, so it can't leave the grid's span (#457). */ + private fun setMonth(ym: YearMonth) { + _month.value = yearMonthForIndex(monthIndexOf(ym)) } private fun buildState( @@ -578,24 +591,39 @@ internal fun nextLoadWindow( /** * The continuous grid addresses months by an absolute index, so the list has one * stable, gap-free coordinate space to scroll through and key its items by. - * Index 0 is January 1900; the list runs to [continuousMonthCount]. + * Index 0 is the first month of [CalendarSpan]; the list runs to [continuousMonthCount]. * * Unlike the week indexing this replaced, the coordinate space doesn't depend on * the week-start preference — changing it reflows the rows *inside* a month * block but never moves the block, so a scroll position stays put. */ -private const val MONTH_INDEX_EPOCH_YEAR = 1900 -private const val MONTH_INDEX_END_YEAR = 2100 +private val MONTH_INDEX_EPOCH_YEAR = CalendarSpan.FIRST_DAY.year +private val MONTH_INDEX_END_YEAR = CalendarSpan.LAST_DAY.year + +/** The first and last day the month and Dense grids can show. */ +private val GRID_FIRST_DAY = LocalDate(MONTH_INDEX_EPOCH_YEAR, 1, 1) +private val GRID_LAST_DAY = LocalDate(MONTH_INDEX_END_YEAR, 12, 31) internal fun monthIndexOf(ym: YearMonth): Int = (ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal -internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth( - MONTH_INDEX_EPOCH_YEAR + index / 12, - Month.entries[index % 12], -) +/** The month at [index], held to the grid's span, so a stray index can't throw (#457). */ +internal fun yearMonthForIndex(index: Int): YearMonth { + val held = clampMonthIndex(index) + return YearMonth(MONTH_INDEX_EPOCH_YEAR + held / 12, Month.entries[held % 12]) +} -/** Total months the continuous grid scrolls through (1900 → 2100). */ +internal fun clampMonthIndex(index: Int): Int = index.coerceIn(0, continuousMonthCount() - 1) + +/** + * Hold [date] inside the span the month grid can show. Every view shares + * [CalendarSpan], but another app can open Calendula on any date (#457). + */ +internal fun coerceToMonthRange(date: LocalDate): LocalDate = date.coerceIn(GRID_FIRST_DAY, GRID_LAST_DAY) + +internal fun isInMonthRange(date: LocalDate): Boolean = date in GRID_FIRST_DAY..GRID_LAST_DAY + +/** Total months the continuous grid scrolls through. */ internal fun continuousMonthCount(): Int = (MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12 @@ -611,25 +639,23 @@ internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2 /** * Hold a load window inside the months that actually exist. The pad * [nextLoadWindow] adds can otherwise push an index past either end of the - * 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one. + * span, and [yearMonthForIndex] has no month to hand back for one. */ -internal fun clampMonthWindow(window: IntRange): IntRange { - val last = continuousMonthCount() - 1 - return window.first.coerceIn(0, last)..window.last.coerceIn(0, last) -} +internal fun clampMonthWindow(window: IntRange): IntRange = + clampMonthIndex(window.first)..clampMonthIndex(window.last) /** * The Dense style addresses *weeks* by absolute index instead: it has no month * blocks to key by, and a row's identity has to survive the months around it - * scrolling past. Index 0 is the first week of 1900 under the active week-start, + * scrolling past. Index 0 is the span's first week under the active week-start, * so — like the month space — every index is non-negative and the LazyColumn's * item indices and week indices are the same number. * * Unlike month indices these *do* shift with the week-start preference, which is * why the list state is keyed on it. */ -private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1) -private val WEEK_INDEX_END = LocalDate(2100, 12, 31) +private val WEEK_INDEX_EPOCH = GRID_FIRST_DAY +private val WEEK_INDEX_END = GRID_LAST_DAY internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart) @@ -639,7 +665,7 @@ internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) -/** Total weeks the Dense grid scrolls through (1900 → 2100). */ +/** Total weeks the Dense grid scrolls through. */ internal fun continuousWeekCount(weekStart: DayOfWeek): Int = weekIndexOf(WEEK_INDEX_END, weekStart) + 1 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt index 150b3f4..5e87b44 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week +import de.jeanlucmakiola.calendula.ui.common.CalendarSpan import de.jeanlucmakiola.calendula.ui.common.startOfWeek import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -7,16 +8,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.daysUntil import kotlinx.datetime.plus -/** Pages the week pager spans — a little under four centuries. */ -internal const val WEEK_PAGE_COUNT: Int = 20_000 - -/** Pages the multi-day pager spans — over three centuries at the widest page. */ -internal const val DAYS_PAGE_COUNT: Int = 40_000 - -/** Pages the day pager spans — a little under four centuries. */ -internal const val DAY_PAGE_COUNT: Int = 140_000 - -/** A fixed day every grid's pages are counted from, at its middle page. */ +/** A fixed day every grid's pages are counted from. */ private val DAYS_EPOCH = LocalDate(2000, 1, 1) /** The week the week pager's middle page shows; any date works, it only has to stay fixed. */ @@ -24,13 +16,19 @@ private val WEEK_EPOCH = LocalDate(2000, 1, 3) /** * How a timeline pager cuts the calendar: runs of [dayCount] days, offset - * [phase] days from a fixed epoch, over [pageCount] pages centred on it. The - * multi-day view's phase (#193) stays put while paging and only changes when a - * jump moves another date to the first column. + * [phase] days from a fixed epoch, over as many pages as cover [CalendarSpan]. + * The multi-day view's phase (#193) stays put while paging and only changes when + * a jump moves another date to the first column. */ -data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) { +data class DaysGrid(val dayCount: Int, val phase: Int) { - private val epochPage: Int get() = pageCount / 2 + /** The page showing the epoch; page 0 is the one holding [CalendarSpan.FIRST_DAY]. */ + private val epochPage: Int get() = -pagesFromEpoch(CalendarSpan.FIRST_DAY) + + val pageCount: Int get() = epochPage + pagesFromEpoch(CalendarSpan.LAST_DAY) + 1 + + private fun pagesFromEpoch(date: LocalDate): Int = + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount) /** First day shown on pager [page]. */ fun startForPage(page: Int): LocalDate = @@ -38,18 +36,16 @@ data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS /** The pager page showing [date]. */ fun pageFor(date: LocalDate): Int = - (epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)) - .coerceIn(0, pageCount - 1) + (epochPage + pagesFromEpoch(date)).coerceIn(0, pageCount - 1) companion object { /** Single days: every date starts a page of its own. */ - val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0, pageCount = DAY_PAGE_COUNT) + val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0) /** Whole weeks, starting on [firstDay]. */ fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid( dayCount = 7, phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)), - pageCount = WEEK_PAGE_COUNT, ) /** The grid of [dayCount]-day pages on which [date] is a page's first day. */ diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index c05cd3a..7d0e861 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -10,6 +10,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.minus import kotlinx.datetime.plus +import kotlinx.datetime.yearMonth import org.junit.jupiter.api.Test /** @@ -34,22 +35,22 @@ class ContinuousMonthIndexTest { @Test fun `index round-trips back to its month`() { listOf( - YearMonth(1900, Month.JANUARY), + YearMonth(1600, Month.JANUARY), jun26, YearMonth(2026, Month.DECEMBER), - YearMonth(2100, Month.DECEMBER), + YearMonth(2399, Month.DECEMBER), ).forEach { ym -> assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym) } } @Test - fun `indices are non-negative and span 1900 through 2100`() { - assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0) + fun `indices are non-negative and span 1600 through 2399`() { + assertThat(monthIndexOf(YearMonth(1600, Month.JANUARY))).isEqualTo(0) assertThat(monthIndexOf(jun26)).isGreaterThan(0) - assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER))) + assertThat(monthIndexOf(YearMonth(2399, Month.DECEMBER))) .isEqualTo(continuousMonthCount() - 1) - assertThat(continuousMonthCount()).isEqualTo(201 * 12) + assertThat(continuousMonthCount()).isEqualTo(800 * 12) } @Test @@ -154,9 +155,9 @@ class ContinuousMonthIndexTest { DayOfWeek.entries.forEach { ws -> val index = weekIndexOf(wed, ws) assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfWeek(ws)) - assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) + assertThat(weekIndexOf(LocalDate(1600, 1, 1), ws)).isAtLeast(0) assertThat(index).isLessThan(continuousWeekCount(ws)) - assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)) + assertThat(weekIndexOf(LocalDate(2399, 12, 31), ws)) .isLessThan(continuousWeekCount(ws)) } } @@ -236,4 +237,23 @@ class ContinuousMonthIndexTest { assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today)) .isEqualTo(LocalDate(2025, 6, 1)) } + + @Test + fun `a date before 1600 is held at the first day of the grid`() { + val held = coerceToMonthRange(LocalDate(1599, 12, 31)) + assertThat(held).isEqualTo(LocalDate(1600, 1, 1)) + assertThat(monthIndexOf(held.yearMonth)).isEqualTo(0) + } + + @Test + fun `a date after 2399 is held at the last day of the grid`() { + val held = coerceToMonthRange(LocalDate(2400, 1, 1)) + assertThat(held).isEqualTo(LocalDate(2399, 12, 31)) + assertThat(monthIndexOf(held.yearMonth)).isEqualTo(continuousMonthCount() - 1) + } + + @Test + fun `a date inside the span is left alone`() { + assertThat(coerceToMonthRange(LocalDate(2026, 6, 10))).isEqualTo(LocalDate(2026, 6, 10)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModelRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModelRangeTest.kt new file mode 100644 index 0000000..fb1e12d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModelRangeTest.kt @@ -0,0 +1,75 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.MainDispatcherExtension +import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource +import de.jeanlucmakiola.calendula.data.time.TodaySource +import de.jeanlucmakiola.calendula.testPrefs +import de.jeanlucmakiola.calendula.testRepository +import de.jeanlucmakiola.calendula.testSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.YearMonth +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** The month view never leaves the span its pager can show (#457). */ +@OptIn(ExperimentalCoroutinesApi::class) +class MonthViewModelRangeTest { + + @JvmField @RegisterExtension + val mainDispatcher = MainDispatcherExtension() + private val dispatcher = mainDispatcher.dispatcher + + private fun viewModel(tempDir: Path): MonthViewModel { + val settings = testSettings(tempDir, CoroutineScope(dispatcher)) + val repo = testRepository( + FakeCalendarDataSource(), tempDir, dispatcher, + prefs = testPrefs(tempDir, CoroutineScope(dispatcher)), + settings = settings, + ) + val today = object : TodaySource { + override val today = flowOf(LocalDate(2026, 11, 2)) + } + return MonthViewModel(repo, settings, today, dispatcher) + } + + @Test + fun `a focus before 1600 opens the first month`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir) + vm.goToDate(LocalDate(1599, 12, 31)) + assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY)) + assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1)) + } + + @Test + fun `a focus after 2399 opens the last month`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir) + vm.goToDate(LocalDate(2400, 1, 1)) + assertThat(vm.month.value).isEqualTo(YearMonth(2399, Month.DECEMBER)) + } + + @Test + fun `a tap on a day past the span is ignored`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir) + vm.goToDate(LocalDate(1600, 1, 1)) + vm.selectDate(LocalDate(1599, 12, 31)) + assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY)) + assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1)) + } + + @Test + fun `the split pane stops at the first month`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir) + vm.goToDate(LocalDate(1600, 1, 15)) + vm.clearSelection() + vm.stepSplitPane(-1) + assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt index 93a5814..10422a1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt @@ -10,8 +10,9 @@ import kotlinx.datetime.plus import org.junit.jupiter.api.Test /** - * Pins each grid's page numbering to the formulas it shipped with: a pager - * restores its page by number, so a change here moves the user to another date. + * Pins each grid's page numbering: a pager restores its page by number, so a + * change here moves the user to another date. Page 0 holds the first day of + * the shared span, and the last page holds its last day (#457). */ class PageNumberingTest { @@ -26,6 +27,7 @@ class PageNumberingTest { add(e) e = e.plus(1, DateTimeUnit.DAY) } + addAll(listOf(FIRST, FIRST.minus(1, DateTimeUnit.DAY), LAST, LAST.plus(1, DateTimeUnit.DAY))) } private fun pages(count: Int): List = @@ -36,13 +38,14 @@ class PageNumberingTest { fun `week pages keep their numbering for every first day`() { DayOfWeek.entries.forEach { firstDay -> val grid = DaysGrid.weeks(firstDay) + val phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.weekStart(firstDay)) assertThat(grid.dayCount).isEqualTo(7) - assertThat(grid.pageCount).isEqualTo(LEGACY_WEEK_COUNT) - pages(LEGACY_WEEK_COUNT).forEach { page -> - assertThat(grid.startForPage(page)).isEqualTo(legacyWeekStart(page, firstDay)) + assertThat(grid.pageCount).isEqualTo(expectedCount(7, phase)) + pages(grid.pageCount).forEach { page -> + assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, 7, phase)) } dates.forEach { date -> - assertThat(grid.pageFor(date)).isEqualTo(legacyWeekPage(date, firstDay)) + assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, 7, phase)) } } } @@ -52,12 +55,12 @@ class PageNumberingTest { (1..7).forEach { count -> (-count..count * 2).forEach { phase -> val grid = DaysGrid(count, phase) - assertThat(grid.pageCount).isEqualTo(LEGACY_DAYS_COUNT) - pages(LEGACY_DAYS_COUNT).forEach { page -> - assertThat(grid.startForPage(page)).isEqualTo(legacyDaysStart(page, count, phase)) + assertThat(grid.pageCount).isEqualTo(expectedCount(count, phase)) + pages(grid.pageCount).forEach { page -> + assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, count, phase)) } dates.forEach { date -> - assertThat(grid.pageFor(date)).isEqualTo(legacyDaysPage(date, count, phase)) + assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, count, phase)) } } dates.filter { it.year in 1950..2050 }.forEach { date -> @@ -71,12 +74,21 @@ class PageNumberingTest { fun `day pages keep their numbering`() { val grid = DaysGrid.days assertThat(grid.dayCount).isEqualTo(1) - assertThat(grid.pageCount).isEqualTo(LEGACY_DAY_COUNT) - pages(LEGACY_DAY_COUNT).forEach { page -> - assertThat(grid.startForPage(page)).isEqualTo(legacyDayStart(page)) + assertThat(grid.pageCount).isEqualTo(FIRST.daysUntil(LAST) + 1) + pages(grid.pageCount).forEach { page -> + assertThat(grid.startForPage(page)).isEqualTo(FIRST.plus(page, DateTimeUnit.DAY)) } dates.forEach { date -> - assertThat(grid.pageFor(date)).isEqualTo(legacyDayPage(date)) + assertThat(grid.pageFor(date)).isEqualTo(FIRST.daysUntil(date).coerceIn(0, grid.pageCount - 1)) + } + } + + @Test + fun `every grid reaches both ends of the span`() { + (DayOfWeek.entries.map { DaysGrid.weeks(it) } + DaysGrid.days + (1..7).map { DaysGrid(it, 0) }).forEach { grid -> + assertThat(grid.startForPage(grid.pageFor(FIRST))).isAtMost(FIRST) + val lastStart = grid.startForPage(grid.pageFor(LAST)) + assertThat(lastStart.plus(grid.dayCount - 1, DateTimeUnit.DAY)).isAtLeast(LAST) } } @@ -104,34 +116,28 @@ class PageNumberingTest { } private companion object { - const val LEGACY_WEEK_COUNT = 20_000 - const val LEGACY_DAYS_COUNT = 40_000 - const val LEGACY_DAY_COUNT = 140_000 + val FIRST = LocalDate(1600, 1, 1) + val LAST = LocalDate(2399, 12, 31) val WEEK_EPOCH = LocalDate(2000, 1, 3) val DAYS_EPOCH = LocalDate(2000, 1, 1) fun LocalDate.weekStart(firstDay: DayOfWeek): LocalDate = minus(((dayOfWeek.ordinal - firstDay.ordinal) + 7) % 7, DateTimeUnit.DAY) - fun legacyWeekStart(page: Int, firstDay: DayOfWeek): LocalDate = - WEEK_EPOCH.weekStart(firstDay).plus((page - LEGACY_WEEK_COUNT / 2) * 7, DateTimeUnit.DAY) - - fun legacyWeekPage(date: LocalDate, firstDay: DayOfWeek): Int { - val days = WEEK_EPOCH.weekStart(firstDay).daysUntil(date.weekStart(firstDay)) - return (LEGACY_WEEK_COUNT / 2 + days / 7).coerceIn(0, LEGACY_WEEK_COUNT - 1) + /** Page 0 starts on the page boundary at or before [FIRST]. */ + fun firstStart(count: Int, phase: Int): LocalDate { + val origin = DAYS_EPOCH.plus(phase, DateTimeUnit.DAY) + return origin.plus(Math.floorDiv(origin.daysUntil(FIRST), count) * count, DateTimeUnit.DAY) } - fun legacyDaysStart(page: Int, count: Int, phase: Int): LocalDate = - DAYS_EPOCH.plus(phase + (page - LEGACY_DAYS_COUNT / 2) * count, DateTimeUnit.DAY) + fun expectedStart(page: Int, count: Int, phase: Int): LocalDate = + firstStart(count, phase).plus(page * count, DateTimeUnit.DAY) - fun legacyDaysPage(date: LocalDate, count: Int, phase: Int): Int = - (LEGACY_DAYS_COUNT / 2 + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, count)) - .coerceIn(0, LEGACY_DAYS_COUNT - 1) + fun expectedCount(count: Int, phase: Int): Int = + Math.floorDiv(firstStart(count, phase).daysUntil(LAST), count) + 1 - fun legacyDayStart(page: Int): LocalDate = - DAYS_EPOCH.plus(page - LEGACY_DAY_COUNT / 2, DateTimeUnit.DAY) - - fun legacyDayPage(date: LocalDate): Int = - (LEGACY_DAY_COUNT / 2 + DAYS_EPOCH.daysUntil(date)).coerceIn(0, LEGACY_DAY_COUNT - 1) + fun expectedPage(date: LocalDate, count: Int, phase: Int): Int = + Math.floorDiv(firstStart(count, phase).daysUntil(date), count) + .coerceIn(0, expectedCount(count, phase) - 1) } }