Pin the month row's four seating copies to each other

This commit is contained in:
2026-10-16 14:37:44 +02:00
parent 6e6f14a4a6
commit ea71ab3ab9
@@ -0,0 +1,167 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertWithMessage
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.testEvent
import kotlinx.datetime.DateTimeUnit
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 org.junit.jupiter.api.Test
import kotlin.random.Random
import kotlin.time.Duration.Companion.minutes
/**
* The month row's seating — which event sits in which lane of a day, and which
* fall to the overflow — is read in four places: the bars and pills a row draws,
* the split style's dots ([laneEvents]), the hit test ([chipAt]) and the overflow
* ([overflowEvents]). They must agree for dot _i_ to morph into lane _i_ (#53).
*
* The references below are the four copies as they were before they shared one
* implementation, the row's taken from its composable; this pins the shared one
* to all of them across a spread of generated weeks.
*/
class MonthSeatingTest {
private val zone = TimeZone.of("Europe/Berlin")
private val firstMonday = LocalDate(2026, 3, 23)
private var overflowed = 0
private var stackedBars = 0
@Test
fun `every reader of a week sees the same seating`() {
val random = Random(53)
repeat(400) { round ->
val start = firstMonday.plus(round % 9, DateTimeUnit.WEEK)
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
val events = randomEvents(random, start, count = random.nextInt(0, 14))
val laid = layoutCalendarWeek(days, events, zone)
val weeks = listOf(laid, clipWeekToMonth(laid, YearMonth(days[3].year, days[3].month)))
weeks.forEach { week ->
for (laneCap in 0..6) checkWeek(week, laneCap, "round $round, laneCap $laneCap")
if (week.spans.any { it.lane > 0 }) stackedBars++
}
}
assertWithMessage("generated days with overflow").that(overflowed).isGreaterThan(500)
assertWithMessage("generated weeks with stacked bars").that(stackedBars).isGreaterThan(100)
}
private fun checkWeek(week: MonthWeek, laneCap: Int, label: String) {
week.days.forEachIndexed { col, day ->
val where = "$label, col $col"
val row = rowSeating(week, col, day, laneCap)
val dots = refLaneEvents(week, col, day, laneCap)
val hits = (0 until laneCap).mapNotNull { lane -> refChipAt(week, col, lane, laneCap)?.let { lane to it } }
val overflow = refOverflowEvents(week, col, day, laneCap)
if (overflow.isNotEmpty()) overflowed++
assertWithMessage("$where: hit test vs row").that(hits).isEqualTo(row.seated)
assertWithMessage("$where: dots vs row").that(dots).isEqualTo(row.seated.map { it.second })
assertWithMessage("$where: overflow vs row").that(overflow).isEqualTo(row.hiddenEvents)
assertWithMessage("$where: dot count vs hidden list")
.that(row.hiddenCount.coerceAtLeast(0)).isEqualTo(row.hiddenEvents.size)
assertWithMessage("$where: laneEvents").that(week.laneEvents(col, day, laneCap)).isEqualTo(dots)
assertWithMessage("$where: overflowEvents").that(week.overflowEvents(col, day, laneCap)).isEqualTo(overflow)
(-1..laneCap).forEach { lane ->
assertWithMessage("$where: chipAt lane $lane")
.that(week.chipAt(col, lane, laneCap))
.isEqualTo(refChipAt(week, col, lane, laneCap))
}
//SEATING
}
}
private fun randomEvents(random: Random, weekStart: LocalDate, count: Int): List<EventInstance> =
(1..count).map { n ->
val id = n.toLong()
val day = weekStart.plus(random.nextInt(-4, 10), DateTimeUnit.DAY)
val response = if (random.nextInt(6) == 0) EventResponse.Declined else EventResponse.Going
when (random.nextInt(5)) {
0, 1 -> {
val from = day.atTime(0, 0).toInstant(TimeZone.UTC)
val to = day.plus(random.nextInt(1, 10), DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC)
testEvent(id = id, title = "A$id", start = from, end = to, isAllDay = true, response = response)
}
2 -> {
// Crosses midnight, sometimes ending on it exactly.
val from = day.atTime(random.nextInt(18, 24), 0).toInstant(zone)
val to = from + (random.nextInt(1, 60) * 30).minutes
testEvent(id = id, title = "M$id", start = from, end = to, response = response)
}
else -> {
val from = day.atTime(random.nextInt(0, 23), random.nextInt(0, 4) * 15).toInstant(zone)
val length = if (random.nextInt(8) == 0) 0 else random.nextInt(1, 8) * 15
testEvent(id = id, title = "T$id", start = from, end = from + length.minutes, response = response)
}
}
}
private class RowSeating(
val seated: List<Pair<Int, EventInstance>>,
val hiddenEvents: List<EventInstance>,
val hiddenCount: Int,
)
/** MonthWeekRow's bars, pills and overflow dots, as the composable worked them out. */
private fun rowSeating(week: MonthWeek, col: Int, d: LocalDate, laneCap: Int): RowSeating {
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(laneCap)
val bars = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane to it.event }
val timed = week.timedByDay[d].orEmpty()
val occupied = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until laneCap).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
val pills = pillsShown.mapIndexed { i, ev -> freeSlots[i] to ev }
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
val hiddenEvents = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event) }
addAll(timed.drop(pillsShown.size))
}
return RowSeating((bars + pills).sortedBy { it.first }, hiddenEvents, hidden)
}
private fun refLaneEvents(week: MonthWeek, col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val byLane = arrayOfNulls<EventInstance>(laneCap)
week.spans.forEach { span ->
if (span.lane < laneCap && col in span.startCol..span.endCol) byLane[span.lane] = span.event
}
val free = (0 until laneCap).filter { byLane[it] == null }
week.timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> byLane[free[i]] = event }
return byLane.filterNotNull()
}
private fun refChipAt(week: MonthWeek, col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in week.days.indices || lane !in 0 until laneCap) return null
week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
val occupied = week.spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val free = (0 until laneCap).filter { it !in occupied }
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
return week.timedByDay[week.days[col]].orEmpty().take(free.size).getOrNull(index)
}
private fun refOverflowEvents(week: MonthWeek, col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val seatedLanes = week.spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
return buildList {
week.spans.forEach { span ->
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
}
addAll(week.timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
}
}
}