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)) + } +}