From c302c62a3f2a5ab1cb53e63e0965b6504ec15bb4 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:56:19 -0400 Subject: [PATCH] performance fix for queue --- .../astralibraryscanner/data/UserDatabase.kt | 184 ++++++++++-- .../astralibraryscanner/queue/QueueReorder.kt | 222 +++++++++++++++ .../queue/QueueReorderTest.kt | 261 ++++++++++++++++++ 3 files changed, 637 insertions(+), 30 deletions(-) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt index dda578c..be1942f 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt @@ -11,6 +11,10 @@ import androidx.room.Transaction import androidx.room.Upsert import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import expo.modules.astralibraryscanner.queue.QUEUE_PARK_OFFSET +import expo.modules.astralibraryscanner.queue.QueueReorder +import expo.modules.astralibraryscanner.queue.QueueRowKey +import expo.modules.astralibraryscanner.queue.QueueWritePlan import kotlinx.coroutines.flow.Flow data class RemotePlaylistSyncPlan( @@ -406,6 +410,31 @@ interface UserDao { ) suspend fun deleteQueueEntriesById(sessionId: String, entryIds: List) + @Query( + """ + UPDATE playback_queue_entries + SET position = position + :delta + WHERE session_id = :sessionId + AND position >= :fromPosition + AND position <= :toPosition + """, + ) + suspend fun shiftQueueRange( + sessionId: String, + fromPosition: Long, + toPosition: Long, + delta: Long, + ) + + @Query( + """ + UPDATE playback_queue_entries + SET position = :position + WHERE session_id = :sessionId AND entry_id = :entryId + """, + ) + suspend fun setQueueEntryPosition(sessionId: String, entryId: Long, position: Long) + @Query("SELECT COUNT(*) FROM playback_queue_entries WHERE session_id = :sessionId") suspend fun countQueueEntries(sessionId: String): Long @@ -439,6 +468,31 @@ interface UserDao { ) suspend fun deleteOriginalQueueEntriesById(sessionId: String, entryIds: List) + @Query( + """ + UPDATE playback_original_queue_entries + SET position = position + :delta + WHERE session_id = :sessionId + AND position >= :fromPosition + AND position <= :toPosition + """, + ) + suspend fun shiftOriginalQueueRange( + sessionId: String, + fromPosition: Long, + toPosition: Long, + delta: Long, + ) + + @Query( + """ + UPDATE playback_original_queue_entries + SET position = :position + WHERE session_id = :sessionId AND entry_id = :entryId + """, + ) + suspend fun setOriginalQueueEntryPosition(sessionId: String, entryId: Long, position: Long) + @Query("SELECT * FROM snapshot_metadata WHERE id = 1") suspend fun getSnapshotMetadata(): SnapshotMetadataEntity? @@ -471,18 +525,34 @@ interface UserDao { ) { val oldEntries = getAllQueueEntries(session.id) putPlaybackSession(session) - val changedAt = firstChangedQueuePosition(oldEntries, entries) - if (changedAt != null) { - parkQueuePositions(session.id, changedAt.toLong(), 1_000_000_000_000L) - val retainedIds = entries.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId) - val removedIds = oldEntries - .asSequence() - .map(PlaybackQueueEntryEntity::entryId) - .filterNot(retainedIds::contains) - .toList() - if (removedIds.isNotEmpty()) deleteQueueEntriesById(session.id, removedIds) - val changedRows = entries.drop(changedAt) - if (changedRows.isNotEmpty()) putQueueEntries(changedRows) + // Removals and single moves — the edits a user performs on an open queue — + // become a handful of range UPDATEs. The fallback below rewrites every row + // from the change point, which for an edit near the active track means + // marshalling almost the whole queue through Room on every tap. + when (val plan = QueueReorder.planWrite(oldEntries.asRowKeys(), entries.asRowKeys())) { + QueueWritePlan.NoChange -> Unit + + is QueueWritePlan.Removal -> { + deleteQueueEntriesById(session.id, plan.removedIds) + parkQueuePositions(session.id, plan.removedPositions.min(), QUEUE_PARK_OFFSET) + QueueReorder.compactionShifts(plan.removedPositions).forEach { shift -> + shiftQueueRange(session.id, shift.fromPosition, shift.toPosition, shift.delta) + } + } + + is QueueWritePlan.Move -> { + val move = QueueReorder.movePlan(plan.from, plan.to) + if (move == null) { + rebuildQueueTail(session.id, oldEntries, entries) + } else { + setQueueEntryPosition(session.id, plan.entryId, move.parkedMovedPosition) + move.spanOut?.let { shiftQueueRange(session.id, it.fromPosition, it.toPosition, it.delta) } + move.spanBack?.let { shiftQueueRange(session.id, it.fromPosition, it.toPosition, it.delta) } + setQueueEntryPosition(session.id, plan.entryId, move.finalPosition) + } + } + + QueueWritePlan.Rebuild -> rebuildQueueTail(session.id, oldEntries, entries) } val oldOriginal = getOriginalQueueEntries(session.id) @@ -490,28 +560,82 @@ interface UserDao { if (oldOriginal.isNotEmpty()) clearOriginalQueueEntries(session.id) return } - val originalChangedAt = firstChangedOriginalPosition(oldOriginal, originalEntries) - if (originalChangedAt != null) { - parkOriginalQueuePositions( - session.id, - originalChangedAt.toLong(), - 1_000_000_000_000L, - ) - val retainedIds = originalEntries - .mapTo(hashSetOf(), PlaybackOriginalQueueEntryEntity::entryId) - val removedIds = oldOriginal - .asSequence() - .map(PlaybackOriginalQueueEntryEntity::entryId) - .filterNot(retainedIds::contains) - .toList() - if (removedIds.isNotEmpty()) { - deleteOriginalQueueEntriesById(session.id, removedIds) + when ( + val plan = QueueReorder.planWrite(oldOriginal.asOriginalRowKeys(), originalEntries.asOriginalRowKeys()) + ) { + QueueWritePlan.NoChange -> Unit + + is QueueWritePlan.Removal -> { + deleteOriginalQueueEntriesById(session.id, plan.removedIds) + parkOriginalQueuePositions(session.id, plan.removedPositions.min(), QUEUE_PARK_OFFSET) + QueueReorder.compactionShifts(plan.removedPositions).forEach { shift -> + shiftOriginalQueueRange(session.id, shift.fromPosition, shift.toPosition, shift.delta) + } } - val changedRows = originalEntries.drop(originalChangedAt) - if (changedRows.isNotEmpty()) putOriginalQueueEntries(changedRows) + + is QueueWritePlan.Move -> { + val move = QueueReorder.movePlan(plan.from, plan.to) + if (move == null) { + rebuildOriginalQueueTail(session.id, oldOriginal, originalEntries) + } else { + setOriginalQueueEntryPosition(session.id, plan.entryId, move.parkedMovedPosition) + move.spanOut?.let { + shiftOriginalQueueRange(session.id, it.fromPosition, it.toPosition, it.delta) + } + move.spanBack?.let { + shiftOriginalQueueRange(session.id, it.fromPosition, it.toPosition, it.delta) + } + setOriginalQueueEntryPosition(session.id, plan.entryId, move.finalPosition) + } + } + + QueueWritePlan.Rebuild -> rebuildOriginalQueueTail(session.id, oldOriginal, originalEntries) } } + /** The pre-existing write path: park the tail, drop removals, upsert from the change point. */ + private suspend fun rebuildQueueTail( + sessionId: String, + before: List, + after: List, + ) { + val changedAt = firstChangedQueuePosition(before, after) ?: return + parkQueuePositions(sessionId, changedAt.toLong(), QUEUE_PARK_OFFSET) + val retainedIds = after.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId) + val removedIds = before + .asSequence() + .map(PlaybackQueueEntryEntity::entryId) + .filterNot(retainedIds::contains) + .toList() + if (removedIds.isNotEmpty()) deleteQueueEntriesById(sessionId, removedIds) + val changedRows = after.drop(changedAt) + if (changedRows.isNotEmpty()) putQueueEntries(changedRows) + } + + private suspend fun rebuildOriginalQueueTail( + sessionId: String, + before: List, + after: List, + ) { + val changedAt = firstChangedOriginalPosition(before, after) ?: return + parkOriginalQueuePositions(sessionId, changedAt.toLong(), QUEUE_PARK_OFFSET) + val retainedIds = after.mapTo(hashSetOf(), PlaybackOriginalQueueEntryEntity::entryId) + val removedIds = before + .asSequence() + .map(PlaybackOriginalQueueEntryEntity::entryId) + .filterNot(retainedIds::contains) + .toList() + if (removedIds.isNotEmpty()) deleteOriginalQueueEntriesById(sessionId, removedIds) + val changedRows = after.drop(changedAt) + if (changedRows.isNotEmpty()) putOriginalQueueEntries(changedRows) + } + + private fun List.asRowKeys(): List = + map { QueueRowKey(it.entryId, it.trackPath, it.position) } + + private fun List.asOriginalRowKeys(): List = + map { QueueRowKey(it.entryId, it.trackPath, it.position) } + private fun firstChangedQueuePosition( before: List, after: List, diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueReorder.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueReorder.kt index bba88e2..71179c1 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueReorder.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/queue/QueueReorder.kt @@ -1,5 +1,72 @@ package expo.modules.astralibraryscanner.queue +/** + * Positions are parked into this disjoint space while an edit is in flight. + * + * `playback_queue_entries` has a unique `(session_id, position)` index, and a + * bulk `UPDATE ... SET position = position + delta` would violate it the moment + * SQLite visits rows in an order where a row lands on a slot not yet vacated — + * an order the query planner does not promise. Moving the affected rows clear of + * the real position space first makes every write collision-free regardless of + * visit order. + */ +const val QUEUE_PARK_OFFSET = 1_000_000_000_000L + +/** One `UPDATE ... SET position = position + delta` over an inclusive range. */ +data class QueueShift( + val fromPosition: Long, + val toPosition: Long, + val delta: Long, +) + +/** + * The statement sequence that relocates one row, leaving positions dense. + * + * The moved row parks below the span so the two never collide even when the + * destination is position zero. + */ +data class QueueMovePlan( + val parkedMovedPosition: Long, + val spanOut: QueueShift?, + val spanBack: QueueShift?, + val finalPosition: Long, +) + +/** Identity of one queue row, decoupled from the Room entity so it stays JVM-testable. */ +data class QueueRowKey( + val entryId: Long, + val trackPath: String, + val position: Long, +) + +/** + * How to persist an order change. + * + * [Removal] and [Move] are the edits a user actually performs on an open queue, + * and both are expressible as a handful of `UPDATE`s over a position range. + * Anything else — shuffle, append, insert-after-active — falls back to + * [Rebuild], which rewrites rows from the change point. Detection is + * conservative on purpose: an unrecognised shape costs the old behaviour, never + * a wrong order. + */ +sealed interface QueueWritePlan { + data class Removal( + val removedIds: List, + val removedPositions: List, + ) : QueueWritePlan + + data class Move( + val entryId: Long, + val from: Long, + val to: Long, + ) : QueueWritePlan + + /** Order is unchanged; only the session row needs writing. */ + data object NoChange : QueueWritePlan + + data object Rebuild : QueueWritePlan +} + /** * Pure index math shared by the queue view and the playback repository. * @@ -35,4 +102,159 @@ object QueueReorder { items.add(to.coerceIn(0, items.size), item) return true } + + /** + * Range shifts that re-densify positions after the rows at [removedPositions] + * have been deleted and everything from the first gap onwards has been parked + * by [parkOffset]. + * + * A surviving row at original position `q` must end up at + * `q - (removed positions below q)`, so rows between consecutive gaps share a + * shift and each gap deepens it by one. That collapses an edit into one + * statement per removed row instead of one row-write per surviving row. + */ + fun compactionShifts( + removedPositions: List, + parkOffset: Long = QUEUE_PARK_OFFSET, + ): List { + val gaps = removedPositions.distinct().sorted() + if (gaps.isEmpty()) return emptyList() + return gaps + .mapIndexed { index, position -> + QueueShift( + fromPosition = parkOffset + position + 1L, + // The final segment runs to the end of the parked space. + toPosition = gaps.getOrNull(index + 1)?.let { parkOffset + it - 1L } ?: Long.MAX_VALUE, + delta = -parkOffset - (index + 1L), + ) + } + // Adjacent gaps enclose no rows. + .filter { it.fromPosition <= it.toPosition } + } + + /** + * Statement sequence to move the row at [from] to [to], keeping positions + * dense. Returns null when the move is a no-op. + * + * Matches [applyMove]: the row ends at exactly [to] in both directions. + */ + /** + * Classify an order change so the common edits can be written as position + * arithmetic instead of rewriting every row from the change point onward. + * + * [before] must already be dense and ordered by position (what + * `ORDER BY position` yields for an intact queue); anything else returns + * [QueueWritePlan.Rebuild], as does any shape this does not positively + * recognise. + */ + fun planWrite( + before: List, + after: List, + ): QueueWritePlan { + if (before.isEmpty() || after.isEmpty()) return QueueWritePlan.Rebuild + if (!isDense(before) || !isDense(after)) return QueueWritePlan.Rebuild + return when { + after.size < before.size -> planRemoval(before, after) + after.size == before.size -> planMove(before, after) + else -> QueueWritePlan.Rebuild + } + } + + private fun isDense(rows: List): Boolean = + rows.withIndex().all { (index, row) -> row.position == index.toLong() } + + private fun planRemoval( + before: List, + after: List, + ): QueueWritePlan { + val keptIds = after.mapTo(HashSet(after.size), QueueRowKey::entryId) + val removedIds = ArrayList(before.size - after.size) + val removedPositions = ArrayList(before.size - after.size) + val survivors = ArrayList(after.size) + before.forEach { row -> + if (row.entryId in keptIds) { + survivors.add(row) + } else { + removedIds.add(row.entryId) + removedPositions.add(row.position) + } + } + if (survivors.size != after.size || removedIds.isEmpty()) return QueueWritePlan.Rebuild + // Survivors must keep both identity and relative order for a compaction to + // reproduce `after` exactly. + for (index in after.indices) { + if ( + survivors[index].entryId != after[index].entryId || + survivors[index].trackPath != after[index].trackPath + ) return QueueWritePlan.Rebuild + } + return QueueWritePlan.Removal(removedIds, removedPositions) + } + + private fun planMove( + before: List, + after: List, + ): QueueWritePlan { + var first = -1 + var last = -1 + for (index in before.indices) { + if (before[index].entryId != after[index].entryId) { + if (first < 0) first = index + last = index + } + } + if (first < 0) return QueueWritePlan.NoChange + // A single relocation shows up as a rotation of [first, last] by one, in + // whichever direction the row travelled. + val movedDown = matchesRotation(before, after, first, last, movedDown = true) + val movedUp = matchesRotation(before, after, first, last, movedDown = false) + return when { + movedDown -> QueueWritePlan.Move(before[first].entryId, first.toLong(), last.toLong()) + movedUp -> QueueWritePlan.Move(before[last].entryId, last.toLong(), first.toLong()) + else -> QueueWritePlan.Rebuild + } + } + + private fun matchesRotation( + before: List, + after: List, + first: Int, + last: Int, + movedDown: Boolean, + ): Boolean { + val moved = if (movedDown) before[first] else before[last] + val landing = if (movedDown) last else first + if (after[landing].entryId != moved.entryId || after[landing].trackPath != moved.trackPath) { + return false + } + // The rest of the window shifts one slot toward the vacated end. + for (index in first..last) { + if (index == landing) continue + val source = if (movedDown) before[index + 1] else before[index - 1] + if ( + after[index].entryId != source.entryId || + after[index].trackPath != source.trackPath + ) return false + } + return true + } + + fun movePlan( + from: Long, + to: Long, + parkOffset: Long = QUEUE_PARK_OFFSET, + ): QueueMovePlan? { + if (from == to || from < 0L || to < 0L) return null + // Below the parked span, so a move to position zero cannot collide with it. + val parkedMoved = parkOffset - 1L + val spanStart = if (from < to) from + 1L else to + val spanEnd = if (from < to) to else from - 1L + val backDelta = if (from < to) -parkOffset - 1L else -parkOffset + 1L + return QueueMovePlan( + parkedMovedPosition = parkedMoved, + spanOut = QueueShift(spanStart, spanEnd, parkOffset), + spanBack = QueueShift(parkOffset + spanStart, parkOffset + spanEnd, backDelta), + finalPosition = to, + ) + } } diff --git a/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/queue/QueueReorderTest.kt b/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/queue/QueueReorderTest.kt index 4b1bf6c..5325a07 100644 --- a/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/queue/QueueReorderTest.kt +++ b/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/queue/QueueReorderTest.kt @@ -2,6 +2,7 @@ package expo.modules.astralibraryscanner.queue import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -94,4 +95,264 @@ class QueueReorderTest { assertEquals(-1, QueueReorder.adapterIndex(activePosition = 5L, queuePosition = 5L)) assertEquals(-1, QueueReorder.adapterIndex(activePosition = 5L, queuePosition = 0L)) } + + // --- SQL position arithmetic ------------------------------------------------- + // + // These simulate the statements the DAO issues against an in-memory row set, + // and assert two things: the end state is dense and matches the plain list + // semantics above, and no intermediate step ever puts two rows on the same + // position — which is exactly what the unique (session_id, position) index + // would reject. + + /** (entryId, position), mirroring playback_queue_entries. */ + private fun rows(count: Int): MutableList> = + (0 until count).mapTo(mutableListOf()) { (100L + it) to it.toLong() } + + private fun applyShift(rows: MutableList>, shift: QueueShift) { + rows.forEachIndexed { index, (id, position) -> + if (position >= shift.fromPosition && position <= shift.toPosition) { + rows[index] = id to (position + shift.delta) + } + } + assertNoCollision(rows) + } + + private fun setPosition(rows: MutableList>, entryId: Long, position: Long) { + rows[rows.indexOfFirst { it.first == entryId }] = entryId to position + assertNoCollision(rows) + } + + private fun assertNoCollision(rows: List>) { + val positions = rows.map { it.second } + assertEquals( + "two rows share a position — the unique index would reject this", + positions.size, + positions.toSet().size, + ) + } + + private fun orderedIds(rows: List>): List = + rows.sortedBy { it.second }.map { it.first } + + private fun assertDense(rows: List>) { + assertEquals( + (0 until rows.size).map(Int::toLong), + rows.map { it.second }.sorted(), + ) + } + + private fun removeAndCompact(count: Int, removed: List): List { + val live = rows(count) + val expected = orderedIds(live).filterIndexed { index, _ -> index.toLong() !in removed } + + live.removeAll { it.second in removed } + val park = QueueReorder.compactionShifts(removed) + applyShift(live, QueueShift(removed.min(), Long.MAX_VALUE, QUEUE_PARK_OFFSET)) + park.forEach { applyShift(live, it) } + + assertDense(live) + return orderedIds(live).also { assertEquals(expected, it) } + } + + @Test + fun `removing one row in the middle compacts the rest`() { + removeAndCompact(count = 8, removed = listOf(3L)) + } + + @Test + fun `removing the first and last rows compacts correctly`() { + removeAndCompact(count = 8, removed = listOf(0L)) + removeAndCompact(count = 8, removed = listOf(7L)) + } + + @Test + fun `removing several rows at once compacts by a deepening offset`() { + removeAndCompact(count = 12, removed = listOf(2L, 5L, 6L, 9L)) + } + + @Test + fun `removing adjacent rows leaves no empty segment behind`() { + removeAndCompact(count = 6, removed = listOf(1L, 2L, 3L)) + } + + @Test + fun `every single-row removal in a queue compacts densely`() { + for (position in 0L until 10L) { + removeAndCompact(count = 10, removed = listOf(position)) + } + } + + @Test + fun `the move plan reproduces applyMove for every source and destination`() { + val size = 7 + for (from in 0 until size) { + for (to in 0 until size) { + val live = rows(size) + val movedId = 100L + from + val expected = orderedIds(live).toMutableList() + val changed = QueueReorder.applyMove(expected, from, to) + + val plan = QueueReorder.movePlan(from.toLong(), to.toLong()) + assertEquals("from=$from to=$to", changed, plan != null) + if (plan == null) continue + + setPosition(live, movedId, plan.parkedMovedPosition) + plan.spanOut?.let { applyShift(live, it) } + plan.spanBack?.let { applyShift(live, it) } + setPosition(live, movedId, plan.finalPosition) + + assertDense(live) + assertEquals("from=$from to=$to", expected, orderedIds(live)) + } + } + } + + @Test + fun `moving to position zero does not collide with the parked row`() { + val live = rows(5) + val plan = QueueReorder.movePlan(4L, 0L)!! + + setPosition(live, 104L, plan.parkedMovedPosition) + plan.spanOut?.let { applyShift(live, it) } + plan.spanBack?.let { applyShift(live, it) } + setPosition(live, 104L, plan.finalPosition) + + assertDense(live) + assertEquals(listOf(104L, 100L, 101L, 102L, 103L), orderedIds(live)) + } + + @Test + fun `a no-op move produces no plan`() { + assertNull(QueueReorder.movePlan(3L, 3L)) + assertNull(QueueReorder.movePlan(-1L, 2L)) + } + + // --- write-plan detection ---------------------------------------------------- + // + // Mis-classification here would persist a wrong order, so the bar is that every + // recognised plan must reproduce `after` exactly when executed, and anything + // not positively recognised must fall back to Rebuild. + + private fun keys(vararg ids: Long): List = + ids.mapIndexed { index, id -> QueueRowKey(id, "track-$id", index.toLong()) } + + private fun keysOf(ids: List): List = keys(*ids.toLongArray()) + + /** Runs a plan against the simulated table and returns the resulting id order. */ + private fun execute(before: List, plan: QueueWritePlan): List { + val live = before.mapTo(mutableListOf()) { it.entryId to it.position } + when (plan) { + is QueueWritePlan.Removal -> { + live.removeAll { (id, _) -> id in plan.removedIds } + applyShift(live, QueueShift(plan.removedPositions.min(), Long.MAX_VALUE, QUEUE_PARK_OFFSET)) + QueueReorder.compactionShifts(plan.removedPositions).forEach { applyShift(live, it) } + } + is QueueWritePlan.Move -> { + val movePlan = QueueReorder.movePlan(plan.from, plan.to)!! + setPosition(live, plan.entryId, movePlan.parkedMovedPosition) + movePlan.spanOut?.let { applyShift(live, it) } + movePlan.spanBack?.let { applyShift(live, it) } + setPosition(live, plan.entryId, movePlan.finalPosition) + } + QueueWritePlan.NoChange, QueueWritePlan.Rebuild -> Unit + } + assertDense(live) + return orderedIds(live) + } + + @Test + fun `every single removal is detected and executes to the right order`() { + val ids = listOf(10L, 11L, 12L, 13L, 14L, 15L) + for (dropped in ids) { + val before = keysOf(ids) + val after = keysOf(ids - dropped) + val plan = QueueReorder.planWrite(before, after) + + assertTrue("dropping $dropped -> $plan", plan is QueueWritePlan.Removal) + assertEquals("dropping $dropped", ids - dropped, execute(before, plan)) + } + } + + @Test + fun `a multi-row removal is detected and executes to the right order`() { + val ids = listOf(10L, 11L, 12L, 13L, 14L, 15L, 16L) + val dropped = setOf(11L, 12L, 15L) + val before = keysOf(ids) + val after = keysOf(ids.filterNot(dropped::contains)) + + val plan = QueueReorder.planWrite(before, after) + + assertTrue(plan is QueueWritePlan.Removal) + assertEquals(ids.filterNot(dropped::contains), execute(before, plan)) + } + + @Test + fun `every single move is detected and executes to the right order`() { + val ids = listOf(10L, 11L, 12L, 13L, 14L, 15L) + for (from in ids.indices) { + for (to in ids.indices) { + if (from == to) continue + val expected = ids.toMutableList() + QueueReorder.applyMove(expected, from, to) + val before = keysOf(ids) + val plan = QueueReorder.planWrite(before, keysOf(expected)) + + assertTrue("from=$from to=$to -> $plan", plan is QueueWritePlan.Move) + assertEquals("from=$from to=$to", expected, execute(before, plan)) + } + } + } + + @Test + fun `an unchanged order is detected as NoChange`() { + val ids = listOf(10L, 11L, 12L) + assertEquals(QueueWritePlan.NoChange, QueueReorder.planWrite(keysOf(ids), keysOf(ids))) + } + + @Test + fun `shapes that are not a plain removal or single move fall back to Rebuild`() { + val ids = listOf(10L, 11L, 12L, 13L, 14L) + + // A shuffle. + assertEquals( + QueueWritePlan.Rebuild, + QueueReorder.planWrite(keysOf(ids), keysOf(listOf(13L, 10L, 14L, 11L, 12L))), + ) + // Two independent swaps — not expressible as one relocation. + assertEquals( + QueueWritePlan.Rebuild, + QueueReorder.planWrite(keysOf(ids), keysOf(listOf(11L, 10L, 13L, 12L, 14L))), + ) + // An insertion. + assertEquals( + QueueWritePlan.Rebuild, + QueueReorder.planWrite(keysOf(ids), keysOf(ids + 99L)), + ) + // A removal that also reorders the survivors. + assertEquals( + QueueWritePlan.Rebuild, + QueueReorder.planWrite(keysOf(ids), keysOf(listOf(14L, 11L, 12L, 13L))), + ) + } + + @Test + fun `a survivor whose track path changed is not treated as a plain removal`() { + val before = keys(10L, 11L, 12L) + val after = listOf( + QueueRowKey(10L, "track-10", 0L), + QueueRowKey(12L, "replaced", 1L), + ) + + assertEquals(QueueWritePlan.Rebuild, QueueReorder.planWrite(before, after)) + } + + @Test + fun `a non-dense before list falls back to Rebuild`() { + val sparse = listOf( + QueueRowKey(10L, "track-10", 0L), + QueueRowKey(11L, "track-11", 5L), + ) + + assertEquals(QueueWritePlan.Rebuild, QueueReorder.planWrite(sparse, keys(10L))) + } }