add screenshot goldens and the title collapse gesture test

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 87cab9d805
commit 177e5ad108
9 changed files with 901 additions and 235 deletions
@@ -20,87 +20,104 @@ class SubsonicSourceIntegrationTest(
@Before
fun setUp() {
val client = SubsonicClient(
baseUrl = server.baseUrl.toHttpUrl(),
username = server.username,
password = server.password,
useTokenAuth = false,
http = OkHttpClient(),
)
val client =
SubsonicClient(
baseUrl = server.baseUrl.toHttpUrl(),
username = server.username,
password = server.password,
useTokenAuth = false,
http = OkHttpClient(),
)
source = SubsonicSource(1, client)
}
@Test
fun ping() = runBlocking {
source.ping()
}
fun ping() =
runBlocking {
source.ping()
}
@Test
fun artists() = runBlocking {
val artists = source.getArtists()
fun artists() =
runBlocking {
val artists = source.getArtists()
assertEquals(2, artists.size)
assertNotNull(artists.first { it.name == "Ugress" }.starred)
assertNull(artists.first { it.name == "Brad Sucks" }.starred)
}
assertEquals(2, artists.size)
assertNotNull(artists.first { it.name == "Ugress" }.starred)
assertNull(artists.first { it.name == "Brad Sucks" }.starred)
}
@Test
fun albums() = runBlocking {
val albums = source.getAlbums()
fun albums() =
runBlocking {
val albums = source.getAlbums()
assertEquals(3, albums.size)
assertEquals(3, albums.size)
val kosmo = albums.first { it.name == "Kosmonaut" }
assertTrue(kosmo.id.isNotEmpty())
assertTrue(kosmo.artistId!!.isNotEmpty())
assertEquals("Ugress", kosmo.albumArtist)
assertEquals(2006L, kosmo.year)
assertEquals("Electronic", kosmo.genre)
assertNotNull(kosmo.coverArt)
assertNotNull(kosmo.starred)
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
assertTrue(kosmo.frequentRank != null || kosmo.recentRank != null)
val kosmo = albums.first { it.name == "Kosmonaut" }
assertTrue(kosmo.id.isNotEmpty())
assertTrue(kosmo.artistId!!.isNotEmpty())
assertEquals("Ugress", kosmo.albumArtist)
assertEquals(2006L, kosmo.year)
assertEquals("Electronic", kosmo.genre)
assertNotNull(kosmo.coverArt)
assertNotNull(kosmo.starred)
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
assertTrue(kosmo.frequentRank != null || kosmo.recentRank != null)
assertNull(albums.first { it.name == "Retroconnaissance EP" }.starred)
assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred)
}
assertNull(albums.first { it.name == "Retroconnaissance EP" }.starred)
assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred)
}
@Test
fun songs() = runBlocking {
assertEquals(20, source.getSongs().size)
}
fun songs() =
runBlocking {
assertEquals(20, source.getSongs().size)
}
@Test
fun playlists() = runBlocking {
val playlists = source.getPlaylists()
fun playlists() =
runBlocking {
val playlists = source.getPlaylists()
assertEquals(1, playlists.size)
assertEquals(7, source.getPlaylistSongs(playlists).size)
}
assertEquals(1, playlists.size)
assertEquals(7, source.getPlaylistSongs(playlists).size)
}
@Test
fun albumArtistRelation() = runBlocking {
val artists = source.getArtists()
val albums = source.getAlbums()
fun albumArtistRelation() =
runBlocking {
val artists = source.getArtists()
val albums = source.getAlbums()
val ugressAlbums = albums.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
.map { it.name }
.sorted()
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
val ugressAlbums =
albums
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
.map { it.name }
.sorted()
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
val bradAlbums = albums.filter { it.artistId == artists.first { a -> a.name == "Brad Sucks" }.id }
.map { it.name }
assertEquals(listOf("I Don't Know What I'm Doing"), bradAlbums)
}
val bradAlbums =
albums
.filter { it.artistId == artists.first { a -> a.name == "Brad Sucks" }.id }
.map { it.name }
assertEquals(listOf("I Don't Know What I'm Doing"), bradAlbums)
}
data class Server(val name: String, val baseUrl: String, val username: String, val password: String)
data class Server(
val name: String,
val baseUrl: String,
val username: String,
val password: String,
)
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun servers(): List<Array<Any>> = listOf(
arrayOf(Server("navidrome", "http://localhost:4533/", "admin", "password")),
arrayOf(Server("gonic", "http://localhost:4747/", "admin", "admin")),
)
fun servers(): List<Array<Any>> =
listOf(
arrayOf(Server("navidrome", "http://localhost:4533/", "admin", "password")),
arrayOf(Server("gonic", "http://localhost:4747/", "admin", "admin")),
)
}
}
@@ -0,0 +1,58 @@
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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
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 SourceRepositoryTest {
private lateinit var db: SubtracksDatabase
private lateinit var repository: SourceRepository
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
repository = SourceRepository(db, OkHttpClient())
}
@After
fun tearDown() {
db.close()
}
@Test
fun addingASourceMakesItTheSingleActiveOne() =
runTest {
repository.addSource("first", "http://a.example", "u", "p", true)
val firstId = repository.activeSourceId().first()
repository.addSource("second", "http://b.example", "u", "p", true)
assertEquals(1, repository.sources().first().count { it.isActive })
assertEquals("second", repository.activeConfig().first()?.name)
assertEquals(
"http://a.example/",
repository
.sources()
.first()
.first { it.id == firstId }
.address,
)
}
}
@@ -25,13 +25,14 @@ class SubsonicClientTest {
server.shutdown()
}
private fun client(tokenAuth: Boolean) = SubsonicClient(
baseUrl = server.url("/"),
username = "guest",
password = "secret",
useTokenAuth = tokenAuth,
http = OkHttpClient(),
)
private fun client(tokenAuth: Boolean) =
SubsonicClient(
baseUrl = server.url("/"),
username = "guest",
password = "secret",
useTokenAuth = tokenAuth,
http = OkHttpClient(),
)
@Test
fun tokenAuthSendsSaltAndHash() {
@@ -26,36 +26,47 @@ class SubsonicSourceTest {
}
@Test
fun getSongsFallsBackToAlbumsWhenEmptySearchUnsupported() = runBlocking {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse = when {
request.path?.startsWith("/rest/search3.view") == true ->
MockResponse().setBody(failed(10, "Required parameter is missing"))
fun getSongsFallsBackToAlbumsWhenEmptySearchUnsupported() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/search3.view") == true -> {
MockResponse().setBody(failed(10, "Required parameter is missing"))
}
request.path?.startsWith("/rest/getAlbumList2.view") == true ->
MockResponse().setBody(ALBUM_LIST)
request.path?.startsWith("/rest/getAlbumList2.view") == true -> {
MockResponse().setBody(ALBUM_LIST)
}
request.path?.startsWith("/rest/getAlbum.view") == true ->
MockResponse().setBody(ALBUM)
request.path?.startsWith("/rest/getAlbum.view") == true -> {
MockResponse().setBody(ALBUM)
}
else -> MockResponse().setResponseCode(404)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
val source = SubsonicSource(1, client())
assertEquals(listOf("s1", "s2"), source.getSongs().map { it.id })
}
val source = SubsonicSource(1, client())
assertEquals(listOf("s1", "s2"), source.getSongs().map { it.id })
}
private fun client() =
SubsonicClient(
baseUrl = server.url("/"),
username = "u",
password = "p",
useTokenAuth = false,
http = OkHttpClient(),
)
private fun client() = SubsonicClient(
baseUrl = server.url("/"),
username = "u",
password = "p",
useTokenAuth = false,
http = OkHttpClient(),
)
private fun failed(code: Int, message: String) =
"<subsonic-response status=\"failed\"><error code=\"$code\" message=\"$message\"/></subsonic-response>"
private fun failed(
code: Int,
message: String,
) = "<subsonic-response status=\"failed\"><error code=\"$code\" message=\"$message\"/></subsonic-response>"
private companion object {
const val ALBUM_LIST =
@@ -1,27 +1,30 @@
package com.subtracks.data.source.subsonic
import javax.xml.parsers.DocumentBuilderFactory
import org.junit.Assert.assertEquals
import org.junit.Test
import org.w3c.dom.Document
import javax.xml.parsers.DocumentBuilderFactory
class SubsonicXmlTest {
private fun parse(xml: String): Document =
DocumentBuilderFactory.newInstance().newDocumentBuilder()
DocumentBuilderFactory
.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream(Charsets.UTF_8))
@Test
fun mapsArtists() {
val document = parse(
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"/>
<artist id="ar2" name="Portishead" albumCount="3"/>
</artists>
</subsonic-response>
""".trimIndent(),
)
val document =
parse(
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"/>
<artist id="ar2" name="Portishead" albumCount="3"/>
</artists>
</subsonic-response>
""".trimIndent(),
)
val artists = SubsonicXml.artists(7, document)
@@ -35,16 +38,17 @@ class SubsonicXmlTest {
@Test
fun mapsAlbumList() {
val document = parse(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="OK Computer" artist="Radiohead" artistId="ar1" created="1997-05-21T00:00:00.000Z" songCount="12" coverArt="al1"/>
<album id="al2" name="Kid A" artist="Radiohead" artistId="ar1" created="2000-10-02T00:00:00.000Z" songCount="10" coverArt="al2"/>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
val document =
parse(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="OK Computer" artist="Radiohead" artistId="ar1" created="1997-05-21T00:00:00.000Z" songCount="12" coverArt="al1"/>
<album id="al2" name="Kid A" artist="Radiohead" artistId="ar1" created="2000-10-02T00:00:00.000Z" songCount="10" coverArt="al2"/>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
val albums = SubsonicXml.albums(1, document)
@@ -68,18 +72,19 @@ class SubsonicXmlTest {
@Test
fun mapsSongsAndPlaylistEntries() {
val document = parse(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="sg2" title="Idioteque" artist="Radiohead" album="Kid A" albumId="al2" artistId="ar1" duration="300" track="8" discNumber="1"/>
</searchResult3>
<playlist id="pl1" name="Favourites" songCount="1" created="2021-02-03T04:05:06.000Z">
<entry id="sg1" title="Everything In Its Right Place" artist="Radiohead" album="Kid A" albumId="al2" artistId="ar1" duration="251" track="1" discNumber="1"/>
</playlist>
</subsonic-response>
""".trimIndent(),
)
val document =
parse(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="sg2" title="Idioteque" artist="Radiohead" album="Kid A" albumId="al2" artistId="ar1" duration="300" track="8" discNumber="1"/>
</searchResult3>
<playlist id="pl1" name="Favourites" songCount="1" created="2021-02-03T04:05:06.000Z">
<entry id="sg1" title="Everything In Its Right Place" artist="Radiohead" album="Kid A" albumId="al2" artistId="ar1" duration="251" track="1" discNumber="1"/>
</playlist>
</subsonic-response>
""".trimIndent(),
)
val songs = SubsonicXml.songs(1, document)
val entries = SubsonicXml.playlistSongs(1, "pl1", document)
@@ -1,9 +1,10 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.test.core.app.ApplicationProvider
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.Album
@@ -13,7 +14,6 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.source.MusicSource
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
@@ -28,9 +28,11 @@ class SyncServiceTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
}
@After
@@ -39,69 +41,131 @@ class SyncServiceTest {
}
@Test
fun syncInsertsAndPrunesRemovedRows() = runTest {
insertSource()
val source = FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1")),
songs = listOf(song("s1"), song("s2")),
playlists = listOf(playlist("p1")),
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
)
fun syncInsertsAndPrunesRemovedRows() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1")),
songs = listOf(song("s1"), song("s2")),
playlists = listOf(playlist("p1")),
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
)
SyncService(db, source).sync()
SyncService(db, source).sync()
assertEquals(2, db.libraryDao().artists(1).first().size)
assertEquals(2, db.libraryDao().songs(1).first().size)
assertEquals(1, db.libraryDao().songsByPlaylist(1, "p1").first().size)
assertEquals(
2,
db
.libraryDao()
.artists(1)
.allRows()
.size,
)
assertEquals(
2,
db
.libraryDao()
.songs(1)
.allRows()
.size,
)
assertEquals(
1,
db
.libraryDao()
.playlistSongs(1, "p1")
.allRows()
.size,
)
source.artists = listOf(artist("a1"))
source.songs = listOf(song("s1"))
source.artists = listOf(artist("a1"))
source.songs = listOf(song("s1"))
SyncService(db, source).sync()
SyncService(db, source).sync()
assertEquals(listOf("a1"), db.libraryDao().artists(1).first().map { it.id })
assertEquals(listOf("s1"), db.libraryDao().songs(1).first().map { it.id })
}
assertEquals(
listOf("a1"),
db
.libraryDao()
.artists(1)
.allRows()
.map { it.id },
)
assertEquals(
listOf("s1"),
db
.libraryDao()
.songs(1)
.allRows()
.map { it.song.id },
)
}
@Test
fun searchIndexMatchesSubstrings() = runTest {
insertSource()
val source = FakeMusicSource(
artists = listOf(artist("a1", name = "Radiohead")),
songs = listOf(song("s1")),
)
fun searchIndexMatchesSubstrings() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", name = "Radiohead")),
songs = listOf(song("s1")),
)
SyncService(db, source).sync()
SyncService(db, source).sync()
val results = db.searchDao().search("1", "adio", 10)
assertEquals(1, results.size)
assertEquals("a1", results.single().itemId)
assertEquals("artist", results.single().type)
}
val results = db.searchDao().search("1", "adio", 10)
assertEquals(1, results.size)
assertEquals("a1", results.single().itemId)
assertEquals("artist", results.single().type)
}
@Test
fun searchIgnoresTooShortQueries() = runTest {
insertSource()
SyncService(db, FakeMusicSource(artists = listOf(artist("a1", name = "Radiohead")))).sync()
fun searchIgnoresTooShortQueries() =
runTest {
insertSource()
SyncService(db, FakeMusicSource(artists = listOf(artist("a1", name = "Radiohead")))).sync()
assertEquals(0, db.searchDao().search("1", "ad", 10).size)
assertEquals(0, db.searchDao().search("1", "", 10).size)
assertEquals(1, db.searchDao().search("1", "adi", 10).size)
}
assertEquals(0, db.searchDao().search("1", "ad", 10).size)
assertEquals(0, db.searchDao().search("1", "", 10).size)
assertEquals(1, db.searchDao().search("1", "adi", 10).size)
}
@Test
fun syncUpdatesExistingRows() = runTest {
insertSource()
val source = FakeMusicSource(artists = listOf(artist("a1", name = "Old")))
fun syncUpdatesExistingRows() =
runTest {
insertSource()
val source = FakeMusicSource(artists = listOf(artist("a1", name = "Old")))
SyncService(db, source).sync()
assertEquals("Old", db.libraryDao().artists(1).first().single().name)
SyncService(db, source).sync()
assertEquals(
"Old",
db
.libraryDao()
.artists(1)
.allRows()
.single()
.name,
)
source.artists = listOf(artist("a1", name = "New"))
SyncService(db, source).sync()
source.artists = listOf(artist("a1", name = "New"))
SyncService(db, source).sync()
assertEquals("New", db.libraryDao().artists(1).first().single().name)
assertEquals(
"New",
db
.libraryDao()
.artists(1)
.allRows()
.single()
.name,
)
}
private suspend fun <T : Any> PagingSource<Int, T>.allRows(): List<T> {
val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false))
return (page as PagingSource.LoadResult.Page).data
}
private suspend fun insertSource() {
@@ -110,39 +174,43 @@ class SyncServiceTest {
)
}
private fun artist(id: String, name: String = "Artist $id") =
Artist(sourceId = 1, id = id, name = name, albumCount = 1, starred = null)
private fun artist(
id: String,
name: String = "Artist $id",
) = Artist(sourceId = 1, id = id, name = name, albumCount = 1, starred = null)
private fun album(id: String) = Album(
sourceId = 1,
id = id,
artistId = "a1",
name = "Album $id",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
frequentRank = null,
recentRank = null,
)
private fun album(id: String) =
Album(
sourceId = 1,
id = id,
artistId = "a1",
name = "Album $id",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
frequentRank = null,
recentRank = null,
)
private fun song(id: String) = Song(
sourceId = 1,
id = id,
albumId = "al1",
artistId = "a1",
title = "Song $id",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
)
private fun song(id: String) =
Song(
sourceId = 1,
id = id,
albumId = "al1",
artistId = "a1",
title = "Song $id",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
)
private fun playlist(id: String) =
Playlist(sourceId = 1, id = id, name = "Playlist $id", comment = null, coverArt = null, songCount = 1, created = 0)
@@ -0,0 +1,431 @@
package com.subtracks.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.Image
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.asImage
import coil3.test.FakeImageLoaderEngine
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
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.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.settings.AddSourceScreen
import com.subtracks.ui.settings.AddSourceState
import com.subtracks.ui.settings.SettingsScreen
import com.subtracks.ui.theme.SubtracksTheme
import kotlinx.coroutines.flow.flowOf
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import kotlin.random.Random
@OptIn(DelicateCoilApi::class)
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class AppScreenshotTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Before
fun installImageLoader() {
SingletonImageLoader.reset()
SingletonImageLoader.setUnsafe(
ImageLoader
.Builder(ApplicationProvider.getApplicationContext<Context>())
.components { add(Fixtures.artEngine()) }
.build(),
)
}
@After
fun resetImageLoader() {
SingletonImageLoader.reset()
}
@Test
fun libraryAlbums() {
setLibraryContent(LibraryTab.Albums)
awaitTag(ALBUM_COVER_TAG)
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_albums.png")
}
@Test
fun libraryArtists() {
setLibraryContent(LibraryTab.Artists)
awaitText("Radiohead")
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_artists.png")
}
@Test
fun librarySongs() {
setLibraryContent(LibraryTab.Songs)
awaitText("Everything In Its Right Place")
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_songs.png")
}
@Test
fun libraryPlaylists() {
setLibraryContent(LibraryTab.Playlists)
awaitText("Late Night")
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_playlists.png")
}
@Test
fun albumDetail() {
composeRule.setContent {
SubtracksTheme {
AlbumDetailScreen(
album = Fixtures.albums.first(),
songs = Fixtures.songs,
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
modifier = Modifier.fillMaxSize(),
)
}
}
awaitText("Everything In Its Right Place")
composeRule.onRoot().captureRoboImage("src/test/screenshots/album_detail.png")
}
@Test
fun playlistDetail() {
composeRule.setContent {
SubtracksTheme {
PlaylistDetailScreen(
playlist = Fixtures.playlists.first(),
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
modifier = Modifier.fillMaxSize(),
)
}
}
awaitText("Everything In Its Right Place")
composeRule.onRoot().captureRoboImage("src/test/screenshots/playlist_detail.png")
}
@Test
fun settings() {
composeRule.setContent {
SubtracksTheme {
SettingsScreen(
sources = Fixtures.sources,
activeSourceId = 1,
onSelectSource = {},
onDeleteSource = {},
onAddServer = {},
onBack = {},
)
}
}
awaitText("Navidrome")
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
}
@Test
fun addSource() {
composeRule.setContent {
SubtracksTheme {
AddSourceScreen(
state =
AddSourceState(
name = "Home",
address = "https://music.example.com",
username = "austin",
password = "hunter2",
),
onNameChange = {},
onAddressChange = {},
onUsernameChange = {},
onPasswordChange = {},
onTokenAuthChange = {},
onTest = {},
onSave = {},
onBack = null,
)
}
}
awaitText("Add server")
composeRule.onRoot().captureRoboImage("src/test/screenshots/add_source.png")
}
private fun setLibraryContent(tab: LibraryTab) {
composeRule.setContent {
SubtracksTheme {
LibraryScreen(
selectedTab = tab,
onTabSelected = {},
albums = remember { flowOf(PagingData.from(Fixtures.albums)) }.collectAsLazyPagingItems(),
artists = remember { flowOf(PagingData.from(Fixtures.artists)) }.collectAsLazyPagingItems(),
songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(),
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) }.collectAsLazyPagingItems(),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onAlbumClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
)
}
}
}
private fun awaitText(text: String) {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
}
private fun awaitTag(tag: String) {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
}
private object Fixtures {
val sources =
listOf(
Source(id = 1, name = "Navidrome", address = "https://navidrome.homelab.example.com:4533", isActive = true, createdAt = 0),
Source(id = 2, name = "gonic", address = "http://192.168.1.10:4747", isActive = false, createdAt = 1),
)
val artists =
listOf(
artist("ar-radiohead", "Radiohead", 2),
artist("ar-portishead", "Portishead", 1),
artist("ar-massive", "Massive Attack", 1),
artist("ar-aphex", "Aphex Twin", 1),
artist("ar-boc", "Boards of Canada", 2),
)
val albums =
listOf(
album("al-kid-a", "ar-radiohead", "Kid A", "Radiohead", 2000, 10),
album("al-amnesiac", "ar-radiohead", "Amnesiac", "Radiohead", 2001, 11),
album("al-dummy", "ar-portishead", "Dummy", "Portishead", 1994, 11),
album("al-mezzanine", "ar-massive", "Mezzanine", "Massive Attack", 1998, 11),
album("al-saw", "ar-aphex", "Selected Ambient Works 85-92", "Aphex Twin", 1992, 13),
album("al-mhtrtc", "ar-boc", "Music Has the Right to Children", "Boards of Canada", 1998, 17),
album("al-geogaddi", "ar-boc", "Geogaddi", "Boards of Canada", 2002, 23),
)
val songs =
listOf(
song("s-eiirp", "al-kid-a", "ar-radiohead", "Everything In Its Right Place", "Kid A", "Radiohead", 1, 251),
song("s-kid-a", "al-kid-a", "ar-radiohead", "Kid A", "Kid A", "Radiohead", 2, 274),
song("s-pyramid", "al-amnesiac", "ar-radiohead", "Pyramid Song", "Amnesiac", "Radiohead", 2, 289),
song("s-roads", "al-dummy", "ar-portishead", "Roads", "Dummy", "Portishead", 9, 302),
song("s-teardrop", "al-mezzanine", "ar-massive", "Teardrop", "Mezzanine", "Massive Attack", 2, 328),
song("s-xtal", "al-saw", "ar-aphex", "Xtal", "Selected Ambient Works 85-92", "Aphex Twin", 1, 293),
song("s-roygbiv", "al-mhtrtc", "ar-boc", "Roygbiv", "Music Has the Right to Children", "Boards of Canada", 3, 149),
song("s-gyroscope", "al-geogaddi", "ar-boc", "Gyroscope", "Geogaddi", "Boards of Canada", 5, 216),
)
val songItems =
songs.map { song ->
SongListItem(song = song, coverArt = albums.firstOrNull { it.id == song.albumId }?.coverArt)
}
val playlists =
listOf(
playlist("pl-late-night", "Late Night", 42),
playlist("pl-road-trip", "Road Trip", 63),
playlist("pl-rainy-day", "Rainy Day", 25),
playlist("pl-focus", "Focus", 18),
playlist("pl-morning", "Morning", 7),
playlist("pl-one-song", "One Song", 1),
)
private fun artist(
id: String,
name: String,
albumCount: Long,
) = Artist(
sourceId = 1,
id = id,
name = name,
albumCount = albumCount,
starred = null,
coverArt = "art-$id",
)
private fun album(
id: String,
artistId: String,
name: String,
albumArtist: String,
year: Long,
songCount: Long,
) = Album(
sourceId = 1,
id = id,
artistId = artistId,
name = name,
albumArtist = albumArtist,
created = 0,
coverArt = "art-$id",
genre = null,
year = year,
starred = null,
songCount = songCount,
frequentRank = null,
recentRank = null,
)
private fun song(
id: String,
albumId: String,
artistId: String,
title: String,
album: String,
artist: String,
track: Long,
duration: Long,
) = Song(
sourceId = 1,
id = id,
albumId = albumId,
artistId = artistId,
title = title,
album = album,
artist = artist,
duration = duration,
track = track,
disc = 1,
starred = null,
genre = null,
)
private fun playlist(
id: String,
name: String,
songCount: Long,
) = Playlist(
sourceId = 1,
id = id,
name = name,
comment = null,
coverArt = "art-$id",
songCount = songCount,
created = 0,
)
fun artEngine(): FakeImageLoaderEngine {
val builder = FakeImageLoaderEngine.Builder()
albums.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Album)) }
playlists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Playlist)) }
artists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Artist)) }
return builder.default(art("art-default", ArtKind.Album)).build()
}
private fun art(
key: String,
kind: ArtKind,
): Image {
val size = 256
val f = size.toFloat()
val random = Random(key.hashCode())
val base = random.nextFloat() * 360f
fun color(offset: Float): Int =
Color.HSVToColor(
floatArrayOf(((base + offset) % 360f + 360f) % 360f, 0.88f, 0.92f),
)
val palette =
if (random.nextBoolean()) {
listOf(color(0f), color(120f + random.nextFloat() * 40f), color(230f + random.nextFloat() * 40f))
} else {
listOf(color(0f), color(170f + random.nextFloat() * 30f))
}
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
canvas.drawColor(palette[0])
when (kind) {
ArtKind.Album -> {
val stripe = f / 9f
canvas.save()
canvas.rotate(-30f, f / 2f, f / 2f)
var x = -f
var index = 1
while (x < f * 2f) {
paint.color = palette[index % palette.size]
canvas.drawRect(x, -f, x + stripe * 0.6f, f * 2f, paint)
x += stripe
index++
}
canvas.restore()
}
ArtKind.Playlist -> {
val step = f / 4f
var row = 1
var y = step / 2f
while (y < f) {
var x = step / 2f
while (x < f) {
paint.color = palette[row % palette.size]
canvas.drawCircle(x, y, step * 0.3f, paint)
x += step
row++
}
y += step
}
}
ArtKind.Artist -> {
paint.style = Paint.Style.STROKE
paint.strokeWidth = f / 13f
var radius = f * 0.5f
var index = 1
while (radius > 0f) {
paint.color = palette[index % palette.size]
canvas.drawCircle(f / 2f, f / 2f, radius, paint)
radius -= paint.strokeWidth
index++
}
paint.style = Paint.Style.FILL
}
}
return bitmap.asImage()
}
}
private enum class ArtKind { Album, Playlist, Artist }
@@ -1,32 +0,0 @@
package com.subtracks.ui
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class HelloScreenshotTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun hello() {
composeRule.setContent {
SubtracksTheme {
HelloScreen()
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/hello.png")
}
}
@@ -0,0 +1,107 @@
package com.subtracks.ui
import androidx.activity.ComponentActivity
import androidx.compose.runtime.remember
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeUp
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.SongListItem
import com.subtracks.ui.library.ALBUM_COVER_TAG
import com.subtracks.ui.library.LibraryScreen
import com.subtracks.ui.library.LibraryTab
import com.subtracks.ui.theme.SubtracksTheme
import kotlinx.coroutines.flow.flowOf
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class LibraryScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun titleCollapsesOnScrollDownAndExpandsOnScrollUp() {
composeRule.setContent {
SubtracksTheme {
LibraryScreen(
selectedTab = LibraryTab.Albums,
onTabSelected = {},
albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(),
artists = remember { flowOf(PagingData.empty<Artist>()) }.collectAsLazyPagingItems(),
songs = remember { flowOf(PagingData.empty<SongListItem>()) }.collectAsLazyPagingItems(),
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.collectAsLazyPagingItems(),
coverArt = { _, _ -> null },
onAlbumClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
)
}
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().isNotEmpty()
}
val expandedTop =
composeRule
.onNodeWithContentDescription("Sync")
.fetchSemanticsNode()
.boundsInRoot.top
composeRule.onRoot().performTouchInput { swipeUp(startY = centerY + 150f, endY = centerY - 150f, durationMillis = 600) }
composeRule.waitForIdle()
val collapsedTop =
composeRule
.onNodeWithContentDescription("Sync")
.fetchSemanticsNode()
.boundsInRoot.top
composeRule.onRoot().performTouchInput { swipeDown(startY = centerY - 150f, endY = centerY + 150f, durationMillis = 600) }
composeRule.waitForIdle()
val restoredTop =
composeRule
.onNodeWithContentDescription("Sync")
.fetchSemanticsNode()
.boundsInRoot.top
assertTrue("title should shrink while scrolling down ($expandedTop -> $collapsedTop)", collapsedTop < expandedTop)
assertTrue("title should come back on upward scroll ($collapsedTop -> $restoredTop)", restoredTop > collapsedTop)
}
private fun albums() =
(1..60).map { index ->
Album(
sourceId = 1,
id = "al-$index",
artistId = "ar-1",
name = "Album $index",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
frequentRank = null,
recentRank = null,
)
}
}