From 43c369f082248f5c4f8c72e3b8443c3d69153413 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 00:18:43 -0400 Subject: [PATCH] test(android): queue drag math is a pure function, held to web's cases (#2436) The drag-offset-to-row arithmetic lived inline in queueReorderDrag's onDragEnd lambda, which no JVM test can reach. Web's copy, offsetToDelta, has been extracted and tested since the start, and the Android comment says it mirrors web, but only one side could be held to that. - QueueDragMath.kt adds queueDragDelta (web's offsetToDelta) and queueDragTarget (delta plus the clamp to the queue). Kotlin's roundToInt breaks ties toward positive infinity, the same as JS Math.round, so the web cases carry over exactly, including half a row up staying put. - queueDragTarget returns the start index for an empty queue instead of letting coerceIn(0, -1) throw. - QueueDragMathTest mirrors queue-row-math.test.ts one case at a time, plus clamping past either end, a sub-half-row drag, an unmeasured row, and the empty queue. No behaviour change for a non-empty queue. Co-Authored-By: Claude Opus 5.5 --- .../minstrel/player/ui/QueueDragMath.kt | 32 ++++++++ .../minstrel/player/ui/QueueRow.kt | 4 +- .../minstrel/player/ui/QueueDragMathTest.kt | 78 +++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueDragMath.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/ui/QueueDragMathTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueDragMath.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueDragMath.kt new file mode 100644 index 00000000..7d0d8ad8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueDragMath.kt @@ -0,0 +1,32 @@ +package com.fabledsword.minstrel.player.ui + +import kotlin.math.roundToInt + +/** + * Drag offset to row delta, rounded to the nearest row boundary. The Android + * half of web's `offsetToDelta` (web/src/lib/components/queue-row-math.ts): + * the two clients are meant to agree, and QueueDragMathTest mirrors web's + * cases so both are held to it (#2436). + * + * Ties round toward positive infinity, as JS `Math.round` does: half a row + * down moves one row, half a row up stays put. A row not yet measured + * (height 0) moves nothing. + */ +internal fun queueDragDelta(offsetPx: Float, rowHeightPx: Int): Int = + if (rowHeightPx > 0) (offsetPx / rowHeightPx).roundToInt() else 0 + +/** + * The queue index a row dragged from [index] lands on, clamped to the queue. + * A drag past either end stops at the first or last row. + */ +internal fun queueDragTarget( + index: Int, + offsetPx: Float, + rowHeightPx: Int, + queueSize: Int, +): Int { + // coerceIn throws on an empty range; a row can't be dragged in an empty + // queue, but the composable's keys can briefly outlive the list. + if (queueSize <= 0) return index + return (index + queueDragDelta(offsetPx, rowHeightPx)).coerceIn(0, queueSize - 1) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt index d9b55b0a..6dee5a84 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/QueueRow.kt @@ -48,7 +48,6 @@ import com.fabledsword.minstrel.shared.formatDuration import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.theme.LocalActionColors -import kotlin.math.roundToInt /* * A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435) @@ -278,8 +277,7 @@ private fun Modifier.queueReorderDrag( onOffsetChange(offset) }, onDragEnd = { - val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0 - val target = (index + delta).coerceIn(0, queueSize - 1) + val target = queueDragTarget(index, offset, rowHeightPx, queueSize) if (target != index) onMove(index, target) offset = 0f onOffsetChange(0f) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/ui/QueueDragMathTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/ui/QueueDragMathTest.kt new file mode 100644 index 00000000..db08bd35 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/ui/QueueDragMathTest.kt @@ -0,0 +1,78 @@ +package com.fabledsword.minstrel.player.ui + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +/** + * The queue drag math (#2436). The delta cases mirror web's + * queue-row-math.test.ts one for one, so a change to either client's + * rounding shows up as a disagreement here rather than as a drop that lands + * one row off on one platform only. + */ +class QueueDragMathTest { + + @Test + fun `zero offset moves nothing`() { + assertEquals(0, queueDragDelta(0f, 64)) + } + + @Test + fun `a full row down moves one`() { + assertEquals(1, queueDragDelta(64f, 64)) + } + + @Test + fun `just under half a row rounds down`() { + assertEquals(0, queueDragDelta(31f, 64)) + } + + @Test + fun `half a row or more rounds up`() { + assertEquals(1, queueDragDelta(32f, 64)) + } + + @Test + fun `drag up is symmetric, with ties toward positive infinity as in JS`() { + assertEquals(-1, queueDragDelta(-64f, 64)) + assertEquals(0, queueDragDelta(-32f, 64)) + } + + @Test + fun `several rows`() { + assertEquals(3, queueDragDelta(192f, 64)) + assertEquals(-2, queueDragDelta(-128f, 64)) + } + + @Test + fun `a row not yet measured moves nothing`() { + assertEquals(0, queueDragDelta(100f, 0)) + assertEquals(0, queueDragDelta(100f, -1)) + } + + @Test + fun `the measured row height is what counts`() { + assertEquals(1, queueDragDelta(56f, 56)) + assertEquals(1, queueDragDelta(28f, 56)) + } + + @Test + fun `a drag past either end stops at the first or last row`() { + assertEquals(0, queueDragTarget(index = 1, offsetPx = -640f, rowHeightPx = 64, queueSize = 5)) + assertEquals(4, queueDragTarget(index = 3, offsetPx = 640f, rowHeightPx = 64, queueSize = 5)) + } + + @Test + fun `a sub-half-row drag lands where it started`() { + assertEquals(2, queueDragTarget(index = 2, offsetPx = 20f, rowHeightPx = 64, queueSize = 5)) + } + + @Test + fun `before the row is measured a drag lands where it started`() { + assertEquals(2, queueDragTarget(index = 2, offsetPx = 300f, rowHeightPx = 0, queueSize = 5)) + } + + @Test + fun `an empty queue does not throw`() { + assertEquals(0, queueDragTarget(index = 0, offsetPx = 64f, rowHeightPx = 64, queueSize = 0)) + } +}