From 177e5ad1083aa1e68846c85635fb23281acabe12 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:54:20 +0000 Subject: [PATCH] add screenshot goldens and the title collapse gesture test --- .../subsonic/SubsonicSourceIntegrationTest.kt | 129 +++--- .../data/repo/SourceRepositoryTest.kt | 58 +++ .../source/subsonic/SubsonicClientTest.kt | 15 +- .../source/subsonic/SubsonicSourceTest.kt | 59 ++- .../data/source/subsonic/SubsonicXmlTest.kt | 73 +-- .../subtracks/data/sync/SyncServiceTest.kt | 232 ++++++---- .../com/subtracks/ui/AppScreenshotTest.kt | 431 ++++++++++++++++++ .../com/subtracks/ui/HelloScreenshotTest.kt | 32 -- .../com/subtracks/ui/LibraryScreenTest.kt | 107 +++++ 9 files changed, 901 insertions(+), 235 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt delete mode 100644 app/src/test/kotlin/com/subtracks/ui/HelloScreenshotTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt index bd4fa167..620d176a 100644 --- a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt +++ b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt @@ -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> = listOf( - arrayOf(Server("navidrome", "http://localhost:4533/", "admin", "password")), - arrayOf(Server("gonic", "http://localhost:4747/", "admin", "admin")), - ) + fun servers(): List> = + listOf( + arrayOf(Server("navidrome", "http://localhost:4533/", "admin", "password")), + arrayOf(Server("gonic", "http://localhost:4747/", "admin", "admin")), + ) } } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt new file mode 100644 index 00000000..fdd59df6 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/repo/SourceRepositoryTest.kt @@ -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() + 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, + ) + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt index 0474b874..0557cf2e 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt @@ -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() { diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt index ab51aac0..8cca9ff2 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt @@ -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) = - "" + private fun failed( + code: Int, + message: String, + ) = "" private companion object { const val ALBUM_LIST = diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt index 437f4edf..1ea43265 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt @@ -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( - """ - - - - - - - """.trimIndent(), - ) + val document = + parse( + """ + + + + + + + """.trimIndent(), + ) val artists = SubsonicXml.artists(7, document) @@ -35,16 +38,17 @@ class SubsonicXmlTest { @Test fun mapsAlbumList() { - val document = parse( - """ - - - - - - - """.trimIndent(), - ) + val document = + parse( + """ + + + + + + + """.trimIndent(), + ) val albums = SubsonicXml.albums(1, document) @@ -68,18 +72,19 @@ class SubsonicXmlTest { @Test fun mapsSongsAndPlaylistEntries() { - val document = parse( - """ - - - - - - - - - """.trimIndent(), - ) + val document = + parse( + """ + + + + + + + + + """.trimIndent(), + ) val songs = SubsonicXml.songs(1, document) val entries = SubsonicXml.playlistSongs(1, "pl1", document) diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index ecc94942..24efd3e5 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -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() - 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 PagingSource.allRows(): List { + 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) diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt new file mode 100644 index 00000000..560a360e --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -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() + + @Before + fun installImageLoader() { + SingletonImageLoader.reset() + SingletonImageLoader.setUnsafe( + ImageLoader + .Builder(ApplicationProvider.getApplicationContext()) + .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 } diff --git a/app/src/test/kotlin/com/subtracks/ui/HelloScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/HelloScreenshotTest.kt deleted file mode 100644 index 1e3c6993..00000000 --- a/app/src/test/kotlin/com/subtracks/ui/HelloScreenshotTest.kt +++ /dev/null @@ -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() - - @Test - fun hello() { - composeRule.setContent { - SubtracksTheme { - HelloScreen() - } - } - composeRule.onRoot().captureRoboImage("src/test/screenshots/hello.png") - } -} diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt new file mode 100644 index 00000000..2933bcb2 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt @@ -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() + + @Test + fun titleCollapsesOnScrollDownAndExpandsOnScrollUp() { + composeRule.setContent { + SubtracksTheme { + LibraryScreen( + selectedTab = LibraryTab.Albums, + onTabSelected = {}, + albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(), + artists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + songs = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + playlists = remember { flowOf(PagingData.empty()) }.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, + ) + } +}