reorder the queue with the reorderable library

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent 68ca600bb5
commit a682cfc1cb
9 changed files with 337 additions and 535 deletions
@@ -1,159 +0,0 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class QueuePagingSourceTest {
private lateinit var db: SubtracksDatabase
private lateinit var repository: QueueRepository
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
repository = QueueRepository(db)
}
@After
fun tearDown() {
db.close()
}
@Test
fun pagesAreContiguousAndDoNotOverlap() =
runTest {
seedSongs(130)
repository.replace(listOf(repository.songsEntry(1)))
val source = repository.pagingSource(initialPosition = 0)
val first = load(source, key = null)
assertEquals((0L until 60L).toList(), first.data.map { it.position })
assertEquals(1L, first.nextKey)
assertEquals(null, first.prevKey)
val second = load(source, key = 1L)
assertEquals((60L until 120L).toList(), second.data.map { it.position })
assertEquals(2L, second.nextKey)
assertEquals(0L, second.prevKey)
val third = load(source, key = 2L)
assertEquals((120L until 130L).toList(), third.data.map { it.position })
assertEquals(null, third.nextKey)
assertEquals(1L, third.prevKey)
}
@Test
fun theFirstPageStartsAtTheInitialPosition() =
runTest {
seedSongs(130)
repository.replace(listOf(repository.songsEntry(1)))
val source = repository.pagingSource(initialPosition = 100)
val first = load(source, key = null)
assertEquals((60L until 120L).toList(), first.data.map { it.position })
assertEquals(2L, first.nextKey)
assertEquals(0L, first.prevKey)
}
@Test
fun theRefreshKeyUsesTheLoadedPositions() =
runTest {
val source = repository.pagingSource(initialPosition = 100)
val state =
PagingState(
pages = listOf(PagingSource.LoadResult.Page(listOf(itemAt(60)), prevKey = 0L, nextKey = 2L)),
anchorPosition = 5,
config = PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false),
leadingPlaceholderCount = 0,
)
assertEquals(1L, source.getRefreshKey(state))
}
@Test
fun anEmptyQueueLoadsNothing() =
runTest {
val result = load(repository.pagingSource(0), key = null)
assertEquals(emptyList<QueueWindowItem>(), result.data)
assertEquals(null, result.nextKey)
assertEquals(null, result.prevKey)
}
private fun itemAt(position: Long) =
QueueWindowItem(
position,
SongListItem(
song =
Song(
sourceId = 1,
id = "s$position",
albumId = "al1",
artistId = "ar1",
title = "Song $position",
album = "Album",
artist = "Artist",
duration = 100,
track = position,
disc = 1,
starred = null,
genre = null,
),
coverArt = null,
),
)
private suspend fun load(
source: PagingSource<Long, QueueWindowItem>,
key: Long?,
): PagingSource.LoadResult.Page<Long, QueueWindowItem> {
val params = PagingSource.LoadParams.Refresh<Long>(key, QUEUE_PAGE_SIZE, false)
return source.load(params) as PagingSource.LoadResult.Page<Long, QueueWindowItem>
}
private suspend fun seedSongs(count: Int) {
db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
)
db.libraryDao().upsertSongs(
(1..count).map { track ->
Song(
sourceId = 1,
id = "s$track",
albumId = "al1",
artistId = "ar1",
title = "Song $track",
album = "Album",
artist = "Artist",
duration = 100,
track = track.toLong(),
disc = 1,
starred = null,
genre = null,
)
},
)
}
}
@@ -223,20 +223,18 @@ class AppScreenshotTest {
composeRule.setContent {
SubtracksTheme {
QueueScreen(
items =
remember {
flowOf(
PagingData.from(
Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) },
),
)
}.collectAsLazyPagingItems(),
rows =
Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) },
ready = true,
currentPosition = 1,
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
onPlay = {},
onRemove = {},
onReorder = { _, _ -> },
onMove = { _, _ -> },
onLoadOlder = {},
onLoadNewer = {},
onUndo = {},
modifier = Modifier.fillMaxSize(),
)
@@ -0,0 +1,129 @@
package com.subtracks.ui.playback
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class QueueViewModelTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var db: SubtracksDatabase
private lateinit var queues: QueueRepository
private lateinit var controller: PlaybackController
private lateinit var viewModel: QueueViewModel
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
queues = QueueRepository(db)
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), dispatcher)
viewModel = QueueViewModel(queues, controller)
}
@After
fun tearDown() {
controller.close()
db.close()
Dispatchers.resetMain()
dispatcher.close()
}
@Test
fun openingLoadsAWindowAroundTheCursorAndExtendingGrowsIt() {
runBlocking { seedSongs(200) }
controller.playSongs(1, 100)
await { controller.state.value.position == 100L }
viewModel.open()
await { viewModel.ready }
assertEquals((70L..129L).toList(), viewModel.rows.map { it.position })
viewModel.loadNewer()
await { viewModel.rows.lastOrNull()?.position == 189L }
assertEquals((70L..189L).toList(), viewModel.rows.map { it.position })
viewModel.loadOlder()
await { viewModel.rows.firstOrNull()?.position == 10L }
assertEquals((10L..189L).toList(), viewModel.rows.map { it.position })
}
@Test
fun reorderingMovesTheRowLocally() {
runBlocking { seedSongs(200) }
controller.playSongs(1, 100)
await { controller.state.value.position == 100L }
viewModel.open()
await { viewModel.ready }
viewModel.reorder(fromIndex = 0, toIndex = 2)
val positions = viewModel.rows.map { it.position }
assertEquals(listOf(71L, 72L, 70L), positions.take(3))
}
private suspend fun seedSongs(count: Int) {
db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
)
db.libraryDao().upsertSongs(
(1..count).map { track ->
Song(
sourceId = 1,
id = "s$track",
albumId = "al1",
artistId = "ar1",
title = "Song $track",
album = "Album",
artist = "Artist",
duration = 100,
track = track.toLong(),
disc = 1,
starred = null,
genre = null,
)
},
)
}
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected state", predicate())
}
}