From ece21c1acfa4cbefd7faa441dfd14a8d46f1d211 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Thu, 8 Oct 2026 23:18:19 -0500 Subject: [PATCH] Search & Filter tweaks --- crates/mtgc-core/src/filters.rs | 38 ++++++++++++++++++- crates/mtgc-core/src/search.rs | 66 +++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index 4f63b32..fc8897f 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -258,7 +258,16 @@ impl Catalog { sql.push_str(" AND COALESCE(lc.quantity_owned, 0) = 0"); } - sql.push_str(" ORDER BY oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?"); + sql.push_str(" ORDER BY "); + // Same reasoning as `NameIndex::search`: a card whose name *is* the query + // outranks more popular cards that merely contain it, and must not be cut + // off by the LIMIT. Bound after the WHERE values and before the LIMIT, so + // the positional order still lines up. + if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) { + sql.push_str("oc.name_lower <> ?, "); + vals.push(Value::Text(text.to_lowercase())); + } + sql.push_str("oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?"); vals.push(Value::Integer(limit as i64)); let mut stmt = self.conn.prepare(&sql)?; @@ -324,6 +333,33 @@ mod tests { assert!(all.contains(&"Serra Angel".to_string())); } + #[test] + fn a_name_search_puts_the_exactly_matching_card_first() { + let fx = owned_fixture(); + // Every one of these contains "explore"; the exact match is deliberately + // given the *worst* popularity rank, since that ordering is what buried + // it behind the others. + for (i, name) in ["Explorer's Scope", "Wildwood Explorer", "Explore"].iter().enumerate() { + fx.catalog + .conn + .execute( + "INSERT INTO ref.oracle_cards (oracle_id, name, name_lower, edhrec_rank) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![format!("oid-{i}"), name, name.to_lowercase(), i as i64], + ) + .unwrap(); + } + + let f = LibraryFilter { text: Some("explore".into()), ..Default::default() }; + assert_eq!(names(&fx.catalog.search_reference(&f, 10).unwrap())[0], "Explore"); + // And it survives a limit tight enough to have dropped it entirely. + assert_eq!(names(&fx.catalog.search_reference(&f, 1).unwrap()), vec!["Explore"]); + + // Without a text filter the popularity ordering is untouched. + let all = names(&fx.catalog.search_reference(&LibraryFilter::default(), 10).unwrap()); + assert_eq!(all[0], "Explorer's Scope"); + } + #[test] fn vanilla_only_keeps_flagged_creatures_and_is_off_by_default() { let fx = owned_fixture(); diff --git a/crates/mtgc-core/src/search.rs b/crates/mtgc-core/src/search.rs index 3de8acc..dda849e 100644 --- a/crates/mtgc-core/src/search.rs +++ b/crates/mtgc-core/src/search.rs @@ -92,9 +92,19 @@ impl NameIndex { } let mut matcher = Matcher::new(Config::DEFAULT); let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); - let mut matches = pattern.match_list(self.entries.iter(), &mut matcher); - matches.truncate(limit); - matches.into_iter().map(|(e, _score)| e.card.clone()).collect() + let matches = pattern.match_list(self.entries.iter(), &mut matcher); + // Typing a card's full name means *that* card, whatever the fuzzy score + // says. "Explore" otherwise sits below "Explorer's Scope", "Explore the + // Vastlands" and a dozen other names that merely contain the word — and + // with a small `limit` it can fall off the end entirely, making the card + // unreachable. Promote before truncating, for that reason. + let needle = query.trim().to_lowercase(); + let (mut ranked, rest): (Vec<_>, Vec<_>) = matches + .into_iter() + .partition(|(e, _)| e.card.name.to_lowercase() == needle); + ranked.extend(rest); + ranked.truncate(limit); + ranked.into_iter().map(|(e, _score)| e.card.clone()).collect() } } @@ -114,6 +124,56 @@ mod tests { assert_eq!(hits[0].name, "Llanowar Elves"); } + /// Seed the reference db with names that all contain "Explore", the real + /// case that made the card itself unreachable. + fn explore_fixture() -> crate::test_support::Fixture { + let fx = crate::test_support::make(); + for (i, name) in [ + "Explorer's Scope", + "Explore", + "Explorer of the Vastlands", + "Wildwood Explorer", + "Explosive Vegetation", + ] + .iter() + .enumerate() + { + fx.catalog + .conn + .execute( + "INSERT INTO ref.oracle_cards (oracle_id, name, name_lower, edhrec_rank) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![format!("oid-{i}"), name, name.to_lowercase(), i as i64], + ) + .unwrap(); + } + fx + } + + #[test] + fn an_exact_name_match_outranks_longer_names_containing_it() { + let fx = explore_fixture(); + let idx = fx.catalog.build_name_index().unwrap(); + + let hits = idx.search("Explore", 10); + assert_eq!(hits[0].name, "Explore"); + // Case shouldn't matter — the entry field is whatever the user typed. + assert_eq!(idx.search("explore", 10)[0].name, "Explore"); + } + + #[test] + fn the_exact_match_survives_a_limit_that_would_cut_it_off() { + // The reason promotion happens *before* truncating: rapid entry asks for + // a handful of results, and a buried exact match would be dropped + // outright rather than merely ranked low. + let fx = explore_fixture(); + let idx = fx.catalog.build_name_index().unwrap(); + + let hits = idx.search("Explore", 1); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "Explore"); + } + #[test] fn prefix_and_limit() { let fx = test_support::make();