Search & Filter tweaks
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user