add queue, migration and playback controller tests and goldens

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent ed9d74d5ae
commit 7abf01c5ce
6 changed files with 973 additions and 0 deletions
@@ -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,