From 46ca2db63aadae12cc39aa1fc5e13c411d1674dc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:44:14 +0200 Subject: [PATCH] Month widget: count Glance's real view ids per Android version, test the budget on device (#372) --- .../widget/month/MonthWidgetViewBudgetTest.kt | 125 +++++++++++++ .../calendula/widget/month/MonthGrid.kt | 169 +++++++++++------- .../calendula/widget/month/MonthWidget.kt | 36 ++-- .../calendula/widget/month/MonthGridTest.kt | 107 +++++++---- 4 files changed, 313 insertions(+), 124 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt new file mode 100644 index 0000000..f57f1d2 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt @@ -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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 3a8915e..53a8815 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -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 { - val cells = mutableListOf() +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(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 mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): List> { + val cells = mutableListOf>() 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 = 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 = - 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): List { - if (hidden.none { it > 0 }) return emptyList() - val cells = mutableListOf() - 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(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> = 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, today: LocalDate, - budget: Int = VIEW_BUDGET - FIXED_VIEWS, -): List { - 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 { + 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? = 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 25a6fae..332726e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -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 { @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, soften: Boolean, colW: Dp) { +private fun LaneRow(cells: List>, 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, colW: Dp) { +private fun OverflowRow(cells: List>, 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), diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index f2751e5..4af9e7a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -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): List = + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone) + /** July 2026 with [perDay] timed events on every day of the grid. */ - private fun weeks(perDay: Int): List { + private fun busyWeeks(perDay: Int): List { 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.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() } }