search: address review findings

Prefix result keys per type so ids shared across songs, albums, artists
and playlists cannot collide and crash the list; add a loading state so
a query in flight no longer reads as no results; gate on code points to
match SQLite's length(); bind the three-character floor to one constant;
label the search field for TalkBack; and drop the vacuous trigram-plan
test. Cancel the ViewModel scope in its test teardown so it cannot leak
into later Robolectric tests.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent c1081b4ae7
commit b25b9deacd
7 changed files with 67 additions and 30 deletions
@@ -762,20 +762,6 @@ class LibraryDaoTest {
assertEquals(2, dao.searchAlbums(sourceId, "oad", 2).first().size)
}
@Test
fun songSearchRunsThroughTheTrigramIndex() =
runTest {
val plan =
plan(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = 1 AND ${SONG_SEARCH_FILTER.replace(":search", "'abc'")} " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT 20 OFFSET 0",
)
assertTrue(plan, plan.contains("song_search"))
}
@Test
fun crossTypeSearchIgnoresQueriesShorterThanThreeCharacters() =
runTest {
@@ -83,6 +83,30 @@ class SearchScreenTest {
composeRule.onNodeWithText("No results", substring = true).assertIsDisplayed()
}
@Test
fun idsSharedAcrossTypesDoNotCollide() {
composeRule.setContent {
SubtracksTheme {
SearchScreen(
query = "road",
onQueryChange = {},
results =
SearchResults(
albums = listOf(album("1", "Road Album")),
artists = listOf(artist("1", "Road Artist")),
playlists = listOf(playlist("1", "Road Trip")),
songs = listOf(AlbumSongItem(song("1", "Road Song"), coverArt = null)),
),
)
}
}
composeRule.onNodeWithText("Road Song").assertIsDisplayed()
composeRule.onNodeWithText("Road Album").assertIsDisplayed()
composeRule.onNodeWithText("Road Artist").assertIsDisplayed()
composeRule.onNodeWithText("Road Trip").assertIsDisplayed()
}
private fun album(
id: String,
name: String,
@@ -1,6 +1,7 @@
package com.subtracks.ui.search
import android.content.Context
import androidx.lifecycle.viewModelScope
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
@@ -27,8 +28,10 @@ import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
@@ -37,6 +40,7 @@ import kotlinx.coroutines.withTimeout
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
@@ -94,6 +98,7 @@ class SearchViewModelTest {
@After
fun tearDown() {
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
cancelAndJoinBlocking(controllerScope, repoScope)
db.close()
Dispatchers.resetMain()
@@ -129,6 +134,13 @@ class SearchViewModelTest {
assertTrue(blank.isEmpty)
}
@Test
fun searchReadyCountsCodePointsRatherThanUtf16Units() {
assertTrue(searchReady("road"))
assertFalse(searchReady("ro"))
assertFalse(searchReady("a\uD83D\uDE00"))
}
@Test
fun playingASearchResultPlaysItsAlbumInsteadOfASearchQueue() {
val sourceId =