From c3ccb64613fd4d09b51d33ee7dd411af2f6623a9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 17:24:55 -0400 Subject: [PATCH] feat(chat): settle reaction pills on a beat The pill row re-sorted on every reaction update, so under a burst of reactions pills moved and changed width while a thumb was on its way down, and the tap landed on a different pill. ReactionSettler, in :shared:chat, sits between ReactionOrdering and the row. It is gen_reactions.py's Settler, and the 16 `settle` cases in reactions.json replay against it with a fake clock: - Counts and boosts update live; order changes on a 1 s beat, at most 3 adjacent swaps per beat, nearest the top first. Any boost lead or a count lead of 1 passes; ties never move. A pair that swapped can't swap back for 3 s. - A touch on the row holds the beat 1.5 s, extended by further touches and capped 4 s from the first. - Emptied pills leave on the beat; new emoji join at the end. - Each pill reserves room for its count (at least 2 digits) and boost as of the last beat. A value that fits shows at once; one that needs more room, such as 99 to 100, waits for the next beat. ReactionPillRow runs the beat, sizes the count to the reserved digits in tabular figures, and for 450 ms after a beat changes the layout routes a tap to whatever was under it before. That window is ReactionSettler.HIT_GRACE_MS, the one place to change it once it's measured on phones. reactions.json is re-synced from the orchestrator's canonical copy. --- .../shared/chat/ui/ReactionPillRow.kt | 163 ++- .../shared/chat/ui/ReactionPillRowTest.kt | 128 +- .../shared/chat/reactions/ReactionSettler.kt | 203 +++ .../chat/reactions/ReactionVectorsTest.kt | 82 +- test-vectors/reactions.json | 1225 +++++++++++++++++ 5 files changed, 1782 insertions(+), 19 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionSettler.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index fc2687913f..fda094cd80 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.ui +import android.os.SystemClock import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState @@ -16,12 +17,16 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -35,14 +40,18 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope @@ -50,22 +59,30 @@ import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.core.R import com.flipcash.shared.chat.reactions.ReactionPill +import com.flipcash.shared.chat.reactions.ReactionSettler import com.getcode.theme.CodeTheme import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** * The N-more collapse the pill row uses under a bubble (decision 2): pack pills onto up to @@ -188,6 +205,11 @@ object ReactionPillRowLayout { * rolls up or down, and the row's height follows. The pills showing when the row first composes * appear without animation, unless [animateInitialPills] says they're new — the caller passes it * for a row that composes because its message just got its first reaction. + * + * While the row is on screen a [ReactionSettler] decides what it shows: counts change live, but + * positions, joins, leaves and pill widths change on a once-a-second beat, a touch holds the row + * still, and for [ReactionSettler.HIT_GRACE_MS] after a beat changes the layout a tap lands on + * whatever was under it before. A row coming on screen shows [pills] in their sorted order. */ @Composable fun ReactionPillRow( @@ -203,10 +225,62 @@ fun ReactionPillRow( ) { var expanded by remember { mutableStateOf(false) } + val clock = LocalPillRowClock.current + val settler = remember { ReactionSettler(pills, clock) } + var beat by remember { mutableIntStateOf(0) } + val settled = remember(pills, beat) { + settler.update(pills) + settler.shown + } + LaunchedEffect(settler, pills) { + while (settler.needsBeat) { + delay((settler.nextBeatAt - clock()).coerceAtLeast(0)) + if (settler.settle()) beat++ + } + } + val shown = settled.map { it.pill } + val countDigits = settled.associate { it.pill.emoji to it.countDigits } + val grace = remember { TapGrace(clock) } + val layoutBeat = beat + val scope = rememberCoroutineScope() - val motion = remember { PillRowMotion(if (animateInitialPills) emptyList() else pills, canReact) } - val plusTarget = canReact && pills.isNotEmpty() - val rendered = remember(pills, plusTarget, motion.exitsFinished) { motion.update(pills, plusTarget) } + val motion = remember { PillRowMotion(if (animateInitialPills) emptyList() else shown, canReact) } + val plusTarget = canReact && shown.isNotEmpty() + val rendered = remember(shown, plusTarget, motion.exitsFinished) { motion.update(shown, plusTarget) } + + val currentCanReact by rememberUpdatedState(canReact) + val currentOnToggle by rememberUpdatedState(onToggle) + val currentOnPillLongClick by rememberUpdatedState(onPillLongClick) + val currentOnOpenPicker by rememberUpdatedState(onOpenPicker) + // Watches every touch on the row before the pills see it: each one holds the beat, and one + // that lands during the tap grace on something the beat moved goes to what was there before. + val touches = Modifier.pointerInput(settler, grace) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + settler.touch() + val target = grace.redirect(down.position) ?: return@awaitEachGesture + down.consume() + val up = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + waitForUpOrCancellation(PointerEventPass.Initial) + } + if (up == null) { + // A long-press, or a cancel: only a pill has anything to do on a long-press. + if (target.key != null && target.key != PLUS_KEY && target.key != MORE_KEY) currentOnPillLongClick() + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } while (event.changes.any { it.pressed }) + return@awaitEachGesture + } + up.consume() + when (target.key) { + null -> Unit + PLUS_KEY -> if (currentCanReact) currentOnOpenPicker() + MORE_KEY -> expanded = true + else -> if (currentCanReact) currentOnToggle(target.key) + } + } + } val pillHeight = 28.dp val pillPadding = 10.dp @@ -214,7 +288,7 @@ fun ReactionPillRow( val plusSize = 28.dp val topGap = 4.dp - SubcomposeLayout(modifier = modifier.animateHeightUnclipped()) { constraints -> + SubcomposeLayout(modifier = modifier.animateHeightUnclipped().then(touches)) { constraints -> val loose = constraints.copy(minWidth = 0, minHeight = 0) val spacingPx = spacingDp.roundToPx() val topGapPx = topGap.roundToPx() @@ -230,6 +304,7 @@ fun ReactionPillRow( ) { motionModifier -> ReactionPillChip( pill = item.pill, + countDigits = countDigits[item.pill.emoji] ?: ReactionSettler.MIN_COUNT_DIGITS, height = pillHeight, horizontalPadding = pillPadding, // A previewer (canReact == false) can still open the reactors sheet @@ -336,12 +411,28 @@ fun ReactionPillRow( if (item.exiting) Item(item.pill.emoji, pillPlaceables[index]) else null } + listOfNotNull(plusPlaceable?.takeIf { rendered.plus?.exiting == true }?.let { Item(PLUS_KEY, it) }) + val targets = placements.map { placement -> + val lineOffset = if (alignEnd) containerWidth - lineWidths[placement.line] else 0 + IntOffset(placement.x + lineOffset, topGapPx + lineY[placement.line]) + } + grace.laidOut( + placements.mapIndexed { index, placement -> + val at = targets[index] + // placeRelative mirrors in RTL, so the hit rects mirror with it. + val x = if (layoutDirection == LayoutDirection.Rtl) { + containerWidth - at.x - placement.item.placeable.width + } else { + at.x + } + placement.item.key to IntRect(x, at.y, x + placement.item.placeable.width, at.y + placement.item.placeable.height) + }, + beat = layoutBeat, + ) + layout(containerWidth, totalHeight) { val placed = HashSet() - for (placement in placements) { - val lineOffset = if (alignEnd) containerWidth - lineWidths[placement.line] else 0 - val target = IntOffset(placement.x + lineOffset, topGapPx + lineY[placement.line]) - placement.item.placeable.placeRelative(motion.slide(placement.item.key, target, scope)) + placements.forEachIndexed { index, placement -> + placement.item.placeable.placeRelative(motion.slide(placement.item.key, targets[index], scope)) placed += placement.item.key } for (item in leaving) { @@ -357,6 +448,48 @@ fun ReactionPillRow( private const val MORE_KEY = "\u0000more" private const val PLUS_KEY = "\u0000plus" +/** The row's clock, in monotonic milliseconds; tests swap it for the compose test clock. */ +internal val LocalPillRowClock = staticCompositionLocalOf<() -> Long> { { SystemClock.uptimeMillis() } } + +/** + * The tap grace: for [ReactionSettler.HIT_GRACE_MS] after a beat changes where anything sits (a + * move, a resize, a pill joining into the "+" slot, one leaving), a tap resolves against the + * layout that was on screen before, since a thumb already on its way down aimed at that. + */ +private class TapGrace(private val clock: () -> Long) { + /** Where a tap should go instead: [key] is a pill's emoji, [PLUS_KEY], [MORE_KEY], or null for empty space. */ + class Redirect(val key: String?) + + private var current: List> = emptyList() + private var currentBeat: Int = 0 + private var previous: List> = emptyList() + private var previousUntil: Long? = null + + /** Records a layout pass's hit rects; a change a beat made opens the grace. */ + fun laidOut(rects: List>, beat: Int) { + if (rects == current) return + if (beat != currentBeat && current.isNotEmpty()) { + previous = current + previousUntil = clock() + ReactionSettler.HIT_GRACE_MS + } + current = rects + currentBeat = beat + } + + /** The redirect for a touch-down at [position], or null when it lands where it would anyway. */ + fun redirect(position: Offset): Redirect? { + val until = previousUntil ?: return null + if (clock() >= until) return null + val before = keyAt(previous, position) + return if (before != keyAt(current, position)) Redirect(before) else null + } + + private fun keyAt(rects: List>, position: Offset): String? = + rects.firstOrNull { (_, rect) -> + position.x >= rect.left && position.x < rect.right && position.y >= rect.top && position.y < rect.bottom + }?.first +} + private class RenderedPill(val pill: ReactionPill, val entering: Boolean, val exiting: Boolean) private class RenderedPlus(val entering: Boolean, val exiting: Boolean) private class RenderedRow(val pills: List, val plus: RenderedPlus?) @@ -492,6 +625,7 @@ private class AnimateHeightNode : Modifier.Node(), LayoutModifierNode { @Composable private fun ReactionPillChip( pill: ReactionPill, + countDigits: Int, height: Dp, horizontalPadding: Dp, onClick: (() -> Unit)?, @@ -535,6 +669,14 @@ private fun ReactionPillChip( verticalAlignment = Alignment.CenterVertically, ) { Text(text = pill.emoji, fontSize = 15.sp) + // Room for [countDigits] tabular digits, the settler's reservation, so the pill's width + // changes only on a beat and never with the count inside it. + val countStyle = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Medium, fontFeatureSettings = "tnum") + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val countWidth = remember(countDigits, measurer, density) { + with(density) { measurer.measure("0".repeat(countDigits), countStyle).size.width.toDp() } + } // The count rolls to its next value like a counter turning: up for a rise, down for a // fall. AnimatedContent( @@ -546,11 +688,12 @@ private fun ReactionPillChip( .using(SizeTransform(clip = true) { _, _ -> ChatAnimations.reactionChangeSize }) }, label = "pill count", + contentAlignment = Alignment.Center, + modifier = Modifier.widthIn(min = countWidth), ) { count -> Text( text = count.toString(), - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + style = countStyle, color = CodeTheme.colors.textMain, ) } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt index 90755bbfa7..2518999bf6 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ReactionPillRowTest.kt @@ -8,6 +8,12 @@ import androidx.compose.ui.platform.testTag import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.click import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotSelected @@ -83,9 +89,11 @@ class ReactionPillRowTest { var pills by mutableStateOf(listOf(pill("😀"), pill("🎉"))) composeTestRule.mainClock.autoAdvance = false composeTestRule.setContent { - DesignSystem { - Box(Modifier.width(400.dp)) { - ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + CompositionLocalProvider(LocalPillRowClock provides { composeTestRule.mainClock.currentTime }) { + DesignSystem { + Box(Modifier.width(400.dp)) { + ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + } } } } @@ -96,6 +104,10 @@ class ReactionPillRowTest { composeTestRule.mainClock.advanceTimeByFrame() composeTestRule.onNodeWithTag("reaction_pill_🎉").assertExists() + // It leaves on the next beat, a second after the row appeared, then fades. + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.onNodeWithTag("reaction_pill_🎉").assertExists() + composeTestRule.mainClock.advanceTimeBy(1_000) composeTestRule.onNodeWithTag("reaction_pill_🎉").assertDoesNotExist() composeTestRule.onNodeWithTag("reaction_pill_😀").assertIsDisplayed() @@ -105,15 +117,19 @@ class ReactionPillRowTest { fun `losing the last pill collapses the row to no height`() { var pills by mutableStateOf(listOf(pill("😀"))) composeTestRule.setContent { - DesignSystem { - Box(Modifier.width(400.dp).testTag("container")) { - ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + CompositionLocalProvider(LocalPillRowClock provides { composeTestRule.mainClock.currentTime }) { + DesignSystem { + Box(Modifier.width(400.dp).testTag("container")) { + ReactionPillRow(pills = pills, canReact = true, onToggle = {}, onPillLongClick = {}, onOpenPicker = {}) + } } } } composeTestRule.onNodeWithTag("container").assertHeightIsEqualTo(32.dp) pills = emptyList() + // The emptied pill leaves on the beat. + composeTestRule.mainClock.advanceTimeBy(2_000) composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("container").assertHeightIsEqualTo(0.dp) composeTestRule.onNodeWithTag("reaction_pill_😀").assertDoesNotExist() @@ -246,4 +262,104 @@ class ReactionPillRowTest { composeTestRule.onNodeWithTag("reaction_pill_😇").assertIsDisplayed() } } + + /** A row on the compose test clock, so its beats follow `mainClock`. */ + private fun setSettlingRow(pills: () -> List, onToggle: (String) -> Unit = {}) { + composeTestRule.mainClock.autoAdvance = false + composeTestRule.setContent { + CompositionLocalProvider(LocalPillRowClock provides { composeTestRule.mainClock.currentTime }) { + DesignSystem { + Box(Modifier.width(400.dp)) { + ReactionPillRow( + pills = pills(), + canReact = true, + onToggle = onToggle, + onPillLongClick = {}, + onOpenPicker = {}, + modifier = Modifier.testTag("row"), + ) + } + } + } + } + composeTestRule.mainClock.advanceTimeByFrame() + } + + private fun widthOf(tag: String) = composeTestRule.onNodeWithTag(tag).getBoundsInRoot().let { it.right - it.left } + + @Test + fun `a count crossing 99 keeps the pill's width until the beat, then widens on it`() { + var pills by mutableStateOf(listOf(pill("😀", count = 99))) + setSettlingRow({ pills }) + val before = widthOf("reaction_pill_😀") + + pills = listOf(pill("😀", count = 100)) + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.onNodeWithText("99").assertExists() + assertEquals(before, widthOf("reaction_pill_😀")) + + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.onNodeWithText("100").assertExists() + assertTrue(widthOf("reaction_pill_😀") > before, "the pill widens on the beat") + } + + @Test + fun `a count that fits its reserved room shows at once without changing the width`() { + var pills by mutableStateOf(listOf(pill("😀", count = 5))) + setSettlingRow({ pills }) + val before = widthOf("reaction_pill_😀") + + pills = listOf(pill("😀", count = 42)) + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(100) + composeTestRule.onNodeWithText("42").assertExists() + assertEquals(before, widthOf("reaction_pill_😀")) + } + + @Test + fun `selecting a pill does not change its width`() { + var pills by mutableStateOf(listOf(pill("😀", count = 3))) + setSettlingRow({ pills }) + val before = widthOf("reaction_pill_😀") + + pills = listOf(pill("😀", count = 3, selfReacted = true)) + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(1_000) + assertEquals(before, widthOf("reaction_pill_😀")) + } + + @Test + fun `positions change on the beat, and a tap right after a swap lands on the pill that was there`() { + var pills by mutableStateOf(listOf(pill("😂", count = 5), pill("🔥", count = 4))) + val toggled = mutableListOf() + setSettlingRow({ pills }, onToggle = { toggled += it }) + val rowLeft = composeTestRule.onNodeWithTag("row").getBoundsInRoot() + val firstSlot = composeTestRule.onNodeWithTag("reaction_pill_😂").getBoundsInRoot() + val slotCenter = Offset( + x = ((firstSlot.left + firstSlot.right) / 2 - rowLeft.left).value, + y = ((firstSlot.top + firstSlot.bottom) / 2 - rowLeft.top).value, + ) + + pills = listOf(pill("😂", count = 5), pill("🔥", count = 6)) + Snapshot.sendApplyNotifications() + composeTestRule.mainClock.advanceTimeBy(500) + assertTrue( + composeTestRule.onNodeWithTag("reaction_pill_😂").getBoundsInRoot().left < + composeTestRule.onNodeWithTag("reaction_pill_🔥").getBoundsInRoot().left, + "nothing moves before the beat", + ) + + // The beat swaps them; a tap 100 ms later on the first slot still gets 😂. + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("row").performTouchInput { click(slotCenter * density) } + composeTestRule.mainClock.advanceTimeByFrame() + assertEquals(listOf("😂"), toggled) + + // Past the grace, the same spot is 🔥. + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.onNodeWithTag("row").performTouchInput { click(slotCenter * density) } + composeTestRule.mainClock.advanceTimeByFrame() + assertEquals(listOf("😂", "🔥"), toggled) + } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionSettler.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionSettler.kt new file mode 100644 index 0000000000..553e4724bb --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/reactions/ReactionSettler.kt @@ -0,0 +1,203 @@ +package com.flipcash.shared.chat.reactions + +import kotlin.math.max +import kotlin.math.min + +/** + * The pills one on-screen row shows, catching up with [ReactionOrdering] on a beat rather than + * jumping the moment a count changes, so a thumb on its way to a pill still lands on it. + * + * This is `gen_reactions.py`'s `Settler`, and `reactions.json`'s `settle` vectors hold it to that. + * Counts and boosts change live through [update]; positions, joins, leaves and widths change only + * in [settle], at most once per [Config.settleMs]: + * - a beat makes at most [Config.maxSwaps] neighbour swaps, nearest the top first; + * - a pill passes its neighbour on any boost lead, or a count lead of [Config.countMargin]; a tie + * never moves one; + * - a pair that swapped cannot swap back for [Config.flapCooldownMs]; + * - a [touch] holds the row for [Config.holdAfterTouchMs], extended by later touches but never + * past [Config.maxHoldMs] from the first; + * - an emptied pill shows 0 in place until the beat, and a new emoji joins at the end on one. + * + * Each shown pill reserves room for its count (at least [MIN_COUNT_DIGITS] digits) and boost as + * they stood at the last beat. A value that fits shows at once; one that needs more room keeps the + * old value until the next beat, so a pill's width never changes between beats. + * + * Pure Kotlin with an injected [clock] (monotonic milliseconds) so it can move into the shared core + * and tests can drive it. Not thread-safe: one row owns it. + */ +class ReactionSettler( + pills: List, + private val clock: () -> Long, + private val config: Config = Config(), +) { + + /** The settle rules, equal to `settleDefaults` in `reactions.json`. */ + data class Config( + val settleMs: Long = 1_000, + val maxSwaps: Int = 3, + val countMargin: Long = 1, + val flapCooldownMs: Long = 3_000, + val holdAfterTouchMs: Long = 1_500, + val maxHoldMs: Long = 4_000, + val hitGraceMs: Long = HIT_GRACE_MS, + ) + + /** + * One pill as the row draws it: [pill] carries the values to show, [countDigits] and + * [boostDigits] the room to reserve for them (a [boostDigits] of 0 reserves none). + */ + data class Shown(val pill: ReactionPill, val countDigits: Int, val boostDigits: Int) + + private class Reservation(val countDigits: Int, val boostDigits: Int) + + private var live: Map = pills.associateBy { it.emoji } + private var order: List = target() + private val reserved = HashMap() + private val displayed = HashMap() + private var lastSettle: Long = clock() + private var holdStart: Long? = null + private var holdUntil: Long? = null + private var hitOrder: List = emptyList() + private var hitUntil: Long? = null + private val pairSwaps = HashMap, Long>() + + init { + reserveAll() + } + + /** The pills to draw, in their settled order. */ + val shown: List + get() = order.map { emoji -> + val reservation = reserved.getValue(emoji) + Shown(displayed.getValue(emoji), reservation.countDigits, reservation.boostDigits) + } + + /** When [settle] can next move anything: the next beat, or the end of a hold. */ + val nextBeatAt: Long + get() = max(lastSettle + config.settleMs, holdUntil ?: Long.MIN_VALUE) + + /** + * Whether a beat has anything to do: a pill to join or leave, a width to change, or a pair out + * of order by more than a tie. The row's beat loop can idle while this is false. + */ + val needsBeat: Boolean + get() { + if (order.any { liveCount(it) <= 0 }) return true + if (live.values.any { it.count > 0 && it.emoji !in order }) return true + if (order.any { !sameReservation(reserved.getValue(it), reservationFor(live.getValue(it))) }) return true + return order.zipWithNext().any { (above, below) -> overtakes(live.getValue(below), live.getValue(above)) } + } + + /** Takes the latest merged pills; their values show at once where they fit. */ + fun update(pills: List) { + live = pills.associateBy { it.emoji } + for (emoji in order) { + val old = displayed.getValue(emoji) + val now = live[emoji]?.takeIf { it.count > 0 } ?: old.copy(count = 0, selfReacted = false, boost = null) + val reservation = reserved.getValue(emoji) + displayed[emoji] = now.copy( + count = if (digits(now.count) <= reservation.countDigits) now.count else old.count, + boost = if (boostDigits(now.boost) <= reservation.boostDigits) now.boost else old.boost, + ) + } + } + + /** A touch-down on the row: nothing moves until the hold this starts or extends ends. */ + fun touch() { + val now = clock() + val until = holdUntil + if (until == null || now >= until) { + holdStart = now + holdUntil = min(now + config.holdAfterTouchMs, now + config.maxHoldMs) + } else { + holdUntil = max(until, min(now + config.holdAfterTouchMs, holdStart!! + config.maxHoldMs)) + } + } + + /** + * Runs a beat if one is due and the row is not held. Returns whether anything shown changed: + * an order, a join or leave, a value that was waiting for room, or a width. + */ + fun settle(): Boolean { + val now = clock() + if (holdUntil?.let { now < it } == true || now - lastSettle < config.settleMs) return false + lastSettle = now + pairSwaps.values.removeAll { now - it >= config.flapCooldownMs } + + val before = order + val next = order.filterTo(ArrayList()) { liveCount(it) > 0 } + target().filterTo(next) { it !in next } + var swaps = 0 + while (swaps < config.maxSwaps) { + val i = (0 until next.size - 1).firstOrNull { i -> + setOf(next[i], next[i + 1]) !in pairSwaps && overtakes(live.getValue(next[i + 1]), live.getValue(next[i])) + } ?: break + pairSwaps[setOf(next[i], next[i + 1])] = now + next[i] = next[i + 1].also { next[i + 1] = next[i] } + swaps++ + } + + val shownBefore = shown + order = next + reserveAll() + if (next != before) { + hitOrder = before + hitUntil = now + config.hitGraceMs + } + return shown != shownBefore + } + + /** + * The emoji a tap on slot [index] lands on, or null for the "+" pill or empty space. For + * [Config.hitGraceMs] after a beat reorders the row, that is the slot in the order before it. + */ + fun hit(index: Int): String? { + val order = if (hitUntil?.let { clock() < it } == true) hitOrder else order + return order.getOrNull(index) + } + + private fun target(): List = + ReactionOrdering.sorted(live.values.filter { it.count > 0 }).map { it.emoji } + + private fun liveCount(emoji: String): Long = live[emoji]?.count ?: 0 + + private fun overtakes(below: ReactionPill, above: ReactionPill): Boolean { + val belowBoost = below.boost?.total ?: 0L + val aboveBoost = above.boost?.total ?: 0L + if (belowBoost != aboveBoost) return belowBoost > aboveBoost + return below.count - above.count >= config.countMargin + } + + private fun reserveAll() { + reserved.keys.retainAll(order.toSet()) + displayed.keys.retainAll(order.toSet()) + for (emoji in order) { + val pill = live.getValue(emoji) + reserved[emoji] = reservationFor(pill) + displayed[emoji] = pill + } + } + + private fun reservationFor(pill: ReactionPill) = + Reservation(countDigits = max(MIN_COUNT_DIGITS, digits(pill.count)), boostDigits = boostDigits(pill.boost)) + + private fun sameReservation(a: Reservation, b: Reservation) = + a.countDigits == b.countDigits && a.boostDigits == b.boostDigits + + companion object { + /** + * How long after a layout change a tap still resolves against the layout before it. Tuned + * in the prototype for a thumb that re-aims mid-travel; nobody has measured real thumbs on + * a phone yet, and a thumb that commits early would want 600 to 700 ms. Keep it here, in + * one place, so that measurement can move it. + */ + const val HIT_GRACE_MS: Long = 450 + + /** The fewest count digits a pill reserves room for, so 9 to 10 does not widen it. */ + const val MIN_COUNT_DIGITS: Int = 2 + + private fun digits(value: Long): Int = value.coerceAtLeast(0).toString().length + + private fun boostDigits(boost: ReactionBoost?): Int = boost?.total?.takeIf { it > 0 }?.let(::digits) ?: 0 + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt index d8ed1c156b..8161526ed8 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/reactions/ReactionVectorsTest.kt @@ -15,7 +15,7 @@ import kotlin.time.Instant * verbatim copy of the orchestrator's canonical fixture (#17). A failure here is either a real regression or a cross-platform decision that * has to be made in the canonical fixture and re-synced to both platforms — never a local edit. * - * Covers the `merge`, `order` and `strip` sections; `defaults` and `recents` are covered by + * Covers the `merge`, `order`, `settle` and `strip` sections; `defaults` and `recents` are covered by * `libs:emojis`'s `RecentReactionsVectorTest`, and `drawability` by its instrumented * `EmojiDrawabilityVectorTest`. * @@ -136,6 +136,55 @@ class ReactionVectorsTest { } } + @Test + fun `settle defaults match the reactions fixture`() { + val defaults = json().getJSONObject("settleDefaults") + assertEquals(configOf(defaults), ReactionSettler.Config()) + assertEquals(defaults.getLong("hitGraceMs"), ReactionSettler.HIT_GRACE_MS) + } + + @Test + fun `settle vectors match the reactions fixture`() { + val vectors = section("settle") + assertTrue(vectors.length() > 0, "reactions.json loaded no settle vectors") + + for (i in 0 until vectors.length()) { + val vector = vectors.getJSONObject(i) + val name = vector.getString("name") + val note = vector.getString("note") + val config = configOf(vector.getJSONObject("config")) + + var now = 0L + var settler: ReactionSettler? = null + val steps = vector.getJSONArray("steps") + for (s in 0 until steps.length()) { + val step = steps.getJSONObject(s) + now = step.getLong("at") + val where = "vector `$name` step $s at ${now}ms: $note" + when (val op = step.getString("op")) { + "start" -> { + settler = ReactionSettler(settlePills(step.getJSONObject("pills")), clock = { now }, config = config) + assertEquals(strings(step.getJSONArray("expect")), settler.shown.map { it.pill.emoji }, where) + } + + "pills" -> settler!!.update(settlePills(step.getJSONObject("pills"))) + "touch" -> settler!!.touch() + "settle" -> { + settler!!.settle() + assertEquals(strings(step.getJSONArray("expect")), settler.shown.map { it.pill.emoji }, where) + } + + "hit" -> { + val expected = if (step.isNull("expect")) null else step.getString("expect") + assertEquals(expected, settler!!.hit(step.getInt("index")), where) + } + + else -> error("unknown op `$op`") + } + } + } + } + @Test fun `strip vectors match the reactions fixture`() { val vectors = section("strip") @@ -210,11 +259,38 @@ class ReactionVectorsTest { else -> error("unknown result `$result`") } - private fun section(name: String): JSONArray { + private fun configOf(json: JSONObject) = ReactionSettler.Config( + settleMs = json.getLong("settleMs"), + maxSwaps = json.getInt("maxSwaps"), + countMargin = json.getLong("countMargin"), + flapCooldownMs = json.getLong("flapCooldownMs"), + holdAfterTouchMs = json.getLong("holdAfterTouchMs"), + maxHoldMs = json.getLong("maxHoldMs"), + hitGraceMs = json.getLong("hitGraceMs"), + ) + + /** A settle step's pills, keyed by emoji, in the fixture's (unsorted) order. */ + private fun settlePills(json: JSONObject): List = json.keys().asSequence().map { emoji -> + val p = json.getJSONObject(emoji) + val boostTotal = p.getLong("boostTotal") + ReactionPill( + emoji = emoji, + count = p.getLong("count"), + selfReacted = false, + pending = false, + boost = if (boostTotal != 0L) ReactionBoost(boostTotal) else null, + ) + }.toList() + + private fun strings(json: JSONArray): List = (0 until json.length()).map { json.getString(it) } + + private fun section(name: String): JSONArray = json().getJSONArray(name) + + private fun json(): JSONObject { val json = javaClass.classLoader!! .getResourceAsStream("reactions.json")!! .bufferedReader().use { it.readText() } - return JSONObject(json).getJSONArray(name) + return JSONObject(json) } private companion object { diff --git a/test-vectors/reactions.json b/test-vectors/reactions.json index b8b86ea67d..35ff909594 100644 --- a/test-vectors/reactions.json +++ b/test-vectors/reactions.json @@ -1293,6 +1293,1231 @@ "note": "The case that separates byte order from a platform's default string compare. Both start U+261D. Next, ☝️ has U+FE0F (UTF-8 EF B8 8F) and ☝🏻 has U+1F3FB (F0 9F 8F BB): EF < F0 in UTF-8, but in UTF-16 the surrogate D83C < FE0F, which would flip them." } ], + "settleDefaults": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "settle": [ + { + "name": "first-render-is-target-order", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "👍": { + "count": 1, + "boostTotal": 0 + }, + "🔥": { + "count": 3, + "boostTotal": 0 + }, + "😂": { + "count": 2, + "boostTotal": 0 + } + }, + "expect": [ + "🔥", + "😂", + "👍" + ] + }, + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 5, + "boostTotal": 0 + } + }, + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "A row coming on screen shows `order` exactly, UTF-8 tiebreak included. Settling only governs changes after that." + }, + { + "name": "counts-live-positions-on-the-beat", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 7, + "boostTotal": 0 + } + } + }, + { + "at": 600, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "🔥 shows 7 from 300 ms, but moves only on the first beat, 1000 ms after the last one." + }, + { + "name": "climb-three-places-per-beat", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 4, + "boostTotal": 0 + }, + "😂": { + "count": 3, + "boostTotal": 0 + }, + "💯": { + "count": 2, + "boostTotal": 0 + }, + "🔥": { + "count": 1, + "boostTotal": 0 + } + }, + "expect": [ + "❤️", + "👍", + "😂", + "💯", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 4, + "boostTotal": 0 + }, + "😂": { + "count": 3, + "boostTotal": 0 + }, + "💯": { + "count": 2, + "boostTotal": 0 + }, + "🔥": { + "count": 1, + "boostTotal": 10 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "❤️", + "🔥", + "👍", + "😂", + "💯" + ] + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "🔥", + "❤️", + "👍", + "😂", + "💯" + ] + } + ], + "note": "A boost puts 🔥 first in `order`. It climbs three places per beat, so the climb is visible and each move is next door." + }, + { + "name": "top-inversion-first", + "config": { + "settleMs": 1000, + "maxSwaps": 1, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 4, + "boostTotal": 0 + }, + "😂": { + "count": 3, + "boostTotal": 0 + }, + "🔥": { + "count": 2, + "boostTotal": 0 + } + }, + "expect": [ + "❤️", + "👍", + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 6, + "boostTotal": 0 + }, + "😂": { + "count": 3, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "👍", + "❤️", + "😂", + "🔥" + ] + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "👍", + "❤️", + "🔥", + "😂" + ] + } + ], + "note": "Two pairs are out of order and the beat allows one swap: the pair nearer the top goes first." + }, + { + "name": "touch-holds-the-row", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 500, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 900, + "op": "touch" + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 2399, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 2400, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "A touch at 900 ms holds the row until 2400 ms. The beat due at 1000 ms waits for it." + }, + { + "name": "hold-is-capped", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 500, + "op": "touch" + }, + { + "at": 1500, + "op": "touch" + }, + { + "at": 2500, + "op": "touch" + }, + { + "at": 3500, + "op": "touch" + }, + { + "at": 4400, + "op": "touch" + }, + { + "at": 4499, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 4500, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "Each touch extends the hold, but not past 4000 ms from the touch that began it (500 ms). Someone tapping every second cannot freeze the row." + }, + { + "name": "new-hold-after-a-gap", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "touch" + }, + { + "at": 1600, + "op": "touch" + }, + { + "at": 1700, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 3099, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 3100, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "The first hold ends at 1600 ms, so the touch at 1600 ms begins a new one rather than extending the old one." + }, + { + "name": "no-swap-back-inside-cooldown", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 1200, + "op": "pills", + "pills": { + "😂": { + "count": 7, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 3000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 4000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + } + ], + "note": "🔥 and 😂 swapped at 1000 ms, so they cannot swap back until 4000 ms, whatever the counts do." + }, + { + "name": "count-margin", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 2, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 1100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 7, + "boostTotal": 0 + } + } + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "With a margin of 2, a lead of 1 is not enough to pass. The shipped value is 1; this pins the knob for anyone who changes it." + }, + { + "name": "money-ignores-count-margin", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 2, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 1 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + } + ], + "note": "Any boost lead passes, the smallest one included." + }, + { + "name": "live-tie-keeps-place", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 5, + "boostTotal": 0 + } + }, + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 6, + "boostTotal": 0 + }, + "🔥": { + "count": 5, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 1100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 5, + "boostTotal": 0 + } + } + }, + { + "at": 9000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + } + ], + "note": "Back to a tie, 😂 stays ahead of 🔥 on screen although `order` puts 🔥 first. The UTF-8 tiebreak decides where pills start, never a move." + }, + { + "name": "emptied-pill-waits-for-the-beat", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "👍": { + "count": 2, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥", + "👍" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "👍": { + "count": 0, + "boostTotal": 0 + } + } + }, + { + "at": 600, + "op": "settle", + "expect": [ + "😂", + "🔥", + "👍" + ] + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + } + ], + "note": "👍 shows 0 in place until the beat, so the pills after it do not slide early." + }, + { + "name": "new-emoji-joins-at-the-end", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "💯": { + "count": 1, + "boostTotal": 0 + } + } + }, + { + "at": 600, + "op": "settle", + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥", + "💯" + ] + } + ], + "note": "A new emoji appears on the beat, after the last pill, where it moves nothing but the \"+\" pill." + }, + { + "name": "new-boosted-emoji-climbs", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 5, + "boostTotal": 0 + }, + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "❤️", + "👍", + "😂", + "🔥" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "❤️": { + "count": 5, + "boostTotal": 0 + }, + "👍": { + "count": 5, + "boostTotal": 0 + }, + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "💯": { + "count": 1, + "boostTotal": 5 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "❤️", + "💯", + "👍", + "😂", + "🔥" + ] + }, + { + "at": 2000, + "op": "settle", + "expect": [ + "💯", + "❤️", + "👍", + "😂", + "🔥" + ] + } + ], + "note": "It joins at the end and spends that beat's three swaps climbing, then climbs on." + }, + { + "name": "tap-grace-hits-the-old-layout", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 100, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 6, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "🔥", + "😂" + ] + }, + { + "at": 1000, + "op": "hit", + "index": 0, + "expect": "😂" + }, + { + "at": 1449, + "op": "hit", + "index": 0, + "expect": "😂" + }, + { + "at": 1449, + "op": "hit", + "index": 2, + "expect": null + }, + { + "at": 1450, + "op": "hit", + "index": 0, + "expect": "🔥" + } + ], + "note": "A thumb already on its way to 😂 in the first slot still gets 😂 for 450 ms after the swap. Slot 2 is the \"+\" pill in both layouts." + }, + { + "name": "tap-grace-covers-the-plus-pill", + "config": { + "settleMs": 1000, + "maxSwaps": 3, + "countMargin": 1, + "flapCooldownMs": 3000, + "holdAfterTouchMs": 1500, + "maxHoldMs": 4000, + "hitGraceMs": 450 + }, + "steps": [ + { + "at": 0, + "op": "start", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + } + }, + "expect": [ + "😂", + "🔥" + ] + }, + { + "at": 300, + "op": "pills", + "pills": { + "😂": { + "count": 5, + "boostTotal": 0 + }, + "🔥": { + "count": 4, + "boostTotal": 0 + }, + "💯": { + "count": 1, + "boostTotal": 0 + } + } + }, + { + "at": 1000, + "op": "settle", + "expect": [ + "😂", + "🔥", + "💯" + ] + }, + { + "at": 1200, + "op": "hit", + "index": 2, + "expect": null + }, + { + "at": 1450, + "op": "hit", + "index": 2, + "expect": "💯" + } + ], + "note": "A joining emoji takes the \"+\" pill's place, so a tap there during the grace still opens the picker." + } + ], "strip": [ { "name": "no-reactions",