mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +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
|
||||
fun tearDown() {
|
||||
repository.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.Paint
|
||||
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.runtime.remember
|
||||
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.SongListItem
|
||||
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.AlbumDetailScreen
|
||||
import com.subtracks.ui.library.LibraryScreen
|
||||
import com.subtracks.ui.library.LibraryTab
|
||||
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.AddSourceState
|
||||
import com.subtracks.ui.settings.SettingsScreen
|
||||
@@ -112,6 +118,8 @@ class AppScreenshotTest {
|
||||
songs = Fixtures.songs,
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
onBack = {},
|
||||
onSongClick = {},
|
||||
playingSongId = Fixtures.songs.first().id,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -129,6 +137,7 @@ class AppScreenshotTest {
|
||||
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
onBack = {},
|
||||
onSongClick = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -182,6 +191,46 @@ class AppScreenshotTest {
|
||||
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) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
@@ -269,6 +318,23 @@ private object Fixtures {
|
||||
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(
|
||||
id: String,
|
||||
name: String,
|
||||
|
||||
Reference in New Issue
Block a user