refactor(month): extract per-week layout and share the agenda's rows

Groundwork for the month view style setting (#38, #53); no behaviour change.

- Split layoutCalendarWeek out of layoutMonthWeeks so the continuous style,
  which streams weeks and has no enclosing YearMonth to slice by, lays rows
  out identically. layoutMonthWeeks (also used by the month widget) keeps its
  signature and becomes a loop over it.
- Add MonthUiState.Success.instancesByDay: the grid's events keyed by date and
  uncapped, so the split style's day pane can list a date without a second
  provider query — the month grid range already covers it.
- Move AgendaDayHeader / AgendaEmptyDayRow / AgendaEventRow and their label
  helpers into AgendaRows.kt as internal, so the split pane reuses the agenda's
  row vocabulary instead of growing a parallel one. AgendaEmptyDayRow takes its
  text as a parameter now that it serves more than "nothing left today".
- Add the month package's first JVM tests, covering week counts, span
  continuation across row boundaries, lane stacking and instancesByDay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 16:36:57 +02:00
parent ada6976e85
commit 498250650c
5 changed files with 495 additions and 199 deletions

View File

@@ -0,0 +1,220 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class MonthLayoutTest {
private val zone = TimeZone.UTC
// 2026-06-01 is a Monday, so a Monday-anchored June grid starts on the 1st.
private val jun = YearMonth(2026, kotlinx.datetime.Month.JUNE)
private val jun1 = LocalDate(2026, 6, 1)
private val jun8 = LocalDate(2026, 6, 8)
private val weekOf8th = (0..6).map { jun8.plus(it, DateTimeUnit.DAY) }
private fun at(date: LocalDate, h: Int, m: Int = 0): Instant =
date.atTime(h, m).toInstant(zone)
private fun timed(
date: LocalDate,
startHour: Int,
endHour: Int,
id: Long = 1L,
title: String = "E",
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = at(date, startHour),
end = at(date, endHour),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
/** All-day events live at UTC midnights with an *exclusive* end. */
private fun allDay(
from: LocalDate,
toInclusive: LocalDate = from,
id: Long = 100L,
title: String = "A",
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = 0xFF445566.toInt(),
location = null,
)
@Test
fun `startOfGridWeek snaps back to the configured week start`() {
val wed = LocalDate(2026, 6, 10)
assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
// A Sunday-anchored week containing the 10th starts on the 7th.
assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7))
}
@Test
fun `monthGridRange always covers 42 days from the grid start`() {
val range = monthGridRange(jun, DayOfWeek.MONDAY, zone)
assertThat(range.start).isEqualTo(at(jun1, 0))
// 42 days inclusive → the last second of 2026-07-12.
assertThat(range.endInclusive)
.isEqualTo(LocalDate(2026, 7, 12).atTime(23, 59, 59).toInstant(zone))
}
@Test
fun `week count follows the month's shape rather than a fixed six rows`() {
// June 2026: starts Monday, 30 days → 5 rows.
assertThat(layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(5)
// August 2026: starts Saturday, 31 days → spills to 6 rows.
val aug = YearMonth(2026, kotlinx.datetime.Month.AUGUST)
assertThat(layoutMonthWeeks(aug, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(6)
// February 2021: starts Monday, 28 days → exactly 4 rows.
val feb = YearMonth(2021, kotlinx.datetime.Month.FEBRUARY)
assertThat(layoutMonthWeeks(feb, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(4)
}
@Test
fun `layoutMonthWeeks rows are contiguous and seven days wide`() {
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)
assertThat(weeks.first().days.first()).isEqualTo(jun1)
weeks.forEach { assertThat(it.days).hasSize(7) }
val allDays = weeks.flatMap { it.days }
allDays.zipWithNext { a, b -> assertThat(b).isEqualTo(a.plus(1, DateTimeUnit.DAY)) }
}
@Test
fun `a multi-day event becomes one span across its columns`() {
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
assertThat(week.spans).hasSize(1)
val span = week.spans.single()
assertThat(span.startCol).isEqualTo(2) // Wednesday the 10th
assertThat(span.endCol).isEqualTo(4) // Friday the 12th
assertThat(span.lane).isEqualTo(0)
assertThat(span.continuesLeft).isFalse()
assertThat(span.continuesRight).isFalse()
}
@Test
fun `a span running past the row end is flagged as continuing`() {
// Saturday the 13th through Tuesday the 16th straddles the row boundary.
val ev = allDay(LocalDate(2026, 6, 13), LocalDate(2026, 6, 16))
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
val span = week.spans.single()
assertThat(span.startCol).isEqualTo(5)
assertThat(span.endCol).isEqualTo(6)
assertThat(span.continuesLeft).isFalse()
assertThat(span.continuesRight).isTrue()
// The following row picks it up with the flags mirrored.
val nextRow = layoutCalendarWeek(
weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
listOf(ev),
zone,
)
val tail = nextRow.spans.single()
assertThat(tail.startCol).isEqualTo(0)
assertThat(tail.endCol).isEqualTo(1)
assertThat(tail.continuesLeft).isTrue()
assertThat(tail.continuesRight).isFalse()
}
@Test
fun `overlapping spans are stacked on separate lanes`() {
val a = allDay(LocalDate(2026, 6, 9), LocalDate(2026, 6, 11), id = 1L)
val b = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 2L)
val week = layoutCalendarWeek(weekOf8th, listOf(a, b), zone)
assertThat(week.spans.map { it.lane }).containsExactly(0, 1)
}
@Test
fun `single-day timed events stay pills and sort by start`() {
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
val week = layoutCalendarWeek(weekOf8th, listOf(late, early), zone)
assertThat(week.spans).isEmpty()
assertThat(week.timedByDay[LocalDate(2026, 6, 10)]?.map { it.instanceId })
.containsExactly(2L, 1L)
.inOrder()
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
}
@Test
fun `countByDay totals bars and pills on each date`() {
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
val pill = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
val week = layoutCalendarWeek(weekOf8th, listOf(span, pill), zone)
assertThat(week.countByDay[LocalDate(2026, 6, 10)]).isEqualTo(2)
assertThat(week.countByDay[LocalDate(2026, 6, 11)]).isEqualTo(1)
assertThat(week.countByDay[LocalDate(2026, 6, 13)]).isEqualTo(0)
}
@Test
fun `layoutMonthWeeks agrees with laying each row out on its own`() {
val events = listOf(
allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L),
timed(LocalDate(2026, 6, 18), 9, 10, id = 2L),
)
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, events, zone)
weeks.forEach { row ->
assertThat(row).isEqualTo(layoutCalendarWeek(row.days, events, zone))
}
}
@Test
fun `instancesByDay puts all-day events first then orders by start`() {
val allDayEv = allDay(LocalDate(2026, 6, 10), id = 1L)
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 2L)
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 3L)
val byDay = instancesByDay(weekOf8th, listOf(late, early, allDayEv), zone)
assertThat(byDay.getValue(LocalDate(2026, 6, 10)).map { it.instanceId })
.containsExactly(1L, 3L, 2L)
.inOrder()
}
@Test
fun `instancesByDay repeats a multi-day event on every date it covers`() {
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
val byDay = instancesByDay(weekOf8th, listOf(ev), zone)
assertThat(byDay.getValue(LocalDate(2026, 6, 9))).isEmpty()
assertThat(byDay.getValue(LocalDate(2026, 6, 10))).hasSize(1)
assertThat(byDay.getValue(LocalDate(2026, 6, 11))).hasSize(1)
assertThat(byDay.getValue(LocalDate(2026, 6, 12))).hasSize(1)
assertThat(byDay.getValue(LocalDate(2026, 6, 13))).isEmpty()
}
@Test
fun `instancesByDay covers every date in the grid, empty ones included`() {
val byDay = instancesByDay(weekOf8th, emptyList(), zone)
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
assertThat(byDay.values.flatten()).isEmpty()
}
}