From 7abf01c5cee85c95a92413b2815c29b259cc6fa8 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:35:03 +0000 Subject: [PATCH] add queue, migration and playback controller tests and goldens --- .../com/subtracks/data/db/MigrationsTest.kt | 120 +++++++ .../data/repo/QueueRepositoryTest.kt | 258 ++++++++++++++ .../data/repo/SourceRepositoryTest.kt | 1 + .../com/subtracks/playback/FakePlayer.kt | 194 ++++++++++ .../playback/PlaybackControllerTest.kt | 334 ++++++++++++++++++ .../com/subtracks/ui/AppScreenshotTest.kt | 66 ++++ 6 files changed, 973 insertions(+) create mode 100644 app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt create mode 100644 app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt diff --git a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt new file mode 100644 index 00000000..26493dd9 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt @@ -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 { + val tableNames = mutableListOf() + 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() + 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() + 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() + connection.prepare("PRAGMA index_list(`$table`)").use { statement -> + while (statement.step()) { + val name = statement.getText(1) + val columns = mutableListOf() + 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, + val foreignKeys: Map, + val indices: Map, + ) + + private companion object { + const val SCHEMA_PATH = "com.subtracks.data.db.SubtracksDatabase" + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt new file mode 100644 index 00000000..50f13e4b --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -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() + 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): QueueSnapshot { + replace(entries) + return snapshot() + } + + private suspend fun resolveAll(snapshot: QueueSnapshot): List = + (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, + ) +} diff --git a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt index fdd59df6..3ea478ff 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt @@ -33,6 +33,7 @@ class SourceRepositoryTest { @After fun tearDown() { + repository.close() db.close() } diff --git a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt new file mode 100644 index 00000000..4e40cd32 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt @@ -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() + val operations = CopyOnWriteArrayList() + + @Volatile + var duration = 0L + + private val listeners = CopyOnWriteArrayList() + + @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, + 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() } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt new file mode 100644 index 00000000..987a4a0b --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -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() + 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()) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 560a360e..5d16820d 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -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,