diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt index 6836d264..0a2e7100 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt @@ -111,12 +111,29 @@ class SelectionRepo( /** * Filters items by query. Uses toIntOrNull() instead of exception-based flow, * and lowercases the query once up front instead of per-item. + * + * Fuzzy search: the query is split into space-separated tokens and every + * token must appear in the satellite name after both sides are normalized + * (lowercased, non-alphanumeric separators such as dashes, spaces, brackets + * and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and + * "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching + * previously failed whenever the name contained a separator the query lacked. */ private fun filterByQuery(items: List, query: String): List { if (query.isBlank()) return items val catnum = query.toIntOrNull() if (catnum != null) return items.filter { it.catnum == catnum } - val lowerQuery = query.lowercase() - return items.filter { it.name.lowercase().contains(lowerQuery) } + val tokens = query.split(' ') + .map { normalizeForSearch(it) } + .filter { it.isNotEmpty() } + if (tokens.isEmpty()) return items + return items.filter { item -> + val normalizedName = normalizeForSearch(item.name) + tokens.all { normalizedName.contains(it) } + } } + + /** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */ + private fun normalizeForSearch(text: String): String = + text.lowercase().filter { it.isLetterOrDigit() } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt new file mode 100644 index 00000000..2b559562 --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt @@ -0,0 +1,197 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.rtbishop.look4sat.core.data.repository + +import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings +import com.rtbishop.look4sat.core.domain.model.DatabaseState +import com.rtbishop.look4sat.core.domain.model.OtherSettings +import com.rtbishop.look4sat.core.domain.model.PassesSettings +import com.rtbishop.look4sat.core.domain.model.RCSettings +import com.rtbishop.look4sat.core.domain.model.RadioControlSettings +import com.rtbishop.look4sat.core.domain.model.SatItem +import com.rtbishop.look4sat.core.domain.model.SatRadio +import com.rtbishop.look4sat.core.domain.predict.GeoPos +import com.rtbishop.look4sat.core.domain.predict.OrbitalData +import com.rtbishop.look4sat.core.domain.predict.OrbitalObject +import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo +import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo +import com.rtbishop.look4sat.core.domain.source.ILocalSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SelectionRepoSearchTest { + + private val sampleItems = listOf( + SatItem(catnum = 25544, name = "ISS (ZARYA)"), + SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"), + SatItem(catnum = 39444, name = "AO-73 (FUNcube-1)"), + SatItem(catnum = 43803, name = "JO-97 (BIRDS-3)"), + SatItem(catnum = 99999, name = "FO-29"), + ) + + @Test + fun `query without separators matches name with dashes spaces brackets`() = runTest { + val repo = createRepo(sampleItems) + repo.setQuery("ao7") + val results = repo.getEntriesFlow().first() + // "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a + // fuzzy search legitimately returns both AO-7 (first) and AO-73. The + // key guarantee is that AO-7 — which was unreachable before because of + // the dashes/brackets — is now found. + assertTrue(results.map { it.catnum }.contains(7530)) + assertEquals(7530, results.first().catnum) + } + + @Test + fun `query without separators matches name with only dashes`() = runTest { + val repo = createRepo(sampleItems) + repo.setQuery("fo29") + val results = repo.getEntriesFlow().first() + assertEquals(listOf(99999), results.map { it.catnum }) + } + + @Test + fun `space-separated tokens all must match in any order`() = runTest { + val repo = createRepo(sampleItems) + repo.setQuery("zarya iss") + val results = repo.getEntriesFlow().first() + assertEquals(listOf(25544), results.map { it.catnum }) + } + + @Test + fun `exact name query still matches`() = runTest { + val repo = createRepo(sampleItems) + repo.setQuery("AO-7 (AMSAT-OSCAR 7)") + val results = repo.getEntriesFlow().first() + assertEquals(listOf(7530), results.map { it.catnum }) + } + + @Test + fun `partial token query matches substring`() = runTest { + val repo = createRepo(sampleItems) + repo.setQuery("funcube") + val results = repo.getEntriesFlow().first() + assertEquals(listOf(39444), results.map { it.catnum }) + } + + @Test + fun `numeric query matches catnum exactly`() = runTest { + val repo = createRepo(sampleItems) + repo.setQuery("25544") + val results = repo.getEntriesFlow().first() + assertEquals(listOf(25544), results.map { it.catnum }) + } + + @Test + fun `no query returns all items`() = runTest { + val repo = createRepo(sampleItems) + repo.setQuery("") + val results = repo.getEntriesFlow().first() + assertEquals(sampleItems.size, results.size) + } + + @Test + fun `unmatched query returns nothing`() = runTest { + val repo = createRepo(sampleItems) + repo.setQuery("zzzznomatch") + assertTrue(repo.getEntriesFlow().first().isEmpty()) + } + + private fun createRepo(items: List): ISelectionRepo { + return SelectionRepo( + dispatcher = Dispatchers.Unconfined, + localSource = FakeLocalSourceForSearch(items), + settingsRepo = FakeSettingsRepoForSearch() + ) + } +} + +private class FakeLocalSourceForSearch(private val items: List) : ILocalSource { + override suspend fun getEntriesTotal(): Int = items.size + override suspend fun getEntriesList(): List = items + override suspend fun getEntriesWithIds(ids: List): List = emptyList() + override suspend fun insertEntries(entries: List) = Unit + override suspend fun deleteEntries() = Unit + override suspend fun getIdsWithModes(modes: List): List = emptyList() + override suspend fun getRadiosTotal(): Int = 0 + override suspend fun getRadiosWithId(id: Int): List = emptyList() + override suspend fun insertRadios(radios: List, isCustom: Boolean) = Unit + override suspend fun deleteManagedRadios() = Unit + override suspend fun deleteRadios() = Unit +} + +private class FakeSettingsRepoForSearch : ISettingsRepo { + override val appVersionName: String = "test" + override val selectedIds: StateFlow> = MutableStateFlow(emptyList()) + override val selectedTypes: StateFlow> = MutableStateFlow(emptyList()) + override val passesSettings: StateFlow = MutableStateFlow( + PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList()) + ) + override val stationPosition: StateFlow = MutableStateFlow(GeoPos(0.0, 0.0)) + override val databaseState: MutableStateFlow = MutableStateFlow(DatabaseState(0, 0, 0L)) + override val rcSettings: StateFlow = MutableStateFlow( + RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "") + ) + override val otherSettings: StateFlow = MutableStateFlow( + OtherSettings( + false, false, false, false, false, false, false, + shouldSeeWarning = false, shouldSeeWhatsNew = false + ) + ) + override val dataSourcesSettings: MutableStateFlow = + MutableStateFlow(DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())) + override val dataSourcesStatus: StateFlow> = MutableStateFlow(emptyMap()) + override val radioControlSettings: StateFlow = MutableStateFlow( + RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) + ) + override val wavelogSettings: StateFlow = + MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings()) + override val lotwSettings: StateFlow = + MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings()) + + override fun setSelectedIds(ids: List) = Unit + override fun setSelectedTypes(types: List) = Unit + override fun setPassesSettings(settings: PassesSettings) = Unit + override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true + override fun setStationPosition(): Boolean = true + override fun setStationPosition(locator: String): Boolean = true + override fun getSatelliteTypesIds(types: List): List = emptyList() + override fun setSatelliteTypeIds(type: String, ids: List) = Unit + override fun updateDatabaseState(state: DatabaseState) = Unit + override fun updateRCSettings(settings: RCSettings) = Unit + override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit + override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit + override fun updateDataSourcesStatus(status: Map) = Unit + override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit + override fun getSatelliteOffset(catnum: Int): String = "" + override fun setSatelliteOffset(catnum: Int, offset: String) = Unit + override fun getAmSatCallsign(): String = "" + override fun setAmSatCallsign(callsign: String) = Unit + override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit + override fun getWorkedGrids(): Set = emptySet() + override fun setWorkedGrids(grids: Set) = Unit + override fun getWorkedGridQsos(): Map> = emptyMap() + override fun setWorkedGridQsos(qsos: Map>) = Unit + override fun getRoamedGrids(): Set = emptySet() + override fun setRoamedGrids(grids: Set) = Unit + override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit + override fun getLastLotwSyncDate(): String = "" + override fun setLastLotwSyncDate(date: String) = Unit + override fun getLastLotwSyncCallsign(): String = "" + override fun setLastLotwSyncCallsign(callsign: String) = Unit +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca2b3565..ffce4874 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "479" +appVersionCode = "480" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.4.7-ba7opf.9" +appVersionName = "4.4.7-ba7opf.10" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry