From 4cb11a84705afa22f1dfcce107eb7ab3c1246fcd Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Fri, 18 Sep 2026 17:13:31 +0800 Subject: [PATCH] feat(satellites): fuzzy name search ignoring separators The satellite search only matched exact continuous substrings, so names with dashes, spaces or brackets (AO-7, ISS (ZARYA), FO-29) could not be found by queries without those separators. The query is now normalized (lowercased, non-alphanumeric separators stripped) and split into space-separated tokens; every token must appear in the normalized satellite name. This makes 'ao7' match 'AO-7 (AMSAT-OSCAR 7)' and 'iss zarya' match 'ISS (ZARYA)'. Numeric queries still match catnum exactly. --- .../core/data/repository/SelectionRepo.kt | 21 ++++++- .../core/data/repository/SelectionRepoTest.kt | 59 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) 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 79cd0a34..854e1e62 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 @@ -114,12 +114,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/SelectionRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt index 8b383c4c..0a09cbf0 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt @@ -84,6 +84,65 @@ class SelectionRepoTest { assertEquals(listOf(true, false, false), items.map { it.isSelected }) } + @Test + fun `query without separators matches name with dashes spaces brackets`() = runTest(dispatcher) { + val repository = repoWithSearchEntries() + val flow = repository.getEntriesFlow() + repository.setQuery("ao7") + val items = flow.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 — unreachable before because of dashes/brackets + // — is now found. + assertEquals(7530, items.first().catnum) + assertEquals(listOf(7530, 39444), items.map { it.catnum }) + } + + @Test + fun `query without separators matches name with only dashes`() = runTest(dispatcher) { + val repository = repoWithSearchEntries() + val flow = repository.getEntriesFlow() + repository.setQuery("fo29") + assertEquals(listOf(99999), flow.first().map { it.catnum }) + } + + @Test + fun `space-separated tokens all must match in any order`() = runTest(dispatcher) { + val repository = repoWithSearchEntries() + val flow = repository.getEntriesFlow() + repository.setQuery("zarya iss") + assertEquals(listOf(25544), flow.first().map { it.catnum }) + } + + @Test + fun `partial token query matches substring`() = runTest(dispatcher) { + val repository = repoWithSearchEntries() + val flow = repository.getEntriesFlow() + repository.setQuery("funcube") + assertEquals(listOf(39444), flow.first().map { it.catnum }) + } + + @Test + fun `unmatched query returns nothing`() = runTest(dispatcher) { + val repository = repoWithSearchEntries() + val flow = repository.getEntriesFlow() + repository.setQuery("zzzznomatch") + assertEquals(emptyList(), flow.first().map { it.catnum }) + } + + private fun repoWithSearchEntries(): SelectionRepo { + val localSource = FakeLocalSource( + entries = listOf( + SatItem(25544, "ISS (ZARYA)", false), + SatItem(7530, "AO-7 (AMSAT-OSCAR 7)", false), + SatItem(39444, "AO-73 (FUNcube-1)", false), + SatItem(43803, "JO-97 (BIRDS-3)", false), + SatItem(99999, "FO-29", false) + ) + ) + return SelectionRepo(dispatcher, localSource, FakeSettingsRepo(selectedModes = emptyList())) + } + private class FakeLocalSource( private val entries: List ) : ILocalSource {