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 <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/466
This commit is contained in:
1 parent
b794c35d8e
commit
1807f228e7
8 files changed
+236
-94
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LocalDate> =
|
||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
|
||||
|
||||
private val _month = MutableStateFlow(todayDate.yearMonth)
|
||||
private val _month = MutableStateFlow(yearMonthForIndex(monthIndexOf(todayDate.yearMonth)))
|
||||
val month: StateFlow<YearMonth> = _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
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<Int> =
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user