ui: polish artist art, chooser navigation, and list search

Fall back to the first album credit cover (then the first track credit) for artist art, keep list searches when opening a result, resume search at the end of the query, and navigate immediately from the artist chooser.
This commit is contained in:
austinried
2026-10-19 09:30:18 +00:00
parent de64e8bc69
commit 88e83c2a86
9 changed files with 229 additions and 37 deletions
@@ -24,6 +24,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -545,6 +546,74 @@ class LibraryDaoTest {
)
}
@Test
fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-own", "Own", albumCount = 1).copy(coverArt = "own-art"),
artist(sourceId, "ar-album", "Album", albumCount = 2),
artist(sourceId, "ar-track", "Track", albumCount = 1),
artist(sourceId, "ar-null-art", "Null Art", albumCount = 2),
artist(sourceId, "ar-none", "None", albumCount = 0),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "own", "Own", year = 2020, starred = null).copy(coverArt = "own-album"),
album(sourceId, "album-new", "New", year = 2020, starred = null)
.copy(artistId = "ar-album", coverArt = "new-art"),
album(sourceId, "album-old", "Old", year = 2000, starred = null)
.copy(artistId = "ar-album", coverArt = "old-art"),
album(sourceId, "track", "Track", year = 2021, starred = null)
.copy(artistId = "ar-other", coverArt = "track-art"),
album(sourceId, "null-new", "Null New", year = 2022, starred = null)
.copy(artistId = "ar-null-art", coverArt = null),
album(sourceId, "null-old", "Null Old", year = 1990, starred = null)
.copy(artistId = "ar-null-art", coverArt = "later-art"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s-own", "Own", starred = null).copy(albumId = "own", artistId = "ar-own"),
song(sourceId, "s-album", "Album", starred = null).copy(albumId = "album-new", artistId = "ar-album"),
song(sourceId, "s-track", "Track", starred = null).copy(albumId = "track", artistId = "ar-track"),
song(sourceId, "s-null-new", "Null New", starred = null)
.copy(albumId = "null-new", artistId = "ar-null-art"),
song(sourceId, "s-null-old", "Null Old", starred = null)
.copy(albumId = "null-old", artistId = "ar-null-art"),
),
)
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
assertEquals("own-art", artists.getValue("ar-own").coverArt)
assertEquals("new-art", artists.getValue("ar-album").coverArt)
assertEquals("track-art", artists.getValue("ar-track").coverArt)
assertNull(artists.getValue("ar-null-art").coverArt)
assertNull(artists.getValue("ar-none").coverArt)
assertEquals("new-art", dao.artistOnce(sourceId, "ar-album")!!.coverArt)
}
@Test
fun artistArtFallbackFindsCreditsThroughArtistIndexes() =
runTest {
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME LIMIT 20")
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
}
@Test
fun albumCreditIdsFindCreditsThroughArtistIndexes() =
runTest {
@@ -232,6 +232,46 @@ class LibraryScreenTest {
assertTrue("the tab that was shown should collect its flow", collected.contains("artists"))
}
@Test
fun openingAResultKeepsTheListSearchQuery() {
val search = mutableStateOf("xyz")
val show = mutableStateOf(true)
var opened: Album? = null
composeRule.setContent {
if (show.value) {
SubtracksTheme {
LibraryScreen(
selectedTab = LibraryTab.Albums,
onTabSelected = {},
albums = remember { flowOf(PagingData.from(albums())) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = { _, _ -> null },
onAlbumClick = { opened = it },
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
search = search.value,
onSearchChange = { search.value = it },
)
}
}
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onAllNodesWithTag(ALBUM_COVER_TAG)[0].performClick()
composeRule.waitForIdle()
assertEquals("al-1", opened?.id)
assertEquals("xyz", search.value)
composeRule.runOnIdle { show.value = false }
composeRule.waitForIdle()
}
@Test
fun theTabRowReflowsInOneDirectionDuringASwitch() {
val pagerState = PagerState(currentPage = 1, pageCount = { LibraryTab.entries.size })
@@ -45,6 +45,29 @@ class ArtistChooserSheetTest {
assertEquals(ArtistRef("ar2", "B"), opened)
}
@Test
fun opensTheChosenArtistBeforeTheSheetFinishesClosing() {
var opened: ArtistRef? = null
composeRule.setContent {
SubtracksTheme {
ArtistChooserSheet(
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
onArtistClick = { opened = it },
onDismiss = {},
)
}
}
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("B").performClick()
assertEquals(ArtistRef("ar2", "B"), opened)
composeRule.mainClock.advanceTimeBy(1_000)
composeRule.mainClock.autoAdvance = true
composeRule.waitForIdle()
}
@Test
fun longListsScrollToTheLastArtist() {
var opened: ArtistRef? = null
@@ -1,10 +1,15 @@
package com.subtracks.ui.search
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.data.model.Album
@@ -84,6 +89,30 @@ class SearchScreenTest {
composeRule.onNodeWithText("No results", substring = true).assertIsDisplayed()
}
@Test
fun typingResumesAtTheEndOfTheQueryAfterTheScreenIsRecreated() {
var query by mutableStateOf("road")
var show by mutableStateOf(true)
composeRule.setContent {
if (show) {
SubtracksTheme {
SearchScreen(query = query, onQueryChange = { query = it }, results = SearchResults())
}
}
}
composeRule.onNode(hasSetTextAction()).performTextInput("s")
assertEquals("roads", query)
composeRule.runOnIdle { show = false }
composeRule.runOnIdle { show = true }
composeRule.onNode(hasSetTextAction()).performTextInput("!")
assertEquals("roads!", query)
composeRule.runOnIdle { show = false }
composeRule.waitForIdle()
}
@Test
fun idsSharedAcrossTypesDoNotCollide() {
composeRule.setContent {