Month widget: count Glance's real view ids per Android version, test the budget on device (#372)

This commit is contained in:
2026-10-21 18:44:14 +02:00
parent a87ebc33b6
commit 46ca2db63a
4 changed files with 313 additions and 124 deletions
@@ -0,0 +1,125 @@
package de.jeanlucmakiola.calendula.widget.month
import android.Manifest
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.provider.CalendarContract
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.ExperimentalGlanceApi
import androidx.glance.appwidget.compose
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.time.LocalDate
import java.time.ZoneId
/**
* Renders the month widget for a packed month through Glance's own translator
* and counts what it inflates (#372): the view-id model in [WeekLayout] has to
* match what Glance really builds, or a busy month fails with "too many views".
*/
@OptIn(ExperimentalGlanceApi::class)
@RunWith(AndroidJUnit4::class)
class MonthWidgetViewBudgetTest {
@get:Rule
val permissionRule: GrantPermissionRule =
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.targetContext
private var calendarId = -1L
private fun Uri.asSyncAdapter(): Uri = buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
@Before
fun packTheMonth() {
val calendar = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.NAME, ACCOUNT)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, ACCOUNT)
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF3F51B5.toInt())
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
}
val uri = context.contentResolver.insert(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(), calendar)
calendarId = ContentUris.parseId(requireNotNull(uri))
val zone = ZoneId.systemDefault()
val first = LocalDate.now(zone).withDayOfMonth(1).minusDays(7)
val events = (0 until 49).flatMap { offset ->
val day = first.plusDays(offset.toLong())
(0 until EVENTS_PER_DAY).map { i ->
val start = day.atTime(8 + i, 0).atZone(zone).toInstant().toEpochMilli()
ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "Busy $i")
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DTEND, start + 30 * 60_000L)
put(CalendarContract.Events.EVENT_TIMEZONE, zone.id)
}
}
}
context.contentResolver.bulkInsert(CalendarContract.Events.CONTENT_URI.asSyncAdapter(), events.toTypedArray())
}
@After
fun removeCalendar() {
if (calendarId < 0) return
context.contentResolver.delete(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId).asSyncAdapter(),
null,
null,
)
}
@Test
fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking {
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) {
val views = MonthWidget().compose(context, size = size)
var total = 0
var texts = 0
instrumentation.runOnMainSync {
val root = views.apply(context, FrameLayout(context))
fun walk(view: View) {
total += 1
if (view is TextView) texts += 1
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
}
walk(root)
}
Log.i(TAG, "size=$size views=$total texts=$texts budget=$VIEW_BUDGET")
// The grid itself rendered, not Glance's error layout: every day number is there.
assertThat(texts).isAtLeast(35)
assertThat(total).isAtMost(GLANCE_VIEW_IDS)
}
}
private companion object {
const val TAG = "MonthWidgetBudget"
const val ACCOUNT = "calendula-widget-budget-test"
const val EVENTS_PER_DAY = 6
const val GLANCE_VIEW_IDS = 499
}
}
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.widget.month
import android.os.Build
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.month.DaySeating
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.seating
import de.jeanlucmakiola.calendula.ui.month.spanAt
@@ -10,100 +12,133 @@ import kotlinx.datetime.LocalDate
internal const val MAX_LANES = 3
/**
* Glance gives each rendering 500 view ids and fails the whole widget past them
* (#372). The grid stays under this, leaving room for Glance's own wrappers.
* View ids one rendering may spend. Glance has 499 and fails the whole widget
* past them (#372); staying well under also keeps a busy month's payload, sent
* once per host size, clear of the binder limit #214 ran into.
*/
internal const val VIEW_BUDGET = 440
/** Views outside the week rows: the header row with its title and three icons, a spacer and the weekday row. */
internal const val FIXED_VIEWS = 18
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
internal data class LaneCell(val event: EventInstance?, val cols: Int)
/** A "+[count]" label one column wide, or — with a zero count — a gap [cols] wide. */
internal data class OverflowCell(val count: Int, val cols: Int)
internal const val VIEW_BUDGET = 400
/**
* Lane [lane] split into bars and the gaps between them, consecutive blank
* columns merged into one gap: the day-open taps come from the strip underneath,
* so a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
* What views cost in Glance 1.2's view ids. A clickable view that isn't a
* Button is wrapped in a Box sized like it, with a ripple Image and the view
* itself filling it. Below Android 12 a view with a fixed width or height is
* inflated into a sizing frame first, which takes a second id.
*/
internal fun MonthWeek.laneCells(lane: Int, laneCap: Int): List<LaneCell> {
val cells = mutableListOf<LaneCell>()
internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) {
val plain = 1
val sized = if (sdkInt < Build.VERSION_CODES.S) 2 else 1
val clickablePlain = plain + 2
val clickableSized = sized + 2
/**
* `MonthWidgetBody`'s views outside the week rows: its Column, the header
* row with three sized clickable icon boxes, their sized icons and the
* clickable title, the spacer, and the weekday row of sized labels.
*/
val fixed = plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + 7 * sized
}
/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */
internal data class GridCell<T : Any>(val item: T?, val cols: Int)
/**
* The seven columns as cells and the gaps between them, consecutive empty
* columns merged into one: the day-open taps come from the strip underneath,
* so a gap needs no per-column view. [cellAt] gives what starts at a column
* and how many columns it covers.
*/
internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): List<GridCell<T>> {
val cells = mutableListOf<GridCell<T>>()
var gap = 0
var col = 0
while (col < days.size) {
val seat = seating(col, laneCap).seated.firstOrNull { it.lane == lane }
if (seat == null) {
while (col < columns) {
val cell = cellAt(col)
if (cell == null) {
gap += 1
col += 1
continue
}
if (gap > 0) cells += LaneCell(null, gap)
if (gap > 0) cells += GridCell(null, gap)
gap = 0
val cols = if (seat.bar) (spanAt(col, lane)?.endCol ?: col) - col + 1 else 1
cells += LaneCell(seat.event, cols)
col += cols
cells += cell
col += cell.cols
}
if (gap > 0) cells += LaneCell(null, gap)
if (gap > 0) cells += GridCell(null, gap)
return cells
}
/** How many lanes this week fills from the top under [laneCap]; empty lanes below are not drawn. */
internal fun MonthWeek.usedLanes(laneCap: Int): Int =
days.indices.maxOfOrNull { col -> seating(col, laneCap).seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0
/** One week of the grid seated with [laneCap] lanes, computed once for both drawing and counting. */
internal class WeekLayout(val week: MonthWeek, val laneCap: Int) {
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
internal fun MonthWeek.hiddenPerDay(laneCap: Int): List<Int> =
days.indices.map { col -> seating(col, laneCap).hidden.size }
/** Lanes filled from the top; empty lanes below are not drawn. */
val usedLanes: Int = seating.maxOfOrNull { day -> day.seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0
/** The "+N" row as labels and merged gaps; empty when nothing is hidden. */
internal fun overflowCells(hidden: List<Int>): List<OverflowCell> {
if (hidden.none { it > 0 }) return emptyList()
val cells = mutableListOf<OverflowCell>()
var gap = 0
hidden.forEach { count ->
if (count == 0) {
gap += 1
} else {
if (gap > 0) cells += OverflowCell(0, gap)
gap = 0
cells += OverflowCell(count, 1)
/** Each drawn lane as bars and merged gaps. */
val lanes: List<List<GridCell<EventInstance>>> = List(usedLanes) { lane ->
mergeGaps(week.days.size) { col ->
val seat = seating[col].seated.firstOrNull { it.lane == lane } ?: return@mergeGaps null
val endCol = if (seat.bar) week.spanAt(col, lane)?.endCol ?: col else col
GridCell(seat.event, endCol - col + 1)
}
}
if (gap > 0) cells += OverflowCell(0, gap)
return cells
}
/** The views `WeekRow` emits for this week with [laneCap] lanes, counted the way it draws them. */
internal fun MonthWeek.viewCount(laneCap: Int, today: LocalDate): Int {
val strips = 1 + days.size
val numbers = 1 + days.size + if (today in days) 2 else 0
val lanes = (0 until usedLanes(laneCap)).sumOf { lane ->
2 + laneCells(lane, laneCap).sumOf { if (it.event == null) 1 else 3 }
/** The "+N" row as counts and merged gaps; empty when everything fits. */
val overflow: List<GridCell<Int>> = seating.map { it.hidden.size }.let { hidden ->
if (hidden.none { it > 0 }) emptyList()
else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
}
/**
* The view ids `WeekRow` spends on this week, counted the way it draws it:
* the week Box, the strip Row and its seven sized clickable strips, the
* content Column, the sized day-number row and its sized numbers (today's
* pill adds a sized Box around a plain Text), the sized spacer, each lane's
* Row and sized spacer with sized gaps and bars (a sized Box around a
* filling Box and Text), and the "+N" Row of sized cells.
*/
fun viewIds(today: LocalDate, ids: ViewIds): Int {
val days = week.days.size
val strips = ids.plain + days * ids.clickableSized
val numbers = ids.sized + days * ids.sized + if (today in week.days) ids.sized + ids.plain else 0
val lanes = lanes.sumOf { cells ->
ids.plain + ids.sized + cells.sumOf { if (it.item == null) ids.sized else ids.sized + 2 * ids.plain }
}
val overflow = if (overflow.isEmpty()) 0 else ids.plain + overflow.size * ids.sized
return ids.plain + strips + ids.plain + numbers + ids.sized + lanes + overflow
}
val overflow = overflowCells(hiddenPerDay(laneCap)).let { if (it.isEmpty()) 0 else 1 + it.size }
// The week's Box, its content Column and the spacer under the numbers.
return 3 + strips + numbers + lanes + overflow
}
/**
* The lanes each week may draw so the grid fits [budget] views: the most
* expensive week gives up its lowest lane first, its events folding into "+N",
* until the whole month fits (#372).
* Seats each week so the whole grid fits [budget] view ids (#372). Each round
* folds the lowest lanes of whichever week saves the most ids per lane given
* up, their events joining "+N", until the month fits.
*/
internal fun laneCaps(
internal fun layoutWeeks(
weeks: List<MonthWeek>,
today: LocalDate,
budget: Int = VIEW_BUDGET - FIXED_VIEWS,
): List<Int> {
val caps = MutableList(weeks.size) { MAX_LANES }
val costs = MutableList(weeks.size) { weeks[it].viewCount(MAX_LANES, today) }
ids: ViewIds = ViewIds(),
budget: Int = VIEW_BUDGET - ids.fixed,
): List<WeekLayout> {
val layouts = weeks.map { week -> WeekLayout(week, WeekLayout(week, MAX_LANES).usedLanes) }.toMutableList()
val costs = layouts.map { it.viewIds(today, ids) }.toMutableList()
while (costs.sum() > budget) {
val busiest = weeks.indices.filter { caps[it] > 0 }.maxByOrNull { costs[it] } ?: break
caps[busiest] -= 1
costs[busiest] = weeks[busiest].viewCount(caps[busiest], today)
var best: Triple<Int, WeekLayout, Int>? = null
var bestRate = 0.0
layouts.forEachIndexed { i, layout ->
for (cap in layout.laneCap - 1 downTo 0) {
val folded = WeekLayout(layout.week, cap)
val cost = folded.viewIds(today, ids)
val rate = (costs[i] - cost).toDouble() / (layout.laneCap - cap)
if (rate > bestRate) {
bestRate = rate
best = Triple(i, folded, cost)
}
}
}
val (i, folded, cost) = best ?: break
layouts[i] = folded
costs[i] = cost
}
return caps
return layouts
}
@@ -123,8 +123,8 @@ class MonthWidget : GlanceAppWidget() {
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
* return early from `GlanceAppWidget.resize` — under those the grid is never
* recomposed for a new size and a resized widget clips instead of reflowing.
* Exact costs one serialised copy per host size, which is affordable now that
* a rendering is ~190 views rather than ~740.
* Exact costs one serialised copy per host size, which is why a rendering is
* held to [VIEW_BUDGET] view ids, folding lanes into "+N" on a busy month.
*/
override val sizeMode = SizeMode.Exact
@@ -223,15 +223,13 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
// Column width from the live widget size, minus our H padding.
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
val caps = laneCaps(weeks, source.today)
MonthHeader(label = monthLabel(ym, source.today.year))
Spacer(GlanceModifier.height(2.dp))
WeekdayHeader(weekStart = source.weekStart, colW = colW)
weeks.forEachIndexed { i, week ->
layoutWeeks(weeks, source.today).forEach { layout ->
WeekRow(
week = week,
laneCap = caps[i],
layout = layout,
currentMonth = ym.month,
today = source.today,
soften = source.soften,
@@ -340,8 +338,7 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
@Composable
private fun WeekRow(
week: MonthWeek,
laneCap: Int,
layout: WeekLayout,
currentMonth: Month,
today: LocalDate,
soften: Boolean,
@@ -349,7 +346,7 @@ private fun WeekRow(
modifier: GlanceModifier,
) {
val context = LocalContext.current
val overflow = overflowCells(week.hiddenPerDay(laneCap))
val week = layout.week
Box(modifier = modifier.fillMaxWidth()) {
// Day-open targets, one full-height strip per column, sitting *under* the
// content: a tap anywhere in a day still opens it, as in the app, without
@@ -387,12 +384,12 @@ private fun WeekRow(
// its columns (colW * n) so it's connected with no seam and rounded ends.
// Lanes past the last one this week fills emit nothing at all.
// The grid's view budget caps the lanes per week (#372); what doesn't
// fit folds into the "+N" row. Mirrored one to one by viewCount.
repeat(week.usedLanes(laneCap)) { lane ->
LaneRow(cells = week.laneCells(lane, laneCap), soften = soften, colW = colW)
// fit folds into the "+N" row. Counted the same way by WeekLayout.viewIds.
layout.lanes.forEach { cells ->
LaneRow(cells = cells, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp))
}
if (overflow.isNotEmpty()) OverflowRow(cells = overflow, colW = colW)
if (layout.overflow.isNotEmpty()) OverflowRow(cells = layout.overflow, colW = colW)
}
}
}
@@ -440,14 +437,15 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
}
@Composable
private fun LaneRow(cells: List<LaneCell>, soften: Boolean, colW: Dp) {
private fun LaneRow(cells: List<GridCell<EventInstance>>, soften: Boolean, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
cells.forEach { cell ->
if (cell.event == null) {
val event = cell.item
if (event == null) {
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
} else {
SpanBar(
event = cell.event,
event = event,
soften = soften,
width = colW * cell.cols,
)
@@ -491,14 +489,14 @@ private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) {
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
@Composable
private fun OverflowRow(cells: List<OverflowCell>, colW: Dp) {
private fun OverflowRow(cells: List<GridCell<Int>>, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
cells.forEach { cell ->
if (cell.count == 0) {
if (cell.item == null) {
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
} else {
Text(
text = "+${cell.count}",
text = "+${cell.item}",
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
@@ -1,7 +1,10 @@
package de.jeanlucmakiola.calendula.widget.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.allDayEvent
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.timedEvent
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
@@ -19,60 +22,88 @@ class MonthGridTest {
private val jul26 = YearMonth(2026, Month.JULY)
private val today = LocalDate(2026, 7, 15)
/** Monday Jul 6 to Sunday Jul 12. */
private val monday = LocalDate(2026, 7, 6)
private val android16 = ViewIds(sdkInt = 36)
private val android10 = ViewIds(sdkInt = 29)
private fun weeks(events: List<EventInstance>): List<MonthWeek> =
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
/** July 2026 with [perDay] timed events on every day of the grid. */
private fun weeks(perDay: Int): List<de.jeanlucmakiola.calendula.ui.month.MonthWeek> {
private fun busyWeeks(perDay: Int): List<MonthWeek> {
val first = LocalDate(2026, 6, 29)
var id = 0L
val events = (0 until 42).flatMap { offset ->
val day = first.plus(offset, DateTimeUnit.DAY)
(1..perDay).map { hour ->
timedEvent(day, hour, zone = zone, id = ++id, title = "E$id", color = 0xFFF44336.toInt())
}
return weeks(
(0 until 42).flatMap { offset ->
val day = first.plus(offset, DateTimeUnit.DAY)
(1..perDay).map { hour -> timedEvent(day, hour, zone = zone, id = ++id, title = "E$id") }
},
)
}
private fun List<WeekLayout>.cost(ids: ViewIds) = sumOf { it.viewIds(today, ids) }
@Test
fun `a quiet month keeps every lane it uses`() {
val layouts = layoutWeeks(busyWeeks(perDay = 1), today, android16)
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(1)
assertThat(layouts.all { it.overflow.isEmpty() }).isTrue()
}
@Test
fun `a full month folds lanes until it fits, on every Android version`() {
for (ids in listOf(android16, android10)) {
val weeks = busyWeeks(perDay = 5)
assertThat(weeks.map { WeekLayout(it, MAX_LANES) }.cost(ids)).isGreaterThan(VIEW_BUDGET - ids.fixed)
val layouts = layoutWeeks(weeks, today, ids)
assertThat(layouts.cost(ids)).isAtMost(VIEW_BUDGET - ids.fixed)
assertThat(layouts.any { it.laneCap < MAX_LANES }).isTrue()
}
return layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
}
@Test
fun `a quiet month keeps every lane`() {
val weeks = weeks(perDay = 1)
assertThat(laneCaps(weeks, today)).containsExactlyElementsIn(List(weeks.size) { MAX_LANES })
}
@Test
fun `a full month folds lanes until it fits the budget`() {
val weeks = weeks(perDay = 5)
val fullCost = weeks.sumOf { it.viewCount(MAX_LANES, today) }
assertThat(fullCost).isGreaterThan(VIEW_BUDGET - FIXED_VIEWS)
val caps = laneCaps(weeks, today)
val cost = weeks.indices.sumOf { weeks[it].viewCount(caps[it], today) }
assertThat(cost).isAtMost(VIEW_BUDGET - FIXED_VIEWS)
assertThat(caps.any { it < MAX_LANES }).isTrue()
fun `older Android folds more, since a sized view costs it two ids`() {
val weeks = busyWeeks(perDay = 3)
val newer = layoutWeeks(weeks, today, android16).sumOf { it.laneCap }
val older = layoutWeeks(weeks, today, android10).sumOf { it.laneCap }
assertThat(older).isLessThan(newer)
}
@Test
fun `folded events are counted in the overflow`() {
val week = weeks(perDay = 5)[1]
val hidden = week.hiddenPerDay(laneCap = 1)
assertThat(hidden).containsExactlyElementsIn(List(7) { 4 })
assertThat(week.usedLanes(laneCap = 1)).isEqualTo(1)
val layout = WeekLayout(busyWeeks(perDay = 5)[1], laneCap = 1)
assertThat(layout.usedLanes).isEqualTo(1)
assertThat(layout.overflow).containsExactly(GridCell(4, 1), GridCell(4, 1), GridCell(4, 1),
GridCell(4, 1), GridCell(4, 1), GridCell(4, 1), GridCell(4, 1)).inOrder()
}
@Test
fun `a lane of single-day events is one cell per day`() {
val cells = weeks(perDay = 1)[1].laneCells(lane = 0, laneCap = MAX_LANES)
assertThat(cells).hasSize(7)
assertThat(cells.all { it.event != null && it.cols == 1 }).isTrue()
fun `a multi-day bar is one cell as wide as its days`() {
val bar = allDayEvent(monday.plus(1, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 1)
val layout = WeekLayout(weeks(listOf(bar))[1], MAX_LANES)
assertThat(layout.lanes).hasSize(1)
assertThat(layout.lanes[0]).containsExactly(GridCell(null, 1), GridCell(bar, 3), GridCell(null, 3)).inOrder()
}
@Test
fun `overflow gaps are merged`() {
assertThat(overflowCells(listOf(0, 0, 2, 0, 0, 0, 1))).containsExactly(
OverflowCell(0, 2),
OverflowCell(2, 1),
OverflowCell(0, 3),
OverflowCell(1, 1),
).inOrder()
assertThat(overflowCells(List(7) { 0 })).isEmpty()
fun `a bar on a folded lane moves into the overflow of each day it covers`() {
val top = allDayEvent(monday, monday.plus(6, DateTimeUnit.DAY), id = 1)
val lower = allDayEvent(monday.plus(2, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 2)
val layout = WeekLayout(weeks(listOf(top, lower))[1], laneCap = 1)
assertThat(layout.lanes).hasSize(1)
assertThat(layout.lanes[0]).containsExactly(GridCell(top, 7))
assertThat(layout.overflow).containsExactly(GridCell(null, 2), GridCell(1, 1), GridCell(1, 1), GridCell(null, 3))
.inOrder()
}
@Test
fun `gaps are merged`() {
val counts = listOf(0, 0, 2, 0, 0, 0, 1)
val cells = mergeGaps(counts.size) { col -> counts[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
assertThat(cells).containsExactly(GridCell(null, 2), GridCell(2, 1), GridCell(null, 3), GridCell(1, 1))
.inOrder()
}
}