Search & Filter tweaks

This commit is contained in:
2026-10-08 23:18:19 -05:00
parent 5b4fa81bc1
commit ece21c1acf
2 changed files with 100 additions and 4 deletions

View File

@@ -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();

View File

@@ -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();