diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt new file mode 100644 index 0000000..926bc9f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt @@ -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 = + (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>, + val hiddenEvents: List, + 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 { + val byLane = arrayOfNulls(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 { + 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)) + } + } +}