diff --git a/.gitignore b/.gitignore index c53a07d..944f7a3 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ CLAUDE.md .claude/ .vscode/ .codex/ +.memsearch/ .agents/ AGENTS.md docs/ui-reference/ @@ -60,4 +61,4 @@ fastlane/test_output # Logs *.log -skills-lock.json \ No newline at end of file +skills-lock.json diff --git a/AkFit/AkFit/Search/SearchTextMatcher.swift b/AkFit/AkFit/Search/SearchTextMatcher.swift new file mode 100644 index 0000000..49e7810 --- /dev/null +++ b/AkFit/AkFit/Search/SearchTextMatcher.swift @@ -0,0 +1,249 @@ +import Foundation + +/// Pure search text helpers shared by Supabase-backed search and type-ahead. +enum SearchTextMatcher { + + /// Precomputed query text used to avoid re-splitting and re-stemming the + /// same query while filtering candidate rows or suggestion terms. + struct QueryMatch { + let normalized: String + let words: [String] + let stemmedWords: [String] + let isPlainFoodQuery: Bool + + init(normalizedQuery: String) { + normalized = normalizedQuery + words = normalizedQuery.split(separator: " ").map(String.init) + stemmedWords = words.map { SearchTextMatcher.stemWord($0) } + isPlainFoodQuery = words.count <= 2 && words.allSatisfy { $0.allSatisfy(\.isLetter) } + } + } + + /// Returns normalized query text plus precomputed word/stem metadata. + static func queryMatch(for query: String) -> QueryMatch { + QueryMatch(normalizedQuery: normalizedQuery(query)) + } + + /// Normalizes user-entered query text and applies known aliases. + static func normalizedQuery(_ text: String) -> String { + applyQueryAliases(normalizeForSearch(text)) + } + + /// Rewrites known query aliases to their canonical normalized forms. + /// Called AFTER `normalizeForSearch` so the input is already lowercased + /// with punctuation stripped. Handles cases where normalization alone + /// can't bridge the gap (e.g. "innout" -> "in n out"). + static func applyQueryAliases(_ normalized: String) -> String { + // Longest patterns first to avoid partial replacement. + let aliases: [(from: String, to: String)] = [ + ("in and out", "in n out"), + ("innout", "in n out"), + ] + var result = normalized + for alias in aliases { + if result.contains(alias.from) { + result = result.replacingOccurrences(of: alias.from, with: alias.to) + } + } + return result + } + + /// Normalizes text for search comparison: lowercased, apostrophes removed, + /// hyphens/commas/parentheses replaced with spaces, whitespace collapsed. + /// Mirrors the Postgres `search_text` column transform. + static func normalizeForSearch(_ text: String) -> String { + text.lowercased() + .replacingOccurrences(of: "'", with: "") + .replacingOccurrences(of: "\u{2019}", with: "") // right single quote (iOS keyboard) + .replacingOccurrences(of: "\u{2018}", with: "") // left single quote + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: ",", with: " ") + .replacingOccurrences(of: "(", with: " ") + .replacingOccurrences(of: ")", with: " ") + .split(separator: " ") + .joined(separator: " ") + } + + /// Reduces a word to a rough stem by stripping common English plural + /// suffixes. Not a full Porter stemmer; just enough to map + /// "strawberries" <-> "strawberry", "blueberries" <-> "blueberry", etc. + static func stemWord(_ word: String) -> String { + let w = word.lowercased() + guard w.count > 3 else { return w } + // -ies -> -y (strawberries -> strawberry) + if w.hasSuffix("ies") { return String(w.dropLast(3)) + "y" } + // -ches, -shes, -xes, -zes, -ses -> drop -es + if w.hasSuffix("es") { + let stem = String(w.dropLast(2)) + if stem.hasSuffix("ch") || stem.hasSuffix("sh") || + stem.hasSuffix("x") || stem.hasSuffix("z") || stem.hasSuffix("s") { + return stem + } + } + // trailing -s (but not -ss) -> drop -s + if w.hasSuffix("s") && !w.hasSuffix("ss") { + return String(w.dropLast(1)) + } + return w + } + + /// Stems every word in a normalized string for comparison. + static func stemmedForm(_ text: String) -> String { + text.split(separator: " ").map { stemWord(String($0)) }.joined(separator: " ") + } + + /// Returns true when every normalized query word matches the term either + /// directly or through the rough plural stem. + static func matchesAllQueryWords(term: String, normalizedQuery: String) -> Bool { + matchesAllQueryWords(term: term, queryMatch: QueryMatch(normalizedQuery: normalizedQuery)) + } + + /// Returns true when every precomputed query word matches the term either + /// directly or through the rough plural stem. + static func matchesAllQueryWords(term: String, queryMatch: QueryMatch) -> Bool { + guard !queryMatch.normalized.isEmpty else { return false } + let normalizedTerm = normalizeForSearch(term) + let stemmedTerm = stemmedForm(normalizedTerm) + return queryMatch.words.allSatisfy { normalizedTerm.contains($0) } || + queryMatch.stemmedWords.allSatisfy { stemmedTerm.contains($0) } + } + + /// Levenshtein edit distance between two strings. Used for typo tolerance + /// in type-ahead suggestions. O(n*m) but only called on short food names. + static func editDistance(_ a: String, _ b: String) -> Int { + let a = Array(a), b = Array(b) + let m = a.count, n = b.count + if m == 0 { return n } + if n == 0 { return m } + var prev = Array(0...n) + var curr = [Int](repeating: 0, count: n + 1) + for i in 1...m { + curr[0] = i + for j in 1...n { + let cost = a[i-1] == b[j-1] ? 0 : 1 + curr[j] = min(prev[j] + 1, curr[j-1] + 1, prev[j-1] + cost) + } + prev = curr + } + return prev[n] + } + + /// Scores how closely `name` matches `query` (query is already normalized). + /// Lower = better match. Supports stem-aware comparison so "strawberry" + /// matches "strawberries" at full quality. + static func matchScore(name: String, query q: String) -> Int { + matchScore(name: name, queryMatch: QueryMatch(normalizedQuery: q)) + } + + /// Scores how closely `name` matches a precomputed query. Lower = better. + static func matchScore(name: String, queryMatch: QueryMatch) -> Int { + let q = queryMatch.normalized + let n = normalizeForSearch(name) + if n == q { return 0 } + + let nStemmed = stemmedForm(n) + let qStemmed = queryMatch.stemmedWords.joined(separator: " ") + if nStemmed == qStemmed { return 0 } + + if n.hasPrefix(q) || nStemmed.hasPrefix(qStemmed) { return 1 } + + let qWords = queryMatch.words + let nWords = n.split(separator: " ").map(String.init) + let qStems = queryMatch.stemmedWords + let nStems = nWords.map { stemWord($0) } + + if qWords.count <= 1 { + let qStem = qStems[0] + if nWords.first == q || nStems.first == qStem { return 2 } + if nWords.contains(where: { $0.hasPrefix(q) }) { return 3 } + if nStems.contains(where: { $0.hasPrefix(qStem) }) { return 3 } + return 4 + } + + // Multi-word: check if every query word is a prefix of some name word + // (with stem-aware fallback). + let allWordPrefixes = qWords.indices.allSatisfy { i in + nWords.contains(where: { $0.hasPrefix(qWords[i]) }) || + nStems.contains(where: { $0.hasPrefix(qStems[i]) }) + } + if allWordPrefixes { + if let fq = qWords.first, let fn = nWords.first, + fn.hasPrefix(fq) || stemWord(fn).hasPrefix(stemWord(fq)) { + return 1 + } + return 2 + } + return 3 + } + + static func isPlainFoodQuery(_ normalizedQuery: String) -> Bool { + QueryMatch(normalizedQuery: normalizedQuery).isPlainFoodQuery + } + + /// Words that indicate a dessert or processed item. When the user's query + /// is a plain food word (e.g. "strawberry") these results should rank below + /// the whole-food match. + private static let dessertKeywords: Set = [ + "milkshake", "shake", "ice cream", "smoothie", "cake", "pie", + "cookie", "brownie", "muffin", "donut", "pastry", "candy", + "frosting", "sundae", "parfait", + ] + + /// Returns `true` when a normalized food name looks like a dessert or + /// processed item; used to add a ranking penalty for plain food queries. + static func isDessertOrProcessed(_ normalizedName: String) -> Bool { + dessertKeywords.contains(where: { normalizedName.contains($0) }) + } + + /// Returns up to `limit` suggestion terms that match `query`, ranked by + /// match quality. This is the former SearchView type-ahead behavior as a + /// pure helper so it can be unit-tested without UI state. + static func suggestions(for query: String, in suggestionPool: [String], limit: Int = 6) -> [String] { + suggestions(for: queryMatch(for: query), in: suggestionPool, limit: limit) + } + + /// Returns up to `limit` suggestion terms for a precomputed query match. + static func suggestions(for queryMatch: QueryMatch, in suggestionPool: [String], limit: Int = 6) -> [String] { + guard queryMatch.normalized.count >= 1 else { return [] } + + // Substring + stem matching (primary) + var matches = suggestionPool.filter { term in + matchesAllQueryWords(term: term, queryMatch: queryMatch) + } + + // Fuzzy fallback: if fewer than 3 substring matches, try edit distance + // on each word of the food name. Only for queries >= 3 chars to avoid + // noise on very short inputs. + if matches.count < 3 && queryMatch.normalized.count >= 3 { + let fuzzy = suggestionPool.filter { term in + guard !matches.contains(term) else { return false } + let nWords = normalizeForSearch(term).split(separator: " ").map(String.init) + return queryMatch.words.allSatisfy { qw in + nWords.contains { nw in + // Allow edit distance <= 2, but scale: for short words (<=4 chars) only allow 1 + let maxDist = qw.count <= 4 ? 1 : 2 + return editDistance(qw, nw) <= maxDist || + editDistance(stemWord(qw), stemWord(nw)) <= maxDist + } + } + } + matches.append(contentsOf: fuzzy) + } + + return matches + .sorted { a, b in + var sa = matchScore(name: a, queryMatch: queryMatch) + var sb = matchScore(name: b, queryMatch: queryMatch) + if queryMatch.isPlainFoodQuery { + let na = normalizeForSearch(a) + let nb = normalizeForSearch(b) + if isDessertOrProcessed(na) { sa += 1 } + if isDessertOrProcessed(nb) { sb += 1 } + } + if sa != sb { return sa < sb } + return a.count < b.count + } + .prefix(limit) + .map { $0 } + } +} diff --git a/AkFit/AkFit/Search/SupabaseFoodSearchService.swift b/AkFit/AkFit/Search/SupabaseFoodSearchService.swift index 6dc742c..611972f 100644 --- a/AkFit/AkFit/Search/SupabaseFoodSearchService.swift +++ b/AkFit/AkFit/Search/SupabaseFoodSearchService.swift @@ -17,10 +17,11 @@ struct SupabaseFoodSearchService: FoodSearchService { // Normalize the query the same way the DB search_text column is normalized: // remove apostrophes, replace hyphens with spaces, collapse whitespace. // Then apply known aliases (e.g. "innout" → "in n out"). - let normalized = Self.applyQueryAliases(Self.normalizeForSearch(q)) + let queryMatch = SearchTextMatcher.queryMatch(for: q) + let normalized = queryMatch.normalized guard !normalized.isEmpty else { return [] } - let words = normalized.split(separator: " ").map(String.init) + let words = queryMatch.words do { let rows: [GenericFoodRow] @@ -49,25 +50,23 @@ struct SupabaseFoodSearchService: FoodSearchService { .limit(100) .execute() .value - let stemmedWords = words.map { Self.stemWord($0) } rows = candidates.filter { row in - let n = Self.normalizeForSearch(row.foodName) - let nStemmed = Self.stemmedForm(n) - return words.allSatisfy { w in n.contains(w) } || - stemmedWords.allSatisfy { sw in nStemmed.contains(sw) } + SearchTextMatcher.matchesAllQueryWords( + term: row.foodName, + queryMatch: queryMatch + ) } } // Re-rank client-side by match quality so exact/prefix matches surface // before weaker substring hits. Dessert/processed items get a penalty // so whole foods rank above them for plain queries like "strawberry". - let isPlainFoodQuery = words.count <= 2 && words.allSatisfy { $0.allSatisfy(\.isLetter) } return rows.map(FoodItem.init).sorted { a, b in - var sa = Self.matchScore(name: a.name, query: normalized) - var sb = Self.matchScore(name: b.name, query: normalized) - if isPlainFoodQuery { - if Self.isDessertOrProcessed(Self.normalizeForSearch(a.name)) { sa += 1 } - if Self.isDessertOrProcessed(Self.normalizeForSearch(b.name)) { sb += 1 } + var sa = SearchTextMatcher.matchScore(name: a.name, queryMatch: queryMatch) + var sb = SearchTextMatcher.matchScore(name: b.name, queryMatch: queryMatch) + if queryMatch.isPlainFoodQuery { + if SearchTextMatcher.isDessertOrProcessed(SearchTextMatcher.normalizeForSearch(a.name)) { sa += 1 } + if SearchTextMatcher.isDessertOrProcessed(SearchTextMatcher.normalizeForSearch(b.name)) { sb += 1 } } if sa != sb { return sa < sb } // Within the same rank, shorter names are simpler/more generic. @@ -78,165 +77,6 @@ struct SupabaseFoodSearchService: FoodSearchService { } } - /// Rewrites known query aliases to their canonical normalized forms. - /// Called AFTER `normalizeForSearch` so the input is already lowercased - /// with punctuation stripped. Handles cases where normalization alone - /// can't bridge the gap (e.g. "innout" → "in n out"). - static func applyQueryAliases(_ normalized: String) -> String { - // Longest patterns first to avoid partial replacement. - let aliases: [(from: String, to: String)] = [ - ("in and out", "in n out"), - ("innout", "in n out"), - ] - var result = normalized - for alias in aliases { - if result.contains(alias.from) { - result = result.replacingOccurrences(of: alias.from, with: alias.to) - } - } - return result - } - - /// Normalizes text for search comparison: lowercased, apostrophes removed, - /// hyphens/commas/parentheses replaced with spaces, whitespace collapsed. - /// Mirrors the Postgres `search_text` column transform. - /// - /// Internal access so `SearchView` can use the same normalization for - /// type-ahead suggestion matching. - static func normalizeForSearch(_ text: String) -> String { - text.lowercased() - .replacingOccurrences(of: "'", with: "") - .replacingOccurrences(of: "\u{2019}", with: "") // right single quote (iOS keyboard) - .replacingOccurrences(of: "\u{2018}", with: "") // left single quote - .replacingOccurrences(of: "-", with: " ") - .replacingOccurrences(of: ",", with: " ") - .replacingOccurrences(of: "(", with: " ") - .replacingOccurrences(of: ")", with: " ") - .split(separator: " ") - .joined(separator: " ") - } - - /// Reduces a word to a rough stem by stripping common English plural - /// suffixes. Not a full Porter stemmer — just enough to map - /// "strawberries" ↔ "strawberry", "blueberries" ↔ "blueberry", etc. - static func stemWord(_ word: String) -> String { - let w = word.lowercased() - guard w.count > 3 else { return w } - // -ies → -y (strawberries → strawberry) - if w.hasSuffix("ies") { return String(w.dropLast(3)) + "y" } - // -ches, -shes, -xes, -zes, -ses → drop -es - if w.hasSuffix("es") { - let stem = String(w.dropLast(2)) - if stem.hasSuffix("ch") || stem.hasSuffix("sh") || - stem.hasSuffix("x") || stem.hasSuffix("z") || stem.hasSuffix("s") { - return stem - } - } - // trailing -s (but not -ss) → drop -s - if w.hasSuffix("s") && !w.hasSuffix("ss") { - return String(w.dropLast(1)) - } - return w - } - - /// Stems every word in a normalized string for comparison. - static func stemmedForm(_ text: String) -> String { - text.split(separator: " ").map { stemWord(String($0)) }.joined(separator: " ") - } - - /// Levenshtein edit distance between two strings. Used for typo tolerance - /// in type-ahead suggestions. O(n*m) but only called on short food names. - static func editDistance(_ a: String, _ b: String) -> Int { - let a = Array(a), b = Array(b) - let m = a.count, n = b.count - if m == 0 { return n } - if n == 0 { return m } - var prev = Array(0...n) - var curr = [Int](repeating: 0, count: n + 1) - for i in 1...m { - curr[0] = i - for j in 1...n { - let cost = a[i-1] == b[j-1] ? 0 : 1 - curr[j] = min(prev[j] + 1, curr[j-1] + 1, prev[j-1] + cost) - } - prev = curr - } - return prev[n] - } - - /// Scores how closely `name` matches `query` (both already normalized). - /// Lower = better match. Supports stem-aware comparison so "strawberry" - /// matches "strawberries" at full quality, and applies a category penalty - /// for desserts/processed items when the query is a plain food word. - /// - /// Single-word query: - /// 0 – exact match or stem-exact ("egg" → "egg", "strawberry" → "strawberries") - /// 1 – name starts with (or stemmed prefix) - /// 2 – first word exact/stem-exact - /// 3 – any word starts with - /// 4 – substring only - /// +1 penalty for dessert/processed names when query looks like a plain food - /// - /// Multi-word query: - /// 0 – exact match - /// 1 – name starts with full query OR every query word matches a - /// name-word prefix and the first word aligns - /// 2 – every query word matches a name-word prefix - /// 3 – all words present as substrings (guaranteed by filter) - static func matchScore(name: String, query q: String) -> Int { - let n = normalizeForSearch(name) - if n == q { return 0 } - - let nStemmed = stemmedForm(n) - let qStemmed = stemmedForm(q) - if nStemmed == qStemmed { return 0 } - - if n.hasPrefix(q) || nStemmed.hasPrefix(qStemmed) { return 1 } - - let qWords = q.split(separator: " ").map(String.init) - let nWords = n.split(separator: " ").map(String.init) - let qStems = qWords.map { stemWord($0) } - let nStems = nWords.map { stemWord($0) } - - if qWords.count <= 1 { - let qStem = qStems[0] - if nWords.first == q || nStems.first == qStem { return 2 } - if nWords.contains(where: { $0.hasPrefix(q) }) { return 3 } - if nStems.contains(where: { $0.hasPrefix(qStem) }) { return 3 } - return 4 - } - - // Multi-word: check if every query word is a prefix of some name word - // (with stem-aware fallback). - let allWordPrefixes = qWords.indices.allSatisfy { i in - nWords.contains(where: { $0.hasPrefix(qWords[i]) }) || - nStems.contains(where: { $0.hasPrefix(qStems[i]) }) - } - if allWordPrefixes { - if let fq = qWords.first, let fn = nWords.first, - fn.hasPrefix(fq) || stemWord(fn).hasPrefix(stemWord(fq)) { - return 1 - } - return 2 - } - return 3 - } - - /// Words that indicate a dessert or processed item. When the user's query - /// is a plain food word (e.g. "strawberry") these results should rank below - /// the whole-food match. - private static let dessertKeywords: Set = [ - "milkshake", "shake", "ice cream", "smoothie", "cake", "pie", - "cookie", "brownie", "muffin", "donut", "pastry", "candy", - "frosting", "sundae", "parfait", - ] - - /// Returns `true` when a normalized food name looks like a dessert or - /// processed item — used to add a ranking penalty for plain food queries. - static func isDessertOrProcessed(_ normalizedName: String) -> Bool { - dessertKeywords.contains(where: { normalizedName.contains($0) }) - } - /// Returns a small, curated set of foods for the empty-state "Suggestions" /// section. Values come from `generic_foods` so they are always consistent /// with search results. diff --git a/AkFit/AkFit/Views/Search/SearchView.swift b/AkFit/AkFit/Views/Search/SearchView.swift index 2a668d7..5522e0e 100644 --- a/AkFit/AkFit/Views/Search/SearchView.swift +++ b/AkFit/AkFit/Views/Search/SearchView.swift @@ -579,61 +579,8 @@ struct SearchView: View { /// names). Supports multi-word queries, stem-aware matching ("strawberry" /// finds "Strawberries"), and fuzzy fallback for typos. private func matchingSuggestions(for query: String) -> [String] { - let normalized = SupabaseFoodSearchService.applyQueryAliases( - SupabaseFoodSearchService.normalizeForSearch(query) - ) - guard normalized.count >= 1 else { return [] } - - let words = normalized.split(separator: " ").map(String.init) - let stemmedWords = words.map { SupabaseFoodSearchService.stemWord($0) } - let isPlainFoodQuery = words.count <= 2 && words.allSatisfy { $0.allSatisfy(\.isLetter) } - - // Substring + stem matching (primary) - var matches = suggestionPool.filter { term in - let n = SupabaseFoodSearchService.normalizeForSearch(term) - let nStemmed = SupabaseFoodSearchService.stemmedForm(n) - return words.allSatisfy { n.contains($0) } || - stemmedWords.allSatisfy { sw in nStemmed.contains(sw) } - } - - // Fuzzy fallback: if fewer than 3 substring matches, try edit distance - // on each word of the food name. Only for queries ≥ 3 chars to avoid - // noise on very short inputs. - if matches.count < 3 && normalized.count >= 3 { - let fuzzy = suggestionPool.filter { term in - guard !matches.contains(term) else { return false } - let nWords = SupabaseFoodSearchService.normalizeForSearch(term) - .split(separator: " ").map(String.init) - return words.allSatisfy { qw in - nWords.contains { nw in - // Allow edit distance ≤ 2, but scale: for short words (≤4 chars) only allow 1 - let maxDist = qw.count <= 4 ? 1 : 2 - return SupabaseFoodSearchService.editDistance(qw, nw) <= maxDist || - SupabaseFoodSearchService.editDistance( - SupabaseFoodSearchService.stemWord(qw), - SupabaseFoodSearchService.stemWord(nw) - ) <= maxDist - } - } - } - matches.append(contentsOf: fuzzy) - } - - return matches - .sorted { a, b in - var sa = SupabaseFoodSearchService.matchScore(name: a, query: normalized) - var sb = SupabaseFoodSearchService.matchScore(name: b, query: normalized) - if isPlainFoodQuery { - let na = SupabaseFoodSearchService.normalizeForSearch(a) - let nb = SupabaseFoodSearchService.normalizeForSearch(b) - if SupabaseFoodSearchService.isDessertOrProcessed(na) { sa += 1 } - if SupabaseFoodSearchService.isDessertOrProcessed(nb) { sb += 1 } - } - if sa != sb { return sa < sb } - return a.count < b.count - } - .prefix(6) - .map { $0 } + let queryMatch = SearchTextMatcher.queryMatch(for: query) + return SearchTextMatcher.suggestions(for: queryMatch, in: suggestionPool) } // MARK: - Search logic diff --git a/AkFit/AkFitTests/SearchTextMatcherTests.swift b/AkFit/AkFitTests/SearchTextMatcherTests.swift new file mode 100644 index 0000000..9fa8f69 --- /dev/null +++ b/AkFit/AkFitTests/SearchTextMatcherTests.swift @@ -0,0 +1,101 @@ +import Testing +@testable import AkFit + +struct SearchTextMatcherTests { + + @Test func normalizedQuery_removesApostrophesAndSplitsHyphens() { + #expect(SearchTextMatcher.normalizedQuery("McDonald's") == "mcdonalds") + #expect(SearchTextMatcher.normalizedQuery("Dave\u{2019}s Hot-Chicken") == "daves hot chicken") + } + + @Test func normalizedQuery_handlesRestaurantAliases() { + #expect(SearchTextMatcher.normalizedQuery("Chick-fil-A") == "chick fil a") + #expect(SearchTextMatcher.normalizedQuery("chick fil a") == "chick fil a") + #expect(SearchTextMatcher.normalizedQuery("In-N-Out") == "in n out") + #expect(SearchTextMatcher.normalizedQuery("innout") == "in n out") + } + + @Test func stemWord_handlesSimplePlurals() { + #expect(SearchTextMatcher.stemWord("strawberries") == "strawberry") + #expect(SearchTextMatcher.stemWord("blueberries") == "blueberry") + #expect(SearchTextMatcher.stemWord("eggs") == "egg") + #expect(SearchTextMatcher.stemWord("glass") == "glass") + } + + @Test func suggestions_matchHyphenatedRestaurantQueries() { + let pool = [ + "Chicken Breast, cooked", + "Chick-fil-A Chicken Sandwich", + "In-N-Out Burger", + ] + + #expect(SearchTextMatcher.suggestions(for: "chick fil a", in: pool).first == "Chick-fil-A Chicken Sandwich") + #expect(SearchTextMatcher.suggestions(for: "innout", in: pool).first == "In-N-Out Burger") + #expect(SearchTextMatcher.suggestions(for: "In-N-Out", in: pool).first == "In-N-Out Burger") + } + + @Test func suggestions_matchSimplePluralForms() { + let pool = [ + "Strawberries", + "Strawberry Milkshake", + "Blueberries", + ] + + let matches = SearchTextMatcher.suggestions(for: "strawberry", in: pool) + + #expect(matches.contains("Strawberries")) + #expect(matches.contains("Strawberry Milkshake")) + } + + @Test func precomputedQueryMatch_preservesWordMatchingBehavior() { + let normalized = SearchTextMatcher.normalizedQuery("strawberry") + let queryMatch = SearchTextMatcher.QueryMatch(normalizedQuery: normalized) + + #expect( + SearchTextMatcher.matchesAllQueryWords(term: "Strawberries", queryMatch: queryMatch) == + SearchTextMatcher.matchesAllQueryWords(term: "Strawberries", normalizedQuery: normalized) + ) + #expect( + SearchTextMatcher.matchesAllQueryWords(term: "Blueberries", queryMatch: queryMatch) == + SearchTextMatcher.matchesAllQueryWords(term: "Blueberries", normalizedQuery: normalized) + ) + } + + @Test func precomputedQueryMatch_preservesSuggestionResults() { + let pool = [ + "Chicken Breast, cooked", + "Chick-fil-A Chicken Sandwich", + "In-N-Out Burger", + "Strawberries", + "Strawberry Milkshake", + ] + let queryMatch = SearchTextMatcher.queryMatch(for: "strawberry") + + #expect( + SearchTextMatcher.suggestions(for: queryMatch, in: pool) == + SearchTextMatcher.suggestions(for: "strawberry", in: pool) + ) + } + + @Test func suggestions_rankWholeFoodBeforeDessertForPlainQuery() { + let pool = [ + "Strawberry Milkshake", + "Strawberry", + "Strawberry Ice Cream", + ] + + let matches = SearchTextMatcher.suggestions(for: "strawberry", in: pool) + + #expect(matches.first == "Strawberry") + } + + @Test func suggestions_returnEmptyForObviousNonMatch() { + let pool = [ + "Chicken Breast, cooked", + "Greek Yogurt, plain nonfat", + "In-N-Out Burger", + ] + + #expect(SearchTextMatcher.suggestions(for: "zzzz", in: pool).isEmpty) + } +}