neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Rotate the detailed cards, ring the compass, and stop dropping types
17fd74d · Gabriel Morell · 2026-09-08 20:18
Message
{commit_body(@commit)}
Files changed
added
clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GtfsPlusRenderer.kt
+154
−0
@@ -0,0 +1,154 @@
| 1 | +package io.neiam.apolloscrib.targets | |
| 2 | + | |
| 3 | +import io.neiam.apolloscrib.R | |
| 4 | +import io.neiam.apolloscrib.types.AlertCondensed | |
| 5 | +import io.neiam.apolloscrib.types.GtfsPlusCondensed | |
| 6 | +import io.neiam.apolloscrib.types.PlusArrival | |
| 7 | +import io.neiam.apolloscrib.types.SourceType | |
| 8 | +import io.neiam.apolloscrib.types.VisionEntry | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * A stop from the Plus board, as a compact card. | |
| 12 | + * | |
| 13 | + * The same shape as [GtfsRenderer]'s — one card per stop, three rows of | |
| 14 | + * routes — because a widget and a Smartspace target have the same room | |
| 15 | + * whichever board the data came from. What Plus adds is what each row can | |
| 16 | + * *say*: a delay, a platform, a cancellation, where Basic could only give a | |
| 17 | + * time. | |
| 18 | + * | |
| 19 | + * Without this, a Plus board drew nothing at all outside the detailed view: | |
| 20 | + * `gtfs_plus` is its own type, and a type with no renderer is dropped. That is | |
| 21 | + * the right behaviour for a type this build does not know, and the wrong one | |
| 22 | + * for the board it is meant to be reading. | |
| 23 | + */ | |
| 24 | +object GtfsPlusRenderer : SourceRenderer { | |
| 25 | + | |
| 26 | + override val type = SourceType.GtfsPlus | |
| 27 | + override val iconRes = R.drawable.fa_bus_simple | |
| 28 | + override val label = "Apollo's Crib: Transit (detailed)" | |
| 29 | + override val description = "Next departures, with what the feed says about each" | |
| 30 | + | |
| 31 | + override fun preview(entry: VisionEntry): List<Preview> { | |
| 32 | + val routes = entry.decode<List<GtfsPlusCondensed>>().orEmpty() | |
| 33 | + | |
| 34 | + // As in the Basic renderer: a stop with nothing due is not news, and | |
| 35 | + // it crowds out the stops that have something. An alert still shows. | |
| 36 | + if (routes.all { it.arrivals.isEmpty() }) { | |
| 37 | + return listOfNotNull(alert(entry, routes)) | |
| 38 | + } | |
| 39 | + | |
| 40 | + val soonest = routes.sortedBy { route -> | |
| 41 | + route.arrivals.firstNotNullOfOrNull { it.best() } ?: "99:99:99" | |
| 42 | + } | |
| 43 | + | |
| 44 | + val board = Preview( | |
| 45 | + id = entry.key, | |
| 46 | + title = entry.label(), | |
| 47 | + subtitle = summary(soonest), | |
| 48 | + iconRes = soonest.firstOrNull()?.let { modeIcon(it.mode) } ?: iconRes, | |
| 49 | + rows = soonest.map { row(it) }, | |
| 50 | + empty = "Nothing due" | |
| 51 | + ) | |
| 52 | + | |
| 53 | + return listOfNotNull(alert(entry, routes)) + board | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** | |
| 57 | + * `[bus] 87 Arlington [tower] 15:33 · 3 late` | |
| 58 | + * | |
| 59 | + * Two departures, as Basic shows — but each stamped with what the feed | |
| 60 | + * said about it rather than only when it is due. | |
| 61 | + */ | |
| 62 | + private fun row(route: GtfsPlusCondensed): Row = Row( | |
| 63 | + iconRes = modeIcon(route.mode), | |
| 64 | + text = "${route.displayName()} ${route.dest.orEmpty()}".trim(), | |
| 65 | + stamps = route.arrivals.take(2).map { arrival -> | |
| 66 | + val time = arrival.best()?.asClockTime() ?: "--:--" | |
| 67 | + val note = noteOf(arrival) | |
| 68 | + | |
| 69 | + Stamp( | |
| 70 | + iconRes = if (arrival.live()) R.drawable.fa_tower_broadcast else R.drawable.fa_clock, | |
| 71 | + text = if (note == null) time else "$time · $note", | |
| 72 | + // Smartspace list rows are plain text, so the glyph cannot | |
| 73 | + // carry the meaning and the word has to. | |
| 74 | + flat = if (note == null) time else "$time $note" | |
| 75 | + ) | |
| 76 | + } | |
| 77 | + ) | |
| 78 | + | |
| 79 | + /** | |
| 80 | + * The one thing worth adding to a time, when there is one. | |
| 81 | + * | |
| 82 | + * Cancellation first: a rider who reads only the time and walks to the | |
| 83 | + * stop has been failed by the card. Then lateness, then how full — in the | |
| 84 | + * order that changes what somebody does. | |
| 85 | + */ | |
| 86 | + private fun noteOf(arrival: PlusArrival): String? = when { | |
| 87 | + arrival.trip_status == "CANCELED" -> "cancelled" | |
| 88 | + arrival.stop_status == "SKIPPED" -> "not stopping" | |
| 89 | + else -> arrival.delayMinutes()?.let { minutes -> | |
| 90 | + when { | |
| 91 | + minutes >= 1 -> "$minutes late" | |
| 92 | + minutes <= -1 -> "${-minutes} early" | |
| 93 | + else -> null | |
| 94 | + } | |
| 95 | + } ?: crowding(arrival) | |
| 96 | + } | |
| 97 | + | |
| 98 | + private fun crowding(arrival: PlusArrival): String? = when (arrival.occupancy) { | |
| 99 | + "CRUSHED_STANDING_ROOM_ONLY", "FULL" -> "full" | |
| 100 | + "STANDING_ROOM_ONLY" -> "standing" | |
| 101 | + "NOT_ACCEPTING_PASSENGERS" -> "not boarding" | |
| 102 | + else -> null | |
| 103 | + } | |
| 104 | + | |
| 105 | + private fun summary(routes: List<GtfsPlusCondensed>): String? { | |
| 106 | + val first = routes.firstOrNull() ?: return null | |
| 107 | + val arrival = first.arrivals.firstOrNull() ?: return null | |
| 108 | + val time = arrival.best()?.asClockTime() ?: return null | |
| 109 | + | |
| 110 | + val note = noteOf(arrival) | |
| 111 | + val live = if (arrival.live()) " · live" else "" | |
| 112 | + | |
| 113 | + return if (note == null) { | |
| 114 | + "${first.displayName()} at $time$live" | |
| 115 | + } else { | |
| 116 | + "${first.displayName()} at $time · $note" | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + /** As Basic does it: severe, or naming this stop. */ | |
| 121 | + private fun alert(entry: VisionEntry, routes: List<GtfsPlusCondensed>): Preview? { | |
| 122 | + val alerts = routes.flatMap { it.alerts.orEmpty() }.distinctBy { it.header ?: it.effect } | |
| 123 | + val lead = alerts.firstOrNull { it.severity == "SEVERE" || it.stop_specific == true } | |
| 124 | + ?: return null | |
| 125 | + | |
| 126 | + return Preview( | |
| 127 | + id = "${entry.key}-alert", | |
| 128 | + title = lead.header ?: lead.effect.humanise(), | |
| 129 | + subtitle = entry.label(), | |
| 130 | + iconRes = alertIcon(lead), | |
| 131 | + style = Preview.Style.Basic | |
| 132 | + ) | |
| 133 | + } | |
| 134 | + | |
| 135 | + private fun alertIcon(alert: AlertCondensed): Int = | |
| 136 | + if (alert.stop_specific == true) R.drawable.fa_location_dot | |
| 137 | + else R.drawable.fa_triangle_exclamation | |
| 138 | + | |
| 139 | + // The same mapping live_preview.ex uses, so a route looks like itself. | |
| 140 | + private fun modeIcon(mode: String?): Int = when (mode) { | |
| 141 | + "Bus" -> R.drawable.fa_bus | |
| 142 | + "Trolleybus" -> R.drawable.fa_bus_simple | |
| 143 | + "Subway" -> R.drawable.fa_train_subway | |
| 144 | + "Rail" -> R.drawable.fa_train | |
| 145 | + "LightRail" -> R.drawable.fa_train_tram | |
| 146 | + "Ferry" -> R.drawable.fa_ferry | |
| 147 | + "CableCar", "Gondola" -> R.drawable.fa_cable_car | |
| 148 | + "Funicular" -> R.drawable.fa_mountain | |
| 149 | + else -> R.drawable.fa_train_tram | |
| 150 | + } | |
| 151 | + | |
| 152 | + private fun String.humanise(): String = | |
| 153 | + lowercase().replace('_', ' ').replaceFirstChar { it.uppercase() } | |
| 154 | +} |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/targets/Renderers.kt
+1
−0
@@ -157,6 +157,7 @@ object Targets {
| 157 | 157 | |
| 158 | 158 | val renderers: List<SourceRenderer> = listOf( |
| 159 | 159 | GtfsRenderer, |
| 160 | + GtfsPlusRenderer, | |
| 160 | 161 | GbfsRenderer, |
| 161 | 162 | WeatherRenderer, |
| 162 | 163 | TidalRenderer, |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+36
−12
@@ -3,6 +3,7 @@ package io.neiam.apolloscrib.ui
| 3 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi |
| 4 | 4 | import androidx.compose.foundation.combinedClickable |
| 5 | 5 | import io.neiam.apolloscrib.types.GtfsPlusCondensed |
| 6 | +import io.neiam.apolloscrib.types.SourceType | |
| 6 | 7 | import androidx.compose.foundation.gestures.detectHorizontalDragGestures |
| 7 | 8 | import androidx.compose.foundation.layout.Arrangement |
| 8 | 9 | import androidx.compose.foundation.layout.PaddingValues |
@@ -87,6 +88,24 @@ fun BoardScreen(
| 87 | 88 | if (richCards > 0) store.plusEntries() else emptyList() |
| 88 | 89 | } |
| 89 | 90 | |
| 91 | + // Soonest first, then a window onto them. Taking them in payload order | |
| 92 | + // would show whichever the publisher happened to list, which for a stop | |
| 93 | + // with six routes is not the one leaving next. | |
| 94 | + val routes = remember(plus) { | |
| 95 | + plus | |
| 96 | + .flatMap { entry -> | |
| 97 | + entry.decode<List<GtfsPlusCondensed>>().orEmpty().map { entry to it } | |
| 98 | + } | |
| 99 | + .sortedBy { (_, route) -> | |
| 100 | + route.arrivals.firstNotNullOfOrNull { it.best() } ?: "99:99:99" | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 104 | + // Rotated only when there is more than fits. Turning a list that is | |
| 105 | + // already whole would take cards away and give them back for nothing. | |
| 106 | + val window = rotatingWindow(routes.size, richCards) | |
| 107 | + val rotation by rememberRotationProgress(rotating = routes.size > richCards && richCards > 0) | |
| 108 | + | |
| 90 | 109 | // A pull asks for a board; the wait ends when one lands. There is no |
| 91 | 110 | // acknowledgement to wait for -- a Pythiae answers on its own tick and the |
| 92 | 111 | // request is debounced at the other end -- so a new payload is the only |
@@ -161,6 +180,7 @@ fun BoardScreen(
| 161 | 180 | lastUpdated = lastUpdated, |
| 162 | 181 | stale = stale, |
| 163 | 182 | richCards = richCards, |
| 183 | + rotation = rotation, | |
| 164 | 184 | onToggleRich = { |
| 165 | 185 | // One more each time, and off the end back to the compact |
| 166 | 186 | // board -- so the gesture always has somewhere to go and never |
@@ -212,16 +232,6 @@ fun BoardScreen(
| 212 | 232 | } |
| 213 | 233 | |
| 214 | 234 | if (richCards > 0) { |
| 215 | − // Soonest first, then as many as asked for. Taking them in payload | |
| 216 | − // order would show whichever the publisher happened to list, which | |
| 217 | − // for a stop with six routes is not the one leaving next. | |
| 218 | − val routes = plus | |
| 219 | − .flatMap { entry -> | |
| 220 | − entry.decode<List<GtfsPlusCondensed>>().orEmpty().map { entry to it } | |
| 221 | − } | |
| 222 | − .sortedBy { (_, route) -> | |
| 223 | − route.arrivals.firstNotNullOfOrNull { it.best() } ?: "99:99:99" | |
| 224 | − } | |
| 225 | 235 | |
| 226 | 236 | if (routes.isEmpty()) { |
| 227 | 237 | item { |
@@ -247,11 +257,22 @@ fun BoardScreen(
| 247 | 257 | } |
| 248 | 258 | |
| 249 | 259 | items( |
| 250 | − routes.take(richCards), | |
| 260 | + window.map { routes[it] }, | |
| 251 | 261 | key = { (entry, route) -> "${entry.key}-${route.route}-${route.dest}" } |
| 252 | 262 | ) { (entry, route) -> |
| 253 | 263 | RichCard(entry, route) |
| 254 | 264 | } |
| 265 | + | |
| 266 | + // Everything Plus does not have an extended reading for -- the | |
| 267 | + // weather, the bikes, the tides. Plus carries Basic's own answer | |
| 268 | + // for those, so they are the same cards they always were, and | |
| 269 | + // leaving them out would make this mode a transit app rather than | |
| 270 | + // a board with detail on it. | |
| 271 | + plus.filter { it.type != SourceType.GtfsPlus }.forEach { entry -> | |
| 272 | + items(Targets.preview(entry), key = { "${entry.key}-${it.id}" }) { preview -> | |
| 273 | + PreviewCard(preview) | |
| 274 | + } | |
| 275 | + } | |
| 255 | 276 | } else { |
| 256 | 277 | entries.forEach { entry -> |
| 257 | 278 | // No preview means the renderer had nothing worth a card -- a |
@@ -295,6 +316,8 @@ private fun StatusHeader(
| 295 | 316 | lastUpdated: Long?, |
| 296 | 317 | stale: Boolean, |
| 297 | 318 | richCards: Int, |
| 319 | + /** How far through the card rotation, or null when nothing is rotating. */ | |
| 320 | + rotation: Float?, | |
| 298 | 321 | onToggleRich: () -> Unit, |
| 299 | 322 | onEditConnection: (() -> Unit)?, |
| 300 | 323 | modifier: Modifier = Modifier |
@@ -329,7 +352,8 @@ private fun StatusHeader(
| 329 | 352 | // other. |
| 330 | 353 | Compass( |
| 331 | 354 | heading, |
| 332 | − Modifier | |
| 355 | + rotation = rotation, | |
| 356 | + modifier = Modifier | |
| 333 | 357 | .padding(horizontal = 12.dp) |
| 334 | 358 | // A long press rather than a tap: the compass is a readout, |
| 335 | 359 | // and something you read should not change the screen when |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/Compass.kt
+35
−2
@@ -210,7 +210,12 @@ private fun declinationAt(context: Context): Float? {
| 210 | 210 | * cards saying a stop is NE of you. |
| 211 | 211 | */ |
| 212 | 212 | @Composable |
| 213 | −fun Compass(heading: Heading, modifier: Modifier = Modifier) { | |
| 213 | +fun Compass( | |
| 214 | + heading: Heading, | |
| 215 | + modifier: Modifier = Modifier, | |
| 216 | + /** How far through the card rotation, 0..1, or null when nothing rotates. */ | |
| 217 | + rotation: Float? = null | |
| 218 | +) { | |
| 214 | 219 | if (!heading.available) return |
| 215 | 220 | |
| 216 | 221 | val palette = LocalAppTheme.current |
@@ -220,7 +225,10 @@ fun Compass(heading: Heading, modifier: Modifier = Modifier) {
| 220 | 225 | horizontalAlignment = Alignment.CenterHorizontally, |
| 221 | 226 | verticalArrangement = Arrangement.spacedBy(1.dp) |
| 222 | 227 | ) { |
| 223 | − Canvas(Modifier.size(34.dp)) { drawRose(heading.degrees, palette.dim, palette.accent) } | |
| 228 | + Canvas(Modifier.size(34.dp)) { | |
| 229 | + drawRose(heading.degrees, palette.dim, palette.accent) | |
| 230 | + rotation?.let { drawTurn(it, palette.accent) } | |
| 231 | + } | |
| 224 | 232 | |
| 225 | 233 | Text( |
| 226 | 234 | "${wrap(heading.degrees).roundToInt()}° ${compassPoint(heading.degrees)}", |
@@ -231,6 +239,31 @@ fun Compass(heading: Heading, modifier: Modifier = Modifier) {
| 231 | 239 | } |
| 232 | 240 | } |
| 233 | 241 | |
| 242 | +/** | |
| 243 | + * How much of the current turn has passed, as an arc outside the dial. | |
| 244 | + * | |
| 245 | + * Outside rather than over it, so it never obscures the reading it is drawn | |
| 246 | + * around -- the compass is the thing being looked at and the timer is the | |
| 247 | + * thing being glanced at. | |
| 248 | + */ | |
| 249 | +private fun DrawScope.drawTurn(progress: Float, accent: androidx.compose.ui.graphics.Color) { | |
| 250 | + val inset = 0.5f | |
| 251 | + | |
| 252 | + drawArc( | |
| 253 | + color = accent.copy(alpha = 0.75f), | |
| 254 | + // From the top, clockwise, like anything else measuring a turn. | |
| 255 | + startAngle = -90f, | |
| 256 | + sweepAngle = 360f * progress.coerceIn(0f, 1f), | |
| 257 | + useCenter = false, | |
| 258 | + topLeft = Offset(inset, inset), | |
| 259 | + size = androidx.compose.ui.geometry.Size( | |
| 260 | + size.minDimension - inset * 2, | |
| 261 | + size.minDimension - inset * 2 | |
| 262 | + ), | |
| 263 | + style = Stroke(width = 2f) | |
| 264 | + ) | |
| 265 | +} | |
| 266 | + | |
| 234 | 267 | private fun DrawScope.drawRose( |
| 235 | 268 | degrees: Float, |
| 236 | 269 | dim: androidx.compose.ui.graphics.Color, |
added
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/Rotation.kt
+83
−0
@@ -0,0 +1,83 @@
| 1 | +package io.neiam.apolloscrib.ui | |
| 2 | + | |
| 3 | +import androidx.compose.runtime.Composable | |
| 4 | +import androidx.compose.runtime.LaunchedEffect | |
| 5 | +import androidx.compose.runtime.State | |
| 6 | +import androidx.compose.runtime.getValue | |
| 7 | +import androidx.compose.runtime.mutableFloatStateOf | |
| 8 | +import androidx.compose.runtime.mutableIntStateOf | |
| 9 | +import androidx.compose.runtime.remember | |
| 10 | +import androidx.compose.runtime.setValue | |
| 11 | +import androidx.compose.runtime.withFrameNanos | |
| 12 | + | |
| 13 | +/** How long one page of cards stays before the next takes its place. */ | |
| 14 | +const val ROTATION_MS = 12_000L | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * Which cards to show right now, as indices into the list. | |
| 18 | + * | |
| 19 | + * A window of [size] moved along by [size] each turn, so every route gets its | |
| 20 | + * turn rather than the first few holding the screen for ever. Wraps, and when | |
| 21 | + * everything fits it never moves at all — rotating a list that is already | |
| 22 | + * whole would take cards away and give them back for no reason. | |
| 23 | + */ | |
| 24 | +@Composable | |
| 25 | +fun rotatingWindow(total: Int, size: Int): List<Int> { | |
| 26 | + if (total <= 0 || size <= 0) return emptyList() | |
| 27 | + if (total <= size) return (0 until total).toList() | |
| 28 | + | |
| 29 | + val pages = (total + size - 1) / size | |
| 30 | + var page by remember(total, size) { mutableIntStateOf(0) } | |
| 31 | + | |
| 32 | + LaunchedEffect(total, size) { | |
| 33 | + while (true) { | |
| 34 | + kotlinx.coroutines.delay(ROTATION_MS) | |
| 35 | + page = (page + 1) % pages | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + // Taken modulo the total rather than truncated, so a last page with room | |
| 40 | + // to spare is filled from the front instead of coming up short. Three | |
| 41 | + // cards should be three cards on every turn. | |
| 42 | + val start = page * size | |
| 43 | + return (start until start + size).map { it % total } | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * How far through the current turn, 0 to 1, or null when nothing is rotating. | |
| 48 | + * | |
| 49 | + * Advanced per frame rather than by a timer, so the ring it draws moves | |
| 50 | + * smoothly instead of stepping. Null is what tells the compass to draw no ring | |
| 51 | + * at all: a full circle sitting still would say a turn is about to happen when | |
| 52 | + * none is. | |
| 53 | + */ | |
| 54 | +@Composable | |
| 55 | +fun rememberRotationProgress(rotating: Boolean): State<Float?> { | |
| 56 | + val progress = remember { mutableFloatStateOf(0f) } | |
| 57 | + val result = remember { androidx.compose.runtime.mutableStateOf<Float?>(null) } | |
| 58 | + | |
| 59 | + LaunchedEffect(rotating) { | |
| 60 | + if (!rotating) { | |
| 61 | + result.value = null | |
| 62 | + return@LaunchedEffect | |
| 63 | + } | |
| 64 | + | |
| 65 | + var started = withFrameNanos { it } | |
| 66 | + | |
| 67 | + while (true) { | |
| 68 | + val now = withFrameNanos { it } | |
| 69 | + val elapsed = (now - started) / 1_000_000f | |
| 70 | + | |
| 71 | + if (elapsed >= ROTATION_MS) { | |
| 72 | + started = now | |
| 73 | + progress.floatValue = 0f | |
| 74 | + } else { | |
| 75 | + progress.floatValue = elapsed / ROTATION_MS | |
| 76 | + } | |
| 77 | + | |
| 78 | + result.value = progress.floatValue | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + return result | |
| 83 | +} |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/widget/BoardWidgetService.kt
+8
−1
@@ -33,7 +33,14 @@ private class BoardFactory(private val context: Context) : RemoteViewsService.Re
| 33 | 33 | */ |
| 34 | 34 | override fun onDataSetChanged() { |
| 35 | 35 | palette = appThemeByKey(Settings(context).themeKey) |
| 36 | − cards = store.entries().flatMap { Targets.preview(it) } | |
| 36 | + // The Plus board where there is one: it carries the extended reading | |
| 37 | + // for transit and Basic's own answer for every type Plus does not | |
| 38 | + // write, so it is the same board with more on it rather than a | |
| 39 | + // different one. Falls back the moment it is absent, which is every | |
| 40 | + // Pythiae that has not been asked to publish it. | |
| 41 | + val board = store.plusEntries().ifEmpty { store.entries() } | |
| 42 | + | |
| 43 | + cards = board.flatMap { Targets.preview(it) } | |
| 37 | 44 | } |
| 38 | 45 | |
| 39 | 46 | override fun onDestroy() { |
added
clients/android/app/src/test/java/io/neiam/apolloscrib/ui/WindowTest.kt
+56
−0
@@ -0,0 +1,56 @@
| 1 | +package io.neiam.apolloscrib.ui | |
| 2 | + | |
| 3 | +import org.junit.Assert.assertEquals | |
| 4 | +import org.junit.Test | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Which page of cards a rotation shows. | |
| 8 | + * | |
| 9 | + * The composable owns the timer; this is the arithmetic under it, which is | |
| 10 | + * where the awkward cases live: a last page with room to spare, and a list | |
| 11 | + * that already fits. | |
| 12 | + */ | |
| 13 | +class WindowTest { | |
| 14 | + | |
| 15 | + // The same expression `rotatingWindow` uses, once a page is chosen. | |
| 16 | + private fun page(total: Int, size: Int, page: Int): List<Int> { | |
| 17 | + if (total <= 0 || size <= 0) return emptyList() | |
| 18 | + if (total <= size) return (0 until total).toList() | |
| 19 | + | |
| 20 | + val start = page * size | |
| 21 | + return (start until start + size).map { it % total } | |
| 22 | + } | |
| 23 | + | |
| 24 | + @Test | |
| 25 | + fun `a list that fits does not rotate`() { | |
| 26 | + assertEquals(listOf(0, 1), page(total = 2, size = 3, page = 0)) | |
| 27 | + assertEquals(listOf(0, 1, 2), page(total = 3, size = 3, page = 0)) | |
| 28 | + } | |
| 29 | + | |
| 30 | + @Test | |
| 31 | + fun `each turn moves on by a whole page`() { | |
| 32 | + assertEquals(listOf(0, 1, 2), page(total = 6, size = 3, page = 0)) | |
| 33 | + assertEquals(listOf(3, 4, 5), page(total = 6, size = 3, page = 1)) | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Test | |
| 37 | + fun `a short last page is filled from the front rather than coming up short`() { | |
| 38 | + // Four routes, three at a time: the second page is 3, 0, 1 -- three | |
| 39 | + // cards, as the mode promises, not one. | |
| 40 | + assertEquals(listOf(0, 1, 2), page(total = 4, size = 3, page = 0)) | |
| 41 | + assertEquals(listOf(3, 0, 1), page(total = 4, size = 3, page = 1)) | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + fun `one card at a time walks the list`() { | |
| 46 | + assertEquals(listOf(0), page(total = 3, size = 1, page = 0)) | |
| 47 | + assertEquals(listOf(1), page(total = 3, size = 1, page = 1)) | |
| 48 | + assertEquals(listOf(2), page(total = 3, size = 1, page = 2)) | |
| 49 | + } | |
| 50 | + | |
| 51 | + @Test | |
| 52 | + fun `nothing to show is nothing rather than a crash`() { | |
| 53 | + assertEquals(emptyList<Int>(), page(total = 0, size = 3, page = 0)) | |
| 54 | + assertEquals(emptyList<Int>(), page(total = 5, size = 0, page = 0)) | |
| 55 | + } | |
| 56 | +} |
Parents: e04988a