mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
add queue, migration and playback controller tests and goldens
This commit is contained in:
@@ -0,0 +1,120 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.sqlite.SQLiteConnection
|
||||||
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import androidx.sqlite.execSQL
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MigrationsTest {
|
||||||
|
private val driver = BundledSQLiteDriver()
|
||||||
|
private val schemaDirectory =
|
||||||
|
listOf(File("schemas"), File("app/schemas"))
|
||||||
|
.map { File(it, SCHEMA_PATH) }
|
||||||
|
.firstOrNull { it.isDirectory }
|
||||||
|
?: error("Schema directory not found under schemas/ or app/schemas/")
|
||||||
|
|
||||||
|
private val versions =
|
||||||
|
schemaDirectory
|
||||||
|
.listFiles { file -> file.name.endsWith(".json") }!!
|
||||||
|
.map { it.nameWithoutExtension.toInt() }
|
||||||
|
.sorted()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyExportedVersionIsCoveredByAContiguousMigrationChain() {
|
||||||
|
val chain = MIGRATIONS.sortedBy { it.startVersion }
|
||||||
|
assertEquals(versions.dropLast(1), chain.map { it.startVersion })
|
||||||
|
assertEquals(versions.drop(1), chain.map { it.endVersion })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theMigrationChainProducesTheCurrentSchema() =
|
||||||
|
runTest {
|
||||||
|
driver.open(":memory:").use { migrated ->
|
||||||
|
applySchema(migrated, versions.first())
|
||||||
|
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
|
||||||
|
|
||||||
|
driver.open(":memory:").use { expected ->
|
||||||
|
applySchema(expected, versions.last())
|
||||||
|
assertEquals(schemaShape(expected), schemaShape(migrated))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applySchema(
|
||||||
|
connection: SQLiteConnection,
|
||||||
|
version: Int,
|
||||||
|
) {
|
||||||
|
val entities = readSchema(version).getJSONObject("database").getJSONArray("entities")
|
||||||
|
for (index in 0 until entities.length()) {
|
||||||
|
val entity = entities.getJSONObject(index)
|
||||||
|
val name = entity.getString("tableName")
|
||||||
|
connection.execSQL(entity.getString("createSql").replace("\${TABLE_NAME}", name))
|
||||||
|
val indices = entity.optJSONArray("indices") ?: continue
|
||||||
|
for (i in 0 until indices.length()) {
|
||||||
|
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readSchema(version: Int): JSONObject = JSONObject(File(schemaDirectory, "$version.json").readText())
|
||||||
|
|
||||||
|
private fun schemaShape(connection: SQLiteConnection): Map<String, TableShape> {
|
||||||
|
val tableNames = mutableListOf<String>()
|
||||||
|
connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").use { statement ->
|
||||||
|
while (statement.step()) tableNames += statement.getText(0)
|
||||||
|
}
|
||||||
|
return tableNames
|
||||||
|
.filterNot { it.startsWith("sqlite_") }
|
||||||
|
.associateWith { table -> tableShape(connection, table) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tableShape(
|
||||||
|
connection: SQLiteConnection,
|
||||||
|
table: String,
|
||||||
|
): TableShape {
|
||||||
|
val columns = linkedMapOf<String, String>()
|
||||||
|
connection.prepare("PRAGMA table_info(`$table`)").use { statement ->
|
||||||
|
while (statement.step()) {
|
||||||
|
val default = if (statement.isNull(4)) "" else statement.getText(4)
|
||||||
|
columns[statement.getText(1)] =
|
||||||
|
"${statement.getText(2)}/${statement.getInt(3)}/${statement.getInt(5)}/$default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val foreignKeys = linkedMapOf<String, String>()
|
||||||
|
connection.prepare("PRAGMA foreign_key_list(`$table`)").use { statement ->
|
||||||
|
while (statement.step()) {
|
||||||
|
foreignKeys[statement.getText(3)] =
|
||||||
|
"${statement.getText(2)}.${statement.getText(4)}/${statement.getText(5)}/${statement.getText(6)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val indices = linkedMapOf<String, String>()
|
||||||
|
connection.prepare("PRAGMA index_list(`$table`)").use { statement ->
|
||||||
|
while (statement.step()) {
|
||||||
|
val name = statement.getText(1)
|
||||||
|
val columns = mutableListOf<String>()
|
||||||
|
connection.prepare("PRAGMA index_info(`$name`)").use { info ->
|
||||||
|
while (info.step()) columns += info.getText(2)
|
||||||
|
}
|
||||||
|
indices[name] = "${statement.getInt(2)}:${columns.joinToString(",")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TableShape(columns, foreignKeys, indices)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TableShape(
|
||||||
|
val columns: Map<String, String>,
|
||||||
|
val foreignKeys: Map<String, String>,
|
||||||
|
val indices: Map<String, String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SCHEMA_PATH = "com.subtracks.data.db.SubtracksDatabase"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
|
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.PlaylistSong
|
||||||
|
import com.subtracks.data.model.QueueEntry
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
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 QueueRepositoryTest {
|
||||||
|
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 wholeAlbumEntryResolvesEveryTrackInOrder() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||||
|
|
||||||
|
assertEquals(3, snapshot.size)
|
||||||
|
assertEquals(listOf("s1", "s2", "s3"), resolveAll(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rangeLimitsAnEntryToASlice() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
|
||||||
|
val entry =
|
||||||
|
QueueEntry(
|
||||||
|
position = 0,
|
||||||
|
sourceId = 1,
|
||||||
|
kind = QueueKind.Album,
|
||||||
|
refId = "al1",
|
||||||
|
rangeStart = 1,
|
||||||
|
rangeEnd = 2,
|
||||||
|
)
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(entry))
|
||||||
|
|
||||||
|
assertEquals(2, snapshot.size)
|
||||||
|
assertEquals(listOf("s2", "s3"), resolveAll(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun windowSpansEntryBoundaries() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
|
||||||
|
val entries =
|
||||||
|
listOf(
|
||||||
|
repository.albumEntry(1, "al1"),
|
||||||
|
repository.songEntry(1, "s4"),
|
||||||
|
)
|
||||||
|
val snapshot = repository.snapshotAfter(entries)
|
||||||
|
|
||||||
|
assertEquals(4, snapshot.size)
|
||||||
|
val window = repository.window(snapshot, center = 3, radius = 1)
|
||||||
|
assertEquals(listOf(2L, 3L), window.map { it.position })
|
||||||
|
assertEquals(listOf("s3", "s4"), window.map { it.item.song.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun locateIdentifiesTheEntryBehindEachPosition() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
val entries = listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4"))
|
||||||
|
val snapshot = repository.snapshotAfter(entries)
|
||||||
|
|
||||||
|
val first = snapshot.locate(0)!!
|
||||||
|
assertEquals(QueueKind.Album, first.first.kind)
|
||||||
|
assertEquals("al1", first.first.refId)
|
||||||
|
assertEquals(0L, first.second)
|
||||||
|
|
||||||
|
val last = snapshot.locate(3)!!
|
||||||
|
assertEquals(QueueKind.Song, last.first.kind)
|
||||||
|
assertEquals("s4", last.first.refId)
|
||||||
|
assertEquals(0L, last.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRangePastTheEndIsClamped() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
|
||||||
|
val entry =
|
||||||
|
QueueEntry(
|
||||||
|
position = 0,
|
||||||
|
sourceId = 1,
|
||||||
|
kind = QueueKind.Album,
|
||||||
|
refId = "al1",
|
||||||
|
rangeStart = 1,
|
||||||
|
rangeEnd = 99,
|
||||||
|
)
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(entry))
|
||||||
|
|
||||||
|
assertEquals(2, snapshot.size)
|
||||||
|
assertEquals(listOf("s2", "s3"), resolveAll(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInvertedRangeIsEmpty() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
|
||||||
|
val entry =
|
||||||
|
QueueEntry(
|
||||||
|
position = 0,
|
||||||
|
sourceId = 1,
|
||||||
|
kind = QueueKind.Album,
|
||||||
|
refId = "al1",
|
||||||
|
rangeStart = 2,
|
||||||
|
rangeEnd = 1,
|
||||||
|
)
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(entry))
|
||||||
|
|
||||||
|
assertEquals(0, snapshot.size)
|
||||||
|
assertEquals(null, repository.itemAt(snapshot, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun positionsPastTheEndResolveNothing() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "s4")))
|
||||||
|
|
||||||
|
assertEquals(null, snapshot.locate(1))
|
||||||
|
assertEquals(null, repository.itemAt(snapshot, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playlistIgnoresRowsWithoutABackingSong() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
db.libraryDao().upsertPlaylistSongs(
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "p1", "s1", 0),
|
||||||
|
PlaylistSong(1, "p1", "ghost", 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||||
|
|
||||||
|
assertEquals(1, snapshot.size)
|
||||||
|
assertEquals(listOf("s1"), resolveAll(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEntryForAMissingSongIsEmpty() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(repository.songEntry(1, "ghost")))
|
||||||
|
|
||||||
|
assertEquals(0, snapshot.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playlistEntryFollowsThePlaylistOrder() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
db.libraryDao().upsertPlaylistSongs(
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "p1", "s3", 0),
|
||||||
|
PlaylistSong(1, "p1", "s1", 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||||
|
|
||||||
|
assertEquals(2, snapshot.size)
|
||||||
|
assertEquals(listOf("s3", "s1"), resolveAll(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun replaceDropsThePreviousQueue() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
repository.replace(listOf(repository.albumEntry(1, "al1")))
|
||||||
|
repository.replace(listOf(repository.songEntry(1, "s4")))
|
||||||
|
|
||||||
|
val snapshot = repository.snapshot()
|
||||||
|
|
||||||
|
assertEquals(1, snapshot.size)
|
||||||
|
assertEquals(listOf("s4"), resolveAll(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
|
||||||
|
replace(entries)
|
||||||
|
return snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveAll(snapshot: QueueSnapshot): List<String> =
|
||||||
|
(0 until snapshot.size).mapNotNull { repository.itemAt(snapshot, it)?.song?.id }
|
||||||
|
|
||||||
|
private suspend fun seedLibrary() {
|
||||||
|
db.sourcesDao().upsertSource(
|
||||||
|
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||||
|
)
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
song("s1", "al1", track = 1),
|
||||||
|
song("s2", "al1", track = 2),
|
||||||
|
song("s3", "al1", track = 3),
|
||||||
|
song("s4", "al2", track = 1),
|
||||||
|
song("s5", "al2", track = 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun song(
|
||||||
|
id: String,
|
||||||
|
albumId: String,
|
||||||
|
track: Long,
|
||||||
|
) = Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = id,
|
||||||
|
albumId = albumId,
|
||||||
|
artistId = "ar1",
|
||||||
|
title = "Song $id",
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = track,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
genre = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ class SourceRepositoryTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
repository.close()
|
||||||
db.close()
|
db.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
class FakePlayerConnection(
|
||||||
|
private val handle: FakePlayerHandle,
|
||||||
|
) : PlayerConnection {
|
||||||
|
override fun connect(
|
||||||
|
onConnected: (PlayerHandle) -> Unit,
|
||||||
|
onDisconnected: () -> Unit,
|
||||||
|
) = onConnected(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakePlayerHandle : PlayerHandle {
|
||||||
|
val items = CopyOnWriteArrayList<QueueItem>()
|
||||||
|
val operations = CopyOnWriteArrayList<String>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var duration = 0L
|
||||||
|
|
||||||
|
private val listeners = CopyOnWriteArrayList<PlayerHandle.Listener>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var index = 0
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var idle = true
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var playing = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var wantsToPlay = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var ended = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var buffering = false
|
||||||
|
|
||||||
|
override val itemCount: Int get() = items.size
|
||||||
|
|
||||||
|
override val currentIndex: Int get() = index
|
||||||
|
|
||||||
|
override val isIdle: Boolean get() = idle
|
||||||
|
|
||||||
|
override val isEnded: Boolean get() = ended
|
||||||
|
|
||||||
|
override val isBuffering: Boolean get() = buffering
|
||||||
|
|
||||||
|
override val isPlaying: Boolean get() = playing
|
||||||
|
|
||||||
|
override val playWhenReady: Boolean get() = wantsToPlay
|
||||||
|
|
||||||
|
override val durationMs: Long get() = duration
|
||||||
|
|
||||||
|
override val currentPositionMs: Long get() = 0
|
||||||
|
|
||||||
|
override val currentItem: QueueItem? get() = items.getOrNull(index)
|
||||||
|
|
||||||
|
override fun setWindow(
|
||||||
|
items: List<QueueItem>,
|
||||||
|
startIndex: Int,
|
||||||
|
) {
|
||||||
|
operations += "setWindow(size=${items.size}, start=$startIndex)"
|
||||||
|
this.items.clear()
|
||||||
|
this.items += items
|
||||||
|
index = startIndex
|
||||||
|
idle = true
|
||||||
|
ended = false
|
||||||
|
buffering = false
|
||||||
|
playing = false
|
||||||
|
wantsToPlay = false
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addFirst(item: QueueItem) {
|
||||||
|
operations += "addFirst(${item.id})"
|
||||||
|
items.add(0, item)
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addLast(item: QueueItem) {
|
||||||
|
operations += "addLast(${item.id})"
|
||||||
|
items += item
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeFirst() {
|
||||||
|
operations += "removeFirst"
|
||||||
|
items.removeAt(0)
|
||||||
|
index--
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeLast() {
|
||||||
|
operations += "removeLast"
|
||||||
|
items.removeAt(items.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun prepare() {
|
||||||
|
operations += "prepare"
|
||||||
|
idle = false
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun play() {
|
||||||
|
operations += "play"
|
||||||
|
playing = true
|
||||||
|
wantsToPlay = true
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
operations += "pause"
|
||||||
|
playing = false
|
||||||
|
wantsToPlay = false
|
||||||
|
buffering = false
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
operations += "stop"
|
||||||
|
playing = false
|
||||||
|
wantsToPlay = false
|
||||||
|
buffering = false
|
||||||
|
idle = true
|
||||||
|
ended = false
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clear() {
|
||||||
|
operations += "clear"
|
||||||
|
items.clear()
|
||||||
|
index = 0
|
||||||
|
idle = true
|
||||||
|
ended = false
|
||||||
|
buffering = false
|
||||||
|
playing = false
|
||||||
|
wantsToPlay = false
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToIndex(index: Int) {
|
||||||
|
operations += "seekToIndex($index)"
|
||||||
|
this.index = index
|
||||||
|
ended = false
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekTo(positionMs: Long) {
|
||||||
|
operations += "seekTo($positionMs)"
|
||||||
|
ended = false
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addListener(listener: PlayerHandle.Listener) {
|
||||||
|
listeners += listener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun advanceTo(nextIndex: Int) {
|
||||||
|
index = nextIndex
|
||||||
|
listeners.forEach { it.onTransition() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitEvents() = notifyEvents()
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
playing = false
|
||||||
|
buffering = false
|
||||||
|
ended = true
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fail(message: String) = listeners.forEach { it.onError(message) }
|
||||||
|
|
||||||
|
fun startBuffering() {
|
||||||
|
idle = false
|
||||||
|
ended = false
|
||||||
|
buffering = true
|
||||||
|
playing = false
|
||||||
|
wantsToPlay = true
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun becomeReady() {
|
||||||
|
buffering = false
|
||||||
|
ended = false
|
||||||
|
idle = false
|
||||||
|
playing = true
|
||||||
|
wantsToPlay = true
|
||||||
|
notifyEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyEvents() = listeners.forEach { it.onEvents() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
package com.subtracks.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.QueueEntry
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.repo.QueueRepository
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PlaybackControllerTest {
|
||||||
|
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
|
|
||||||
|
private lateinit var db: SubtracksDatabase
|
||||||
|
private lateinit var sources: SourceRepository
|
||||||
|
private lateinit var queues: QueueRepository
|
||||||
|
private lateinit var handle: FakePlayerHandle
|
||||||
|
private lateinit var controller: PlaybackController
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
db =
|
||||||
|
Room
|
||||||
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
|
.setDriver(BundledSQLiteDriver())
|
||||||
|
.build()
|
||||||
|
sources = SourceRepository(db, OkHttpClient())
|
||||||
|
queues = QueueRepository(db)
|
||||||
|
handle = FakePlayerHandle()
|
||||||
|
controller = PlaybackController(sources, queues, FakePlayerConnection(handle), dispatcher = dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
controller.close()
|
||||||
|
sources.close()
|
||||||
|
db.close()
|
||||||
|
dispatcher.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playingAnAlbumLoadsAWindowAroundTheStart() {
|
||||||
|
seedAlbum(60, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 30)
|
||||||
|
await { handle.items.isNotEmpty() }
|
||||||
|
|
||||||
|
assertEquals(51, handle.itemCount)
|
||||||
|
assertEquals(25, handle.currentIndex)
|
||||||
|
assertEquals("s31", handle.currentItem?.id)
|
||||||
|
assertEquals(listOf("prepare", "play"), handle.operations.takeLast(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun advancingShiftsTheWindowAndKeepsItBounded() {
|
||||||
|
seedAlbum(100, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
|
||||||
|
repeat(60) { step ->
|
||||||
|
handle.advanceTo(handle.currentIndex + 1)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s${step + 2}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(handle.itemCount <= 51)
|
||||||
|
assertEquals(
|
||||||
|
"s61",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun togglingPlayOnARestoredQueuePreparesBeforePlaying() {
|
||||||
|
seedAlbum(5, sourceId = 1)
|
||||||
|
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
|
||||||
|
|
||||||
|
controller.connect()
|
||||||
|
await { handle.items.isNotEmpty() }
|
||||||
|
handle.operations.clear()
|
||||||
|
|
||||||
|
controller.togglePlayPause()
|
||||||
|
await { handle.operations.contains("play") }
|
||||||
|
|
||||||
|
assertEquals(listOf("prepare", "play"), handle.operations)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nextAdvancesTheCursor() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.next()
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s2"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(handle.operations.contains("seekToIndex(1)"))
|
||||||
|
assertTrue(controller.state.value.hasPrevious)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDurationDoesNotDropToZeroWhenTheNextTrackIsStillLoading() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.duration = 284_000
|
||||||
|
handle.emitEvents()
|
||||||
|
await { controller.state.value.durationMs == 284_000L }
|
||||||
|
|
||||||
|
handle.duration = 0
|
||||||
|
controller.next()
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s2"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(284_000L, controller.state.value.durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theLastTrackReportsNoNext() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 2)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s3"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(controller.state.value.hasNext)
|
||||||
|
assertTrue(controller.state.value.hasPrevious)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePlayerReportsStoppedWhenTheQueueEnds() {
|
||||||
|
seedAlbum(1, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.isPlaying }
|
||||||
|
|
||||||
|
handle.finish()
|
||||||
|
handle.emitEvents()
|
||||||
|
await { !controller.state.value.isPlaying }
|
||||||
|
|
||||||
|
assertFalse(controller.state.value.isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun togglingPlayAfterTheQueueEndedRestartsTheCurrentTrack() {
|
||||||
|
seedAlbum(1, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.isPlaying }
|
||||||
|
|
||||||
|
handle.finish()
|
||||||
|
await { !controller.state.value.isPlaying }
|
||||||
|
|
||||||
|
handle.operations.clear()
|
||||||
|
controller.togglePlayPause()
|
||||||
|
await { controller.state.value.isPlaying }
|
||||||
|
|
||||||
|
assertEquals(listOf("seekToIndex(0)", "play"), handle.operations)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tappingPauseWhileBufferingPauses() {
|
||||||
|
seedAlbum(1, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.isPlaying }
|
||||||
|
|
||||||
|
handle.startBuffering()
|
||||||
|
handle.operations.clear()
|
||||||
|
controller.togglePlayPause()
|
||||||
|
await { handle.operations.contains("pause") }
|
||||||
|
|
||||||
|
assertEquals(listOf("pause"), handle.operations)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bufferingIsOnlyReportedAfterItPersists() {
|
||||||
|
seedAlbum(1, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.isPlaying }
|
||||||
|
|
||||||
|
handle.startBuffering()
|
||||||
|
Thread.sleep(300)
|
||||||
|
assertFalse(controller.state.value.isBuffering)
|
||||||
|
|
||||||
|
Thread.sleep(1_200)
|
||||||
|
assertTrue(controller.state.value.isBuffering)
|
||||||
|
|
||||||
|
handle.becomeReady()
|
||||||
|
await { !controller.state.value.isBuffering }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playbackFailuresAreSurfacedUntilTheNextTrack() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.fail("ERROR_CODE_IO_UNSPECIFIED")
|
||||||
|
await { controller.state.value.error == "ERROR_CODE_IO_UNSPECIFIED" }
|
||||||
|
|
||||||
|
handle.advanceTo(handle.currentIndex + 1)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s2"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(null, controller.state.value.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startingAnEmptyQueueStopsThePlayer() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { handle.items.isNotEmpty() }
|
||||||
|
|
||||||
|
controller.playAlbum(1, "missing", 0)
|
||||||
|
await { handle.operations.contains("clear") }
|
||||||
|
|
||||||
|
assertTrue(handle.items.isEmpty())
|
||||||
|
assertEquals(null, controller.state.value.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun restoringAQueueFromAnotherSourceStopsIt() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
seedAlbum(3, sourceId = 2)
|
||||||
|
runBlocking {
|
||||||
|
queues.replace(listOf(QueueEntry(position = 0, sourceId = 2, kind = QueueKind.Album, refId = "al1")))
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.connect()
|
||||||
|
await { handle.operations.contains("clear") }
|
||||||
|
|
||||||
|
assertFalse(handle.operations.any { it.startsWith("setWindow") })
|
||||||
|
assertTrue(handle.items.isEmpty())
|
||||||
|
assertEquals(null, controller.state.value.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stateCarriesTheQueueContext() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.item != null }
|
||||||
|
|
||||||
|
assertEquals(QueueContext(QueueKind.Album, "al1"), controller.state.value.context)
|
||||||
|
assertTrue(controller.state.value.isPlaying)
|
||||||
|
assertTrue(controller.state.value.hasNext)
|
||||||
|
assertFalse(controller.state.value.hasPrevious)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedAlbum(
|
||||||
|
count: Int,
|
||||||
|
sourceId: Long,
|
||||||
|
) {
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(
|
||||||
|
Source(sourceId, "source $sourceId", "http://localhost:$sourceId", sourceId == 1L, sourceId),
|
||||||
|
)
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
(1..count).map { track ->
|
||||||
|
Song(
|
||||||
|
sourceId = sourceId,
|
||||||
|
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 playback state", predicate())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -32,11 +34,15 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.playback.PlaybackState
|
||||||
|
import com.subtracks.playback.QueueItem
|
||||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||||
import com.subtracks.ui.library.AlbumDetailScreen
|
import com.subtracks.ui.library.AlbumDetailScreen
|
||||||
import com.subtracks.ui.library.LibraryScreen
|
import com.subtracks.ui.library.LibraryScreen
|
||||||
import com.subtracks.ui.library.LibraryTab
|
import com.subtracks.ui.library.LibraryTab
|
||||||
import com.subtracks.ui.library.PlaylistDetailScreen
|
import com.subtracks.ui.library.PlaylistDetailScreen
|
||||||
|
import com.subtracks.ui.playback.MiniPlayer
|
||||||
|
import com.subtracks.ui.playback.NowPlayingScreen
|
||||||
import com.subtracks.ui.settings.AddSourceScreen
|
import com.subtracks.ui.settings.AddSourceScreen
|
||||||
import com.subtracks.ui.settings.AddSourceState
|
import com.subtracks.ui.settings.AddSourceState
|
||||||
import com.subtracks.ui.settings.SettingsScreen
|
import com.subtracks.ui.settings.SettingsScreen
|
||||||
@@ -112,6 +118,8 @@ class AppScreenshotTest {
|
|||||||
songs = Fixtures.songs,
|
songs = Fixtures.songs,
|
||||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||||
onBack = {},
|
onBack = {},
|
||||||
|
onSongClick = {},
|
||||||
|
playingSongId = Fixtures.songs.first().id,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -129,6 +137,7 @@ class AppScreenshotTest {
|
|||||||
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(),
|
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(),
|
||||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||||
onBack = {},
|
onBack = {},
|
||||||
|
onSongClick = {},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -182,6 +191,46 @@ class AppScreenshotTest {
|
|||||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/add_source.png")
|
composeRule.onRoot().captureRoboImage("src/test/screenshots/add_source.png")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nowPlaying() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
NowPlayingScreen(
|
||||||
|
state = Fixtures.playbackState(),
|
||||||
|
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
||||||
|
onBack = {},
|
||||||
|
onPlayPause = {},
|
||||||
|
onNext = {},
|
||||||
|
onPrevious = {},
|
||||||
|
onSeek = {},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
awaitText("Everything In Its Right Place")
|
||||||
|
composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun miniPlayer() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
MiniPlayer(
|
||||||
|
state = Fixtures.playbackState(),
|
||||||
|
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
||||||
|
onExpand = {},
|
||||||
|
onPlayPause = {},
|
||||||
|
onNext = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
awaitText("Everything In Its Right Place")
|
||||||
|
composeRule.onRoot().captureRoboImage("src/test/screenshots/mini_player.png")
|
||||||
|
}
|
||||||
|
|
||||||
private fun setLibraryContent(tab: LibraryTab) {
|
private fun setLibraryContent(tab: LibraryTab) {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
@@ -269,6 +318,23 @@ private object Fixtures {
|
|||||||
playlist("pl-one-song", "One Song", 1),
|
playlist("pl-one-song", "One Song", 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun playbackState() =
|
||||||
|
PlaybackState(
|
||||||
|
item =
|
||||||
|
QueueItem(
|
||||||
|
id = "s-eiirp",
|
||||||
|
title = "Everything In Its Right Place",
|
||||||
|
artist = "Radiohead",
|
||||||
|
album = "Kid A",
|
||||||
|
coverArtId = "art-al-kid-a",
|
||||||
|
),
|
||||||
|
isPlaying = true,
|
||||||
|
positionMs = 62_000,
|
||||||
|
durationMs = 251_000,
|
||||||
|
hasNext = true,
|
||||||
|
hasPrevious = false,
|
||||||
|
)
|
||||||
|
|
||||||
private fun artist(
|
private fun artist(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user