test(android): queue drag math is a pure function, held to web's cases (#2436)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / android (push) Successful in 4m46s
release / Build signed APK (releases and dev) (push) Successful in 4m58s
release / integration (push) Successful in 15m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 00:18:43 -04:00
co-authored by Claude Opus 5.5
parent b46c080d19
commit 43c369f082
3 changed files with 111 additions and 3 deletions
@@ -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)
}
@@ -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)
@@ -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))
}
}