mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
search: expose cross-library search from a home FAB
Add FTS5-backed search across songs, albums, artists and playlists, reachable from a search FAB on the Home tab. Terms of three characters or more run through the trigram indices; shorter terms fall back to the instr scan, matching the per-tab filters. Results are grouped by type and route into detail screens or song playback.
This commit is contained in:
@@ -703,6 +703,91 @@ class LibraryDaoTest {
|
||||
assertTrue(plan, plan.contains("album_search"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crossTypeSearchMatchesAllFourEntityTypes() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Road Album", year = null, starred = null)))
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Road Artists", albumCount = 1)))
|
||||
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "Road Trip", created = 0, changed = 0)))
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "Road Song", starred = null),
|
||||
song(sourceId, "s2", "Neutral", starred = null).copy(artist = "Roadie"),
|
||||
song(sourceId, "s3", "Unrelated", starred = null).copy(artist = "Someone"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Road Album"), dao.searchAlbums(sourceId, "oad", 50).first().map { it.name })
|
||||
assertEquals(listOf("ar-1"), dao.searchArtists(sourceId, "oad", 50).first().map { it.id })
|
||||
assertEquals(listOf("pl-1"), dao.searchPlaylists(sourceId, "oad", 50).first().map { it.id })
|
||||
assertEquals(
|
||||
setOf("s1", "s2"),
|
||||
dao
|
||||
.searchSongs(sourceId, "oad", 50)
|
||||
.first()
|
||||
.map { it.song.id }
|
||||
.toSet(),
|
||||
)
|
||||
|
||||
assertTrue(dao.searchAlbums(sourceId, "", 50).first().isEmpty())
|
||||
assertTrue(dao.searchSongs(sourceId, "", 50).first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crossTypeSearchIsScopedToTheSource() =
|
||||
runTest {
|
||||
val first = source()
|
||||
val second = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(first, "a1", "Road One", year = null, starred = null),
|
||||
album(second, "a2", "Road Two", year = null, starred = null),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Road One"), dao.searchAlbums(first, "oad", 50).first().map { it.name })
|
||||
assertEquals(listOf("Road Two"), dao.searchAlbums(second, "oad", 50).first().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crossTypeSearchHonoursTheLimit() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums((1..3).map { album(sourceId, "a$it", "Road $it", year = null, starred = null) })
|
||||
|
||||
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 crossTypeSearchFallsBackToAScanBelowThreeCharacters() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Gamma", year = null, starred = null)))
|
||||
dao.upsertSongs(listOf(song(sourceId, "s1", "Gamma Ray", starred = null)))
|
||||
|
||||
assertEquals(listOf("Gamma"), dao.searchAlbums(sourceId, "ga", 50).first().map { it.name })
|
||||
assertEquals(listOf("s1"), dao.searchSongs(sourceId, "ga", 50).first().map { it.song.id })
|
||||
}
|
||||
|
||||
private suspend fun plan(sql: String): String =
|
||||
db.useReaderConnection { connection ->
|
||||
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
||||
|
||||
@@ -110,6 +110,33 @@ class LibraryScreenTest {
|
||||
assertTrue("an empty library should offer a sync", synced)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theHomeTabOffersSearch() {
|
||||
var searched = false
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
LibraryScreen(
|
||||
selectedTab = LibraryTab.Home,
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.empty<Album>()) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
onOpenSearch = { searched = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithContentDescription("Search").performClick()
|
||||
|
||||
assertTrue("the home tab should expose search", searched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theVisibleTabAndItsNeighbourCollectTheirPagingFlows() {
|
||||
val collected = CopyOnWriteArrayList<String>()
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.subtracks.ui.search
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
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 SearchScreenTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun resultsAreGroupedByTypeAndTappingASongOrAlbumFiresItsCallback() {
|
||||
var played: Song? = null
|
||||
var opened: Album? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SearchScreen(
|
||||
query = "road",
|
||||
onQueryChange = {},
|
||||
results =
|
||||
SearchResults(
|
||||
albums = listOf(album("al-1", "Road Album")),
|
||||
artists = listOf(artist("ar-1", "Road Artist")),
|
||||
playlists = listOf(playlist("pl-1", "Road Trip")),
|
||||
songs = listOf(AlbumSongItem(song("s1", "Road Song"), coverArt = null)),
|
||||
),
|
||||
onSongClick = { played = it },
|
||||
onAlbumClick = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Songs").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Albums").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artists").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Playlists").assertIsDisplayed()
|
||||
|
||||
composeRule.onNodeWithText("Road Song").performClick()
|
||||
composeRule.onNodeWithText("Road Album").performClick()
|
||||
|
||||
assertEquals("s1", played?.id)
|
||||
assertEquals("al-1", opened?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptyQueryPromptsAndAnEmptyResultSaysSo() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SearchScreen(query = "", onQueryChange = {}, results = SearchResults())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Search for songs, albums, artists and playlists.").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aQueryWithNoResultsSaysSo() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SearchScreen(query = "zzz", onQueryChange = {}, results = SearchResults())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("No results", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun album(
|
||||
id: String,
|
||||
name: String,
|
||||
) = Album(
|
||||
sourceId = 1,
|
||||
id = id,
|
||||
artistId = "ar-1",
|
||||
name = name,
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
)
|
||||
|
||||
private fun artist(
|
||||
id: String,
|
||||
name: String,
|
||||
) = Artist(
|
||||
sourceId = 1,
|
||||
id = id,
|
||||
name = name,
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
)
|
||||
|
||||
private fun playlist(
|
||||
id: String,
|
||||
name: String,
|
||||
) = Playlist(
|
||||
sourceId = 1,
|
||||
id = id,
|
||||
name = name,
|
||||
comment = null,
|
||||
coverArt = null,
|
||||
songCount = 1,
|
||||
created = 0,
|
||||
changed = 0,
|
||||
duration = 100,
|
||||
)
|
||||
|
||||
private fun song(
|
||||
id: String,
|
||||
title: String,
|
||||
) = Song(
|
||||
sourceId = 1,
|
||||
id = id,
|
||||
albumId = "al-1",
|
||||
artistId = "ar-1",
|
||||
title = title,
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.subtracks.ui.search
|
||||
|
||||
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.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.NetworkServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.FakePlayerConnection
|
||||
import com.subtracks.playback.FakePlayerHandle
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SearchViewModelTest {
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var sourceRepository: SourceRepository
|
||||
private lateinit var viewModel: SearchViewModel
|
||||
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
||||
val downloads =
|
||||
DownloadRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
FakeDownloadEngine(),
|
||||
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
||||
artworkStore = artwork,
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
)
|
||||
val controller =
|
||||
PlaybackController(
|
||||
sourceRepository,
|
||||
QueueRepository(db),
|
||||
FakePlayerConnection(FakePlayerHandle()),
|
||||
downloads,
|
||||
scope = controllerScope,
|
||||
)
|
||||
viewModel =
|
||||
SearchViewModel(
|
||||
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
||||
sourceRepository,
|
||||
controller,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
dispatcher.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchFindsEveryEntityTypeAndBlankClearsTheResults() {
|
||||
runBlocking {
|
||||
val id = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
|
||||
db.libraryDao().upsertAlbums(listOf(album(id, "al-1", "Road Album")))
|
||||
db.libraryDao().upsertArtists(listOf(artist(id, "ar-1", "Road Artist")))
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist(id, "pl-1", "Road Trip")))
|
||||
db.libraryDao().upsertSongs(listOf(song(id, "s1", "Road Song")))
|
||||
}
|
||||
|
||||
viewModel.setQuery("oad")
|
||||
val results = runBlocking { withTimeout(5_000) { viewModel.results.first { !it.isEmpty } } }
|
||||
|
||||
assertEquals(listOf("Road Album"), results.albums.map { it.name })
|
||||
assertEquals(listOf("ar-1"), results.artists.map { it.id })
|
||||
assertEquals(listOf("pl-1"), results.playlists.map { it.id })
|
||||
assertEquals(listOf("s1"), results.songs.map { it.song.id })
|
||||
|
||||
viewModel.setQuery("")
|
||||
val blank = runBlocking { withTimeout(5_000) { viewModel.results.first { it.isEmpty } } }
|
||||
|
||||
assertTrue(blank.isEmpty)
|
||||
}
|
||||
|
||||
private fun album(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
name: String,
|
||||
) = Album(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
artistId = "ar-1",
|
||||
name = name,
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
)
|
||||
|
||||
private fun artist(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
name: String,
|
||||
) = Artist(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
name = name,
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
)
|
||||
|
||||
private fun playlist(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
name: String,
|
||||
) = Playlist(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
name = name,
|
||||
comment = null,
|
||||
coverArt = null,
|
||||
songCount = 1,
|
||||
created = 0,
|
||||
changed = 0,
|
||||
duration = 100,
|
||||
)
|
||||
|
||||
private fun song(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
title: String,
|
||||
) = Song(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
albumId = "al-1",
|
||||
artistId = "ar-1",
|
||||
title = title,
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user