From 46b1be007dd5d3db88838b2284f6d85eeab1483c Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 15 Aug 2026 14:43:42 -0400 Subject: [PATCH] feat(unit-search): filter units with a predicate, the way MegaMek does MegaMek has no database. Every unit sits in a MekSummary[] and filtering is a linear predicate scan through RowFilter.include(). At 11k units that costs under a millisecond, so helm-facet is a predicate too - which makes it the conformant reference, lets it run with no database under it, and turns any SQL translation into an optimisation that can be checked against it. The vocabulary is read off MekSearchFilter rather than invented: inclusive ranges with isBetween's quirks (int bounds against a double value, unparseable reads as unbounded), tri-state booleans on its own 0/1/2 codes, and an equipment expression tree whose leaves test each entry separately rather than summing duplicates. A differential test runs predicate and SQL over the whole library and demands the same units back. Eleven query shapes agree across 10,988 units. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 9 + Cargo.toml | 2 + crates/helm-db/Cargo.toml | 7 + crates/helm-db/tests/differential.rs | 324 ++++++++++++++++++++++ crates/helm-facet/Cargo.toml | 12 + crates/helm-facet/src/equipment.rs | 206 ++++++++++++++ crates/helm-facet/src/lib.rs | 247 +++++++++++++++++ crates/helm-facet/src/query.rs | 387 +++++++++++++++++++++++++++ scripts/check-boundaries.sh | 2 +- 9 files changed, 1195 insertions(+), 1 deletion(-) create mode 100644 crates/helm-db/tests/differential.rs create mode 100644 crates/helm-facet/Cargo.toml create mode 100644 crates/helm-facet/src/equipment.rs create mode 100644 crates/helm-facet/src/lib.rs create mode 100644 crates/helm-facet/src/query.rs diff --git a/Cargo.lock b/Cargo.lock index f949bd1..a8f0dbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,8 +160,17 @@ name = "helm-db" version = "0.1.0" dependencies = [ "helm-core", + "helm-facet", "helm-unitfile", "rusqlite", + "serde_json", +] + +[[package]] +name = "helm-facet" +version = "0.1.0" +dependencies = [ + "helm-core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 62e2e69..77e54ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ resolver = "3" members = [ "crates/helm-core", "crates/helm-unitfile", + "crates/helm-facet", "crates/helm-bridge", "crates/helm-db", "crates/helm-cli", @@ -20,6 +21,7 @@ publish = false [workspace.dependencies] helm-core = { path = "crates/helm-core" } helm-unitfile = { path = "crates/helm-unitfile" } +helm-facet = { path = "crates/helm-facet" } helm-bridge = { path = "crates/helm-bridge" } helm-db = { path = "crates/helm-db" } diff --git a/crates/helm-db/Cargo.toml b/crates/helm-db/Cargo.toml index 5b38c65..a4d113b 100644 --- a/crates/helm-db/Cargo.toml +++ b/crates/helm-db/Cargo.toml @@ -10,3 +10,10 @@ publish.workspace = true helm-core.workspace = true helm-unitfile.workspace = true rusqlite.workspace = true + +# helm-facet is the reference a SQL translation is checked against, and +# serde_json reads the bridge's output without depending on the producer crate +# that helm-db is not allowed to see. Test-only, so neither reaches the build. +[dev-dependencies] +helm-facet.workspace = true +serde_json.workspace = true diff --git a/crates/helm-db/tests/differential.rs b/crates/helm-db/tests/differential.rs new file mode 100644 index 0000000..aa19fb6 --- /dev/null +++ b/crates/helm-db/tests/differential.rs @@ -0,0 +1,324 @@ +//! Check that filtering a database agrees with filtering in memory. +//! +//! `helm-facet` is the reference: it is a predicate over one unit, which is +//! the same shape MegaMek's own filtering has. Anything that answers the same +//! question with SQL is an optimisation, and an optimisation that disagrees +//! with its reference is a bug — so this runs both over the whole library and +//! demands the same units back, by name and in the same order. +//! +//! It needs a real MegaMek install, so it does not run by default: +//! +//! HELM_MEGAMEK=/path/to/megamek \ +//! HELM_BRIDGE=/path/to/bridge-output \ +//! cargo test -p helm-db -- --ignored --nocapture +//! +//! Without HELM_BRIDGE the computed columns are absent, and the queries that +//! filter on battle value are skipped rather than quietly passing. + +use std::path::PathBuf; + +use helm_core::{ComputedStats, Provenance}; +use helm_facet::{Equipment, FacetQuery, Range, Tri, UnitFacets}; +use rusqlite::Connection; + +struct Fixture { + facets: Vec, + db: Connection, + has_stats: bool, + _dir: tempdir::TempDir, +} + +/// A throwaway directory that cleans itself up. Written here rather than +/// pulled in, so the crate gains no dependency for one test. +mod tempdir { + use std::path::{Path, PathBuf}; + + pub struct TempDir(PathBuf); + + impl TempDir { + pub fn new(tag: &str) -> std::io::Result { + // The pid is enough: two runs of one test binary do not overlap. + let path = std::env::temp_dir().join(format!("helm-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path)?; + Ok(TempDir(path)) + } + + pub fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } +} + +fn fixture() -> Option { + let mm = PathBuf::from(std::env::var("HELM_MEGAMEK").ok()?); + let zip = mm.join("data/mekfiles/unit_files.zip"); + let file = std::fs::File::open(&zip).expect("unit_files.zip"); + let library = helm_unitfile::read_zip(file).expect("read library"); + + let (stats, catalogue) = match std::env::var("HELM_BRIDGE").ok() { + Some(dir) => { + let dir = PathBuf::from(dir); + let u = helm_bridge_read_units(&dir.join("units.jsonl")); + let c = dir.join("equipment.jsonl"); + let cat = c.is_file().then(|| helm_bridge_read_catalogue(&c)); + (u, cat) + } + None => (Vec::new(), None), + }; + let has_stats = !stats.is_empty(); + + let by_name: std::collections::HashMap<&str, &ComputedStats> = + stats.iter().map(|s| (s.name.as_str(), s)).collect(); + let facets: Vec = library + .units + .iter() + .map(|u| UnitFacets::from_unit(u, by_name.get(u.name.as_str()).copied())) + .collect(); + + let dir = tempdir::TempDir::new("differential").expect("temp dir"); + let dest = dir.path().join("helm.sqlite"); + let inputs = helm_db::Inputs { + stats: &stats, + catalogue: catalogue.as_ref(), + }; + helm_db::build(&library, &inputs, &Provenance::from_bridge("test"), &dest).expect("build"); + + Some(Fixture { + facets, + db: Connection::open(&dest).expect("open"), + has_stats, + _dir: dir, + }) +} + +// helm-bridge is a producer and helm-db must not depend on it, so the test +// reads the same JSON Lines directly rather than pulling the crate in and +// breaking the boundary it is meant to respect. +fn helm_bridge_read_units(path: &std::path::Path) -> Vec { + let text = std::fs::read_to_string(path).expect("units.jsonl"); + text.lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + let v: serde_json::Value = serde_json::from_str(l).ok()?; + let mut s = ComputedStats::new(v.get("name")?.as_str()?); + s.battle_value = v.get("bv").and_then(|x| x.as_i64()); + s.weight_class = v.get("weightClass").and_then(|x| x.as_i64()); + s.canon = v.get("canon").and_then(|x| x.as_bool()); + s.invalid = v.get("invalid").and_then(|x| x.as_bool()); + s.omni = v.get("omni").and_then(|x| x.as_bool()); + s.clan = v.get("clan").and_then(|x| x.as_bool()); + Some(s) + }) + .collect() +} + +fn helm_bridge_read_catalogue(path: &std::path::Path) -> helm_core::Catalogue { + let text = std::fs::read_to_string(path).expect("equipment.jsonl"); + let entries = text + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + let v: serde_json::Value = serde_json::from_str(l).ok()?; + let internal = v.get("_internalName")?.as_str()?.to_string(); + let name = v + .get("_name") + .and_then(|x| x.as_str()) + .unwrap_or(&internal) + .to_string(); + Some(helm_core::EquipmentEntry { + internal_name: internal, + name, + ..Default::default() + }) + }) + .collect(); + helm_core::Catalogue::new(entries) +} + +/// Names the predicate selects, in library order. +fn by_predicate(f: &Fixture, q: &FacetQuery) -> Vec { + f.facets + .iter() + .filter(|u| q.matches(u)) + .map(|u| u.name.clone()) + .collect() +} + +/// Names SQL selects, in the same order (unit_id follows library order). +fn by_sql(f: &Fixture, where_clause: &str) -> Vec { + let sql = format!("SELECT name FROM units WHERE {where_clause} ORDER BY unit_id"); + let mut stmt = f.db.prepare(&sql).expect("prepare"); + stmt.query_map([], |r| r.get::<_, String>(0)) + .expect("query") + .map(|r| r.expect("row")) + .collect() +} + +fn agree(f: &Fixture, label: &str, q: &FacetQuery, where_clause: &str) { + let a = by_predicate(f, q); + let b = by_sql(f, where_clause); + assert_eq!( + a.len(), + b.len(), + "{label}: predicate matched {} units, SQL matched {}", + a.len(), + b.len() + ); + assert_eq!(a, b, "{label}: same count, different units"); + println!(" {label}: {} units, both agree", a.len()); +} + +#[test] +#[ignore = "needs a MegaMek install; set HELM_MEGAMEK"] +fn predicate_and_sql_select_the_same_units() { + let Some(f) = fixture() else { + panic!("set HELM_MEGAMEK to a MegaMek install"); + }; + println!("library: {} units", f.facets.len()); + + agree(&f, "every unit", &FacetQuery::new(), "1=1"); + + agree( + &f, + "unit_type = Mek", + &FacetQuery { + unit_types: vec!["Mek".into()], + ..Default::default() + }, + "unit_type = 'Mek'", + ); + + agree( + &f, + "Clan or mixed tech base", + &FacetQuery { + tech_bases: vec!["Clan".into(), "Mixed Clan".into()], + ..Default::default() + }, + "tech_base IN ('Clan', 'Mixed Clan')", + ); + + agree( + &f, + "introduced 3050-3060", + &FacetQuery { + year: Range::between(3050, 3060), + ..Default::default() + }, + "year IS NOT NULL AND year BETWEEN 3050 AND 3060", + ); + + agree( + &f, + "55 to 75 tons, jump capable", + &FacetQuery { + tons: Range::between(55, 75), + jump_mp: Range::at_least(1), + ..Default::default() + }, + "mass IS NOT NULL AND mass BETWEEN 55 AND 75 \ + AND jump_mp IS NOT NULL AND jump_mp >= 1", + ); + + agree( + &f, + "name contains 'atlas'", + &FacetQuery { + name: Some("atlas".into()), + ..Default::default() + }, + // The predicate normalises both sides; for a plain alphabetic needle + // LIKE is equivalent. + "LOWER(name) LIKE '%atlas%'", + ); + + if f.has_stats { + agree( + &f, + "Clan heavy Meks by 3050, BV 1800-2200", + &FacetQuery { + unit_types: vec!["Mek".into()], + tech_bases: vec!["Clan".into()], + weight_classes: vec![3], + year: Range::at_most(3050), + battle_value: Range::between(1800, 2200), + ..Default::default() + }, + "unit_type = 'Mek' AND tech_base = 'Clan' AND weight_class = 3 \ + AND year IS NOT NULL AND year <= 3050 \ + AND bv IS NOT NULL AND bv BETWEEN 1800 AND 2200", + ); + + agree( + &f, + "canon and valid", + &FacetQuery { + canon: Tri::Yes, + invalid: Tri::No, + ..Default::default() + }, + "canon = 1 AND invalid = 0", + ); + + agree( + &f, + "omni units only", + &FacetQuery { + omni: Tri::Yes, + ..Default::default() + }, + "omni = 1", + ); + } else { + println!(" (battle value queries skipped: no HELM_BRIDGE)"); + } +} + +#[test] +#[ignore = "needs a MegaMek install; set HELM_MEGAMEK"] +fn equipment_expressions_agree_with_a_join() { + let Some(f) = fixture() else { + panic!("set HELM_MEGAMEK to a MegaMek install"); + }; + + // What this proves and what it does not: both sides read the same loadout, + // so agreement here says the SQL translation matches the predicate. It says + // nothing about agreeing with MegaMek, because helm's loadout is not yet + // MekSummary's - see the note on UnitFacets::loadout. Hence the internal + // key below rather than a name a person would type: 126 units record + // `ISGaussRifle` and 320 record `Gauss Rifle`, and MegaMek would see one + // vocabulary where helm currently sees both. + let q = FacetQuery { + equipment: Some(Equipment::carries("ISGaussRifle")), + ..Default::default() + }; + let a = by_predicate(&f, &q); + let b = by_sql( + &f, + "unit_id IN (SELECT unit_id FROM unit_equipment WHERE name = 'ISGaussRifle')", + ); + assert_eq!(a, b, "carries ISGaussRifle"); + println!(" carries ISGaussRifle: {} units, both agree", a.len()); + + // And the negation, which is the leaf rule that reads backwards. + let q = FacetQuery { + unit_types: vec!["Mek".into()], + equipment: Some(Equipment::lacks("ISGaussRifle")), + ..Default::default() + }; + let a = by_predicate(&f, &q); + let b = by_sql( + &f, + "unit_type = 'Mek' AND unit_id NOT IN \ + (SELECT unit_id FROM unit_equipment WHERE name = 'ISGaussRifle')", + ); + assert_eq!(a, b, "Meks lacking ISGaussRifle"); + println!(" Meks lacking ISGaussRifle: {} units, both agree", a.len()); +} diff --git a/crates/helm-facet/Cargo.toml b/crates/helm-facet/Cargo.toml new file mode 100644 index 0000000..510ce8d --- /dev/null +++ b/crates/helm-facet/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "helm-facet" +version.workspace = true +edition.workspace = true +publish.workspace = true + +# A query here is a predicate over one unit, so this crate needs nothing to +# evaluate one - no database, no I/O. That is what lets the same query run in a +# browser and be the reference a SQL translation is checked against. +# scripts/check-boundaries.sh enforces it. +[dependencies] +helm-core.workspace = true diff --git a/crates/helm-facet/src/equipment.rs b/crates/helm-facet/src/equipment.rs new file mode 100644 index 0000000..d2b4763 --- /dev/null +++ b/crates/helm-facet/src/equipment.rs @@ -0,0 +1,206 @@ +//! The equipment half of a query: a boolean expression over a loadout. +//! +//! MegaMek builds this as an `ExpNode` tree with a `BoolOp` per branch, parsed +//! from the tokens its advanced search dialog collects, and evaluates it +//! recursively against `MekSummary`'s parallel `equipmentNames` and +//! `equipmentQuantities` lists. This is the same tree with the same +//! evaluation rules. +//! +//! The rules are worth stating because two of them are not what you would +//! guess, and both are reproduced here on purpose: +//! +//! * A leaf tests **each entry separately and does not sum them**. A unit +//! listing `Medium Laser` twice at quantity 2 does not satisfy "at least 3 +//! Medium Laser", because neither entry alone reaches 3. See +//! [`Equipment::matches`]. +//! * "At most" is really "not at least". MegaMek's `!atLeast` leaf fails the +//! moment it finds an entry with `quantity >= qty`, and otherwise passes — +//! so `at_least: false, quantity: 1` reads as "does not carry this at all". + +/// A node in an equipment expression. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Equipment { + /// A leaf: this named equipment, in this quantity. + /// + /// `name` is matched by exact string equality against what the loadout + /// records, which is MegaMek's internal key — `ISGaussRifle`, not + /// `Gauss Rifle`. Callers that start from something a human typed should + /// resolve it through [`helm_core::Catalogue`] first. + Name { + name: String, + quantity: i64, + /// True for "at least this many", false for "fewer than this many". + at_least: bool, + }, + /// Every child must match. + All(Vec), + /// At least one child must match. MegaMek's `NOP` branch behaves this way + /// too: it seeds its fold with `false` and ORs. + Any(Vec), +} + +impl Equipment { + /// "Carries at least one of this." + pub fn carries(name: impl Into) -> Self { + Equipment::Name { + name: name.into(), + quantity: 1, + at_least: true, + } + } + + /// "Carries at least `quantity` of this." + pub fn carries_at_least(name: impl Into, quantity: i64) -> Self { + Equipment::Name { + name: name.into(), + quantity, + at_least: true, + } + } + + /// "Does not carry this." + pub fn lacks(name: impl Into) -> Self { + Equipment::Name { + name: name.into(), + quantity: 1, + at_least: false, + } + } + + /// Evaluate against a loadout of `(internal name, quantity)` pairs. + /// + /// The traversal follows MegaMek's `evaluate`: an `All` node seeds `true` + /// and folds with AND, an `Any` node seeds `false` and folds with OR, and + /// a leaf walks the loadout entry by entry. + pub fn matches(&self, loadout: &[(String, i64)]) -> bool { + match self { + Equipment::All(children) => children.iter().all(|c| c.matches(loadout)), + Equipment::Any(children) => children.iter().any(|c| c.matches(loadout)), + Equipment::Name { + name, + quantity, + at_least, + } => { + for (entry, entry_qty) in loadout { + if entry != name { + continue; + } + // Per entry, not summed across entries. MegaMek returns as + // soon as one entry decides the leaf, so a second entry of + // the same equipment never contributes. + if entry_qty >= quantity { + return *at_least; + } + } + // Nothing in the loadout decided it. Wanting the equipment + // means failing; wanting it absent means passing. + !at_least + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn loadout(pairs: &[(&str, i64)]) -> Vec<(String, i64)> { + pairs.iter().map(|(n, q)| ((*n).to_string(), *q)).collect() + } + + #[test] + fn carries_finds_equipment_by_internal_key() { + let l = loadout(&[("ISGaussRifle", 1), ("Medium Laser", 2)]); + assert!(Equipment::carries("ISGaussRifle").matches(&l)); + assert!(!Equipment::carries("CLGaussRifle").matches(&l)); + } + + #[test] + fn quantity_is_a_minimum() { + let l = loadout(&[("Medium Laser", 4)]); + assert!(Equipment::carries_at_least("Medium Laser", 4).matches(&l)); + assert!(Equipment::carries_at_least("Medium Laser", 2).matches(&l)); + assert!(!Equipment::carries_at_least("Medium Laser", 5).matches(&l)); + } + + #[test] + fn lacks_passes_only_when_the_equipment_is_absent() { + let has = loadout(&[("ISGaussRifle", 1)]); + let hasnt = loadout(&[("Medium Laser", 2)]); + assert!(!Equipment::lacks("ISGaussRifle").matches(&has)); + assert!(Equipment::lacks("ISGaussRifle").matches(&hasnt)); + } + + // Upstream behaviour, reproduced on purpose: a leaf decides on the first + // entry that reaches the quantity and never sums duplicates. Two entries of + // 2 do not satisfy "at least 3". + #[test] + fn duplicate_entries_are_not_summed() { + let l = loadout(&[("Medium Laser", 2), ("Medium Laser", 2)]); + assert!(Equipment::carries_at_least("Medium Laser", 2).matches(&l)); + assert!(!Equipment::carries_at_least("Medium Laser", 3).matches(&l)); + assert!(!Equipment::carries_at_least("Medium Laser", 4).matches(&l)); + } + + #[test] + fn all_requires_every_child() { + let l = loadout(&[("ISGaussRifle", 1), ("Medium Laser", 2)]); + assert!( + Equipment::All(vec![ + Equipment::carries("ISGaussRifle"), + Equipment::carries("Medium Laser"), + ]) + .matches(&l) + ); + assert!( + !Equipment::All(vec![ + Equipment::carries("ISGaussRifle"), + Equipment::carries("ISERPPC"), + ]) + .matches(&l) + ); + } + + #[test] + fn any_requires_one_child() { + let l = loadout(&[("ISGaussRifle", 1)]); + assert!( + Equipment::Any(vec![ + Equipment::carries("ISERPPC"), + Equipment::carries("ISGaussRifle"), + ]) + .matches(&l) + ); + assert!( + !Equipment::Any(vec![ + Equipment::carries("ISERPPC"), + Equipment::carries("CLERPPC"), + ]) + .matches(&l) + ); + } + + #[test] + fn an_empty_all_matches_and_an_empty_any_does_not() { + let l = loadout(&[("Medium Laser", 1)]); + // Seeds of the two folds, same as upstream. + assert!(Equipment::All(vec![]).matches(&l)); + assert!(!Equipment::Any(vec![]).matches(&l)); + } + + #[test] + fn trees_nest() { + // A Gauss rifle, plus either an ER PPC or at least four medium lasers. + let expr = Equipment::All(vec![ + Equipment::carries("ISGaussRifle"), + Equipment::Any(vec![ + Equipment::carries("ISERPPC"), + Equipment::carries_at_least("Medium Laser", 4), + ]), + ]); + assert!(expr.matches(&loadout(&[("ISGaussRifle", 1), ("Medium Laser", 4)]))); + assert!(expr.matches(&loadout(&[("ISGaussRifle", 1), ("ISERPPC", 1)]))); + assert!(!expr.matches(&loadout(&[("ISGaussRifle", 1), ("Medium Laser", 2)]))); + assert!(!expr.matches(&loadout(&[("ISERPPC", 1)]))); + } +} diff --git a/crates/helm-facet/src/lib.rs b/crates/helm-facet/src/lib.rs new file mode 100644 index 0000000..af63e14 --- /dev/null +++ b/crates/helm-facet/src/lib.rs @@ -0,0 +1,247 @@ +//! Filtering units, meaning the same thing MegaMek means by it. +//! +//! MegaMek has no database. Every unit sits in a `MekSummary[]` in memory and +//! filtering is a linear predicate scan through Swing's `RowFilter.include()`, +//! one call per row, with `MekSearchFilter` supplying the predicate. That is +//! not the oversight it looks like: a linear scan over 10,988 units costs well +//! under a millisecond, and indexing 11k rows would buy a fraction of one. +//! +//! So a query here is a predicate too — [`FacetQuery::matches`] over one +//! [`UnitFacets`] — and that is deliberate on three counts. It is the +//! conformant reference, because it is the same shape as the thing being +//! conformed to. It runs anywhere, with no database under it. And a SQL +//! translation becomes an *optimisation* that can be differentially tested +//! against it over the whole library, which catches translation bugs for free. +//! +//! The vocabulary is modelled on `MekSearchFilter` rather than invented, since +//! matching its vocabulary is most of what conformance means. It has three +//! kinds of filter and this has the same three: [`Range`], [`Tri`] and an +//! equipment [`Equipment`] expression tree. + +use helm_core::normalize; + +mod equipment; +mod query; + +pub use equipment::Equipment; +pub use query::{FacetQuery, UnitFacets}; + +/// An inclusive numeric range where either end may be open. +/// +/// Mirrors MegaMek's `StringUtil.isBetween(double, String, String)`, which is +/// where the surprising parts come from: +/// +/// * both ends absent means no filter at all, so the range matches everything; +/// * bounds are parsed as **integers** even when the value being tested is a +/// double, so a fractional bound never narrows anything; +/// * an unparseable bound falls back to unbounded rather than to an error, +/// because `toInt` takes a fallback and the caller passes `MIN_VALUE` or +/// `MAX_VALUE`; +/// * both ends are inclusive. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Range { + pub start: Option, + pub end: Option, +} + +impl Range { + /// An open range, which filters nothing. + pub fn any() -> Self { + Range::default() + } + + pub fn new(start: Option, end: Option) -> Self { + Range { start, end } + } + + pub fn at_least(start: i64) -> Self { + Range::new(Some(start), None) + } + + pub fn at_most(end: i64) -> Self { + Range::new(None, Some(end)) + } + + pub fn between(start: i64, end: i64) -> Self { + Range::new(Some(start), Some(end)) + } + + /// Build from the text a UI would collect, the way MegaMek does. + /// + /// Empty *or* unparseable becomes unbounded — `toInt(s, fallback)` returns + /// the fallback for both, so "abc" and "" filter identically. Reproducing + /// that here keeps the surprise at the edge instead of in the predicate. + pub fn from_text(start: &str, end: &str) -> Self { + Range { + start: start.trim().parse::().ok(), + end: end.trim().parse::().ok(), + } + } + + /// True when this range constrains nothing. + pub fn is_open(&self) -> bool { + self.start.is_none() && self.end.is_none() + } + + /// Test a value. An open range accepts everything, including a value the + /// caller does not have. + pub fn matches(&self, value: Option) -> bool { + if self.is_open() { + return true; + } + // A constrained range cannot judge a value that is not there. + // + // This is a deliberate deviation: MekSummary's fields are Java + // primitives and are never absent, so MegaMek has no case for it. helm + // can be built with no producer, leaving battle value null, and + // treating unknown as passing a `bv <= 1000` filter would be worse + // than treating it as failing. + let Some(value) = value else { + return false; + }; + if let Some(start) = self.start + && value < start as f64 + { + return false; + } + if let Some(end) = self.end + && value > end as f64 + { + return false; + } + true + } +} + +/// A three-state boolean filter: don't care, must be true, must be false. +/// +/// MegaMek spells this as an `int` — `isMatch(int i, boolean b)` returns `b` +/// for 1, `!b` for 2, and true for anything else — which is the any/yes/no +/// dropdown in its search dialog. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Tri { + #[default] + Any, + Yes, + No, +} + +impl Tri { + /// MegaMek's own encoding, so a filter round-tripped through its numbers + /// means the same thing. Anything other than 1 or 2 is "don't care". + pub fn from_code(code: i32) -> Self { + match code { + 1 => Tri::Yes, + 2 => Tri::No, + _ => Tri::Any, + } + } + + pub fn matches(self, value: Option) -> bool { + match self { + Tri::Any => true, + // As with Range, a filter that asks a question cannot be satisfied + // by a unit that has no answer. + Tri::Yes => value == Some(true), + Tri::No => value == Some(false), + } + } +} + +/// Case- and punctuation-insensitive substring test. +/// +/// Used for the free-text name filter. Normalising both sides is the same +/// trick the equipment lookups use: it makes "timber wolf" find +/// `Mad Cat (Timber Wolf) B`. +pub(crate) fn contains_normalized(haystack: &str, needle: &str) -> bool { + if needle.trim().is_empty() { + return true; + } + normalize(haystack).contains(&normalize(needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_open_range_filters_nothing() { + let r = Range::any(); + assert!(r.matches(Some(1.0))); + assert!(r.matches(Some(-9999.0))); + // Even a value that is not there passes, because nothing was asked. + assert!(r.matches(None)); + } + + #[test] + fn range_ends_are_both_inclusive() { + let r = Range::between(1800, 2200); + assert!(r.matches(Some(1800.0))); + assert!(r.matches(Some(2200.0))); + assert!(!r.matches(Some(1799.0))); + assert!(!r.matches(Some(2201.0))); + } + + #[test] + fn one_open_end_bounds_only_the_other() { + assert!(Range::at_least(100).matches(Some(1e9))); + assert!(!Range::at_least(100).matches(Some(99.0))); + assert!(Range::at_most(100).matches(Some(-1e9))); + assert!(!Range::at_most(100).matches(Some(101.0))); + } + + // MegaMek's toInt returns the caller's fallback for unparseable text as + // well as for empty, so "abc" and "" filter identically. + #[test] + fn unparseable_text_bounds_read_as_unbounded() { + let r = Range::from_text("abc", ""); + assert!(r.is_open()); + assert!(r.matches(Some(12345.0))); + + let r = Range::from_text("10", "not a number"); + assert_eq!(r, Range::at_least(10)); + } + + // Deliberate deviation, documented on Range::matches: MekSummary fields are + // primitives and never absent, so MegaMek has no unknown case. + #[test] + fn a_constrained_range_rejects_an_unknown_value() { + assert!(!Range::at_most(1000).matches(None)); + assert!(!Range::between(1, 2).matches(None)); + } + + #[test] + fn tri_uses_megameks_own_codes() { + assert_eq!(Tri::from_code(0), Tri::Any); + assert_eq!(Tri::from_code(1), Tri::Yes); + assert_eq!(Tri::from_code(2), Tri::No); + assert_eq!(Tri::from_code(7), Tri::Any); + } + + #[test] + fn tri_any_accepts_anything_including_the_unknown() { + assert!(Tri::Any.matches(Some(true))); + assert!(Tri::Any.matches(Some(false))); + assert!(Tri::Any.matches(None)); + + assert!(Tri::Yes.matches(Some(true))); + assert!(!Tri::Yes.matches(Some(false))); + assert!(!Tri::Yes.matches(None)); + + assert!(Tri::No.matches(Some(false))); + assert!(!Tri::No.matches(Some(true))); + assert!(!Tri::No.matches(None)); + } + + #[test] + fn normalized_substring_ignores_case_and_punctuation() { + assert!(contains_normalized( + "Mad Cat (Timber Wolf) B", + "timber wolf" + )); + assert!(contains_normalized("Atlas AS7-D", "as7d")); + assert!(!contains_normalized("Atlas AS7-D", "banshee")); + // An empty needle is not a filter. + assert!(contains_normalized("anything", " ")); + } +} diff --git a/crates/helm-facet/src/query.rs b/crates/helm-facet/src/query.rs new file mode 100644 index 0000000..8036200 --- /dev/null +++ b/crates/helm-facet/src/query.rs @@ -0,0 +1,387 @@ +//! The query itself, and the flattened view of a unit it filters. + +use helm_core::{ComputedStats, Unit}; + +use crate::{Equipment, Range, Tri, contains_normalized}; + +/// The subset of a unit a query looks at. +/// +/// Flattened deliberately. A query has to run over a parsed [`Unit`] joined to +/// its [`ComputedStats`] in one place, and over a database row in another, and +/// both must mean the same thing — so both build one of these and the +/// predicate has a single shape to reason about. It is also what makes the +/// differential test between this predicate and a SQL translation possible at +/// all. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct UnitFacets { + pub name: String, + pub chassis: String, + pub unit_type: Option, + pub tech_base: Option, + pub role: Option, + pub source: Option, + pub weight_class: Option, + pub year: Option, + pub tons: Option, + pub battle_value: Option, + pub walk_mp: Option, + pub jump_mp: Option, + pub canon: Option, + pub invalid: Option, + pub omni: Option, + pub clan: Option, + /// `(equipment name, quantity)`, the shape MegaMek evaluates against. + /// + /// **This is not yet MegaMek's vocabulary, so equipment filtering is not + /// yet conformant.** The evaluation rules in [`Equipment`] match + /// `MekSearchFilter` exactly; what they are evaluated *against* does not. + /// Measured over the 0.51.0 library: 2,108 units have a loadout set + /// identical to `MekSummary.equipmentNames`, and 8,493 differ, for three + /// separate reasons. + /// + /// * `.blk` mounts keep suffixes MegaMek strips — `ISArrowIV(ST)` for a + /// turret mount, `EXTERNAL PowerGenerator:SIZE:16.0` for variable-size + /// equipment. + /// * A `.mtf` `Weapons:` block lists weapons only, where MekSummary also + /// carries structure, armour, heat sinks and ammunition — an Atlas + /// AS7-D has four entries here and ten there. + /// * The same weapon is spelled differently in the two places a `.mtf` + /// names it: `AC/20` in the weapons block, `Autocannon/20` in the + /// critical slots, and MekSummary uses the latter. + /// + /// Closing this means resolving mount names through + /// [`helm_core::Catalogue`] and taking the loadout from the critical slots + /// rather than the weapons block. Until then a query by equipment answers + /// a question about the file, which is a narrower thing than the question + /// MegaMek answers. + pub loadout: Vec<(String, i64)>, +} + +impl UnitFacets { + /// Build from a parsed unit and, where there is one, its computed record. + /// + /// The loadout is collapsed to one entry per distinct name with a count, + /// because `helm-unitfile` records one row per mount while MekSummary + /// records a name and a quantity, and the equipment expression is written + /// against the latter. + pub fn from_unit(unit: &Unit, stats: Option<&ComputedStats>) -> Self { + let mut loadout: Vec<(String, i64)> = Vec::new(); + for mount in &unit.equipment { + match loadout.iter_mut().find(|(n, _)| *n == mount.name) { + Some((_, qty)) => *qty += 1, + None => loadout.push((mount.name.clone(), 1)), + } + } + + UnitFacets { + name: unit.name.clone(), + chassis: unit.chassis.clone(), + unit_type: unit.unit_type.clone(), + tech_base: unit.tech_base.clone(), + role: unit.role.clone(), + source: unit.source.clone(), + weight_class: stats.and_then(|s| s.weight_class), + year: unit.year, + tons: unit.mass, + battle_value: stats.and_then(|s| s.battle_value), + walk_mp: unit.walk_mp, + jump_mp: unit.jump_mp, + canon: stats.and_then(|s| s.canon), + invalid: stats.and_then(|s| s.invalid), + omni: stats.and_then(|s| s.omni), + clan: stats.and_then(|s| s.clan), + loadout, + } + } +} + +/// A filter over units. +/// +/// Every field defaults to "no opinion", so [`FacetQuery::default`] matches +/// everything and a query is built by narrowing. That mirrors the dialog this +/// is modelled on, where an untouched control filters nothing. +#[derive(Debug, Clone, Default)] +pub struct FacetQuery { + /// Free text over the display name, case and punctuation insensitive. + pub name: Option, + /// Any-of. Empty means no opinion, which is not the same as "none". + pub unit_types: Vec, + pub tech_bases: Vec, + pub roles: Vec, + pub weight_classes: Vec, + + pub year: Range, + pub tons: Range, + pub battle_value: Range, + pub walk_mp: Range, + pub jump_mp: Range, + + pub canon: Tri, + pub invalid: Tri, + pub omni: Tri, + pub clan: Tri, + + pub equipment: Option, +} + +impl FacetQuery { + pub fn new() -> Self { + FacetQuery::default() + } + + /// Does this unit pass every filter? + /// + /// Cheap scalar tests run before the equipment tree, which is the only + /// part that walks a list. + pub fn matches(&self, u: &UnitFacets) -> bool { + if let Some(text) = &self.name + && !contains_normalized(&u.name, text) + { + return false; + } + + if !any_of(&self.unit_types, u.unit_type.as_deref()) { + return false; + } + if !any_of(&self.tech_bases, u.tech_base.as_deref()) { + return false; + } + if !any_of(&self.roles, u.role.as_deref()) { + return false; + } + if !self.weight_classes.is_empty() + && !u + .weight_class + .is_some_and(|w| self.weight_classes.contains(&w)) + { + return false; + } + + if !self.year.matches(u.year.map(|v| v as f64)) + || !self.tons.matches(u.tons) + || !self.battle_value.matches(u.battle_value.map(|v| v as f64)) + || !self.walk_mp.matches(u.walk_mp.map(|v| v as f64)) + || !self.jump_mp.matches(u.jump_mp.map(|v| v as f64)) + { + return false; + } + + if !self.canon.matches(u.canon) + || !self.invalid.matches(u.invalid) + || !self.omni.matches(u.omni) + || !self.clan.matches(u.clan) + { + return false; + } + + if let Some(expr) = &self.equipment + && !expr.matches(&u.loadout) + { + return false; + } + + true + } + + /// Filter a slice, in order. + pub fn filter<'a>(&self, units: &'a [UnitFacets]) -> Vec<&'a UnitFacets> { + units.iter().filter(|u| self.matches(u)).collect() + } +} + +/// An empty set of choices is "no opinion", not "nothing matches". +/// +/// A unit with no value for the field cannot satisfy a filter that names +/// specific values, for the same reason a constrained [`Range`] rejects an +/// unknown number. +fn any_of(choices: &[String], value: Option<&str>) -> bool { + if choices.is_empty() { + return true; + } + value.is_some_and(|v| choices.iter().any(|c| c == v)) +} + +#[cfg(test)] +mod tests { + use super::*; + use helm_core::Mount; + + fn facets(name: &str) -> UnitFacets { + UnitFacets { + name: name.to_string(), + chassis: name.to_string(), + unit_type: Some("Mek".into()), + tech_base: Some("Inner Sphere".into()), + role: Some("Juggernaut".into()), + weight_class: Some(4), + year: Some(2755), + tons: Some(100.0), + battle_value: Some(1897), + walk_mp: Some(3), + jump_mp: Some(0), + canon: Some(true), + invalid: Some(false), + ..Default::default() + } + } + + #[test] + fn a_default_query_matches_everything() { + assert!(FacetQuery::new().matches(&facets("Atlas AS7-D"))); + assert!(FacetQuery::new().matches(&UnitFacets::default())); + } + + #[test] + fn an_empty_choice_list_is_no_opinion() { + let q = FacetQuery { + unit_types: vec![], + ..Default::default() + }; + assert!(q.matches(&facets("Atlas AS7-D"))); + } + + #[test] + fn choices_are_any_of() { + let q = FacetQuery { + unit_types: vec!["Tank".into(), "Mek".into()], + ..Default::default() + }; + assert!(q.matches(&facets("Atlas AS7-D"))); + + let q = FacetQuery { + unit_types: vec!["Tank".into()], + ..Default::default() + }; + assert!(!q.matches(&facets("Atlas AS7-D"))); + } + + #[test] + fn filters_combine_with_and() { + let q = FacetQuery { + tech_bases: vec!["Inner Sphere".into()], + battle_value: Range::between(1800, 2000), + ..Default::default() + }; + assert!(q.matches(&facets("Atlas AS7-D"))); + + let q = FacetQuery { + tech_bases: vec!["Clan".into()], + battle_value: Range::between(1800, 2000), + ..Default::default() + }; + assert!(!q.matches(&facets("Atlas AS7-D"))); + } + + #[test] + fn name_search_is_forgiving_about_case_and_punctuation() { + let q = FacetQuery { + name: Some("timber wolf".into()), + ..Default::default() + }; + assert!(q.matches(&facets("Mad Cat (Timber Wolf) B"))); + assert!(!q.matches(&facets("Atlas AS7-D"))); + } + + #[test] + fn equipment_narrows_alongside_the_scalars() { + let mut u = facets("Atlas AS7-D"); + u.loadout = vec![("Medium Laser".into(), 4), ("AC/20".into(), 1)]; + + let q = FacetQuery { + tech_bases: vec!["Inner Sphere".into()], + equipment: Some(Equipment::carries("AC/20")), + ..Default::default() + }; + assert!(q.matches(&u)); + + let q = FacetQuery { + tech_bases: vec!["Inner Sphere".into()], + equipment: Some(Equipment::carries("ISGaussRifle")), + ..Default::default() + }; + assert!(!q.matches(&u)); + } + + // helm-unitfile records one row per mount; the equipment expression is + // written against MekSummary's name-and-quantity shape, so building facets + // has to collapse them or every quantity reads as 1. + #[test] + fn building_facets_collapses_mounts_into_quantities() { + let unit = Unit { + chassis: "Atlas".into(), + model: "AS7-D".into(), + name: "Atlas AS7-D".into(), + equipment: vec![ + Mount { + name: "Medium Laser".into(), + location: Some("Left Arm".into()), + rear: false, + }, + Mount { + name: "Medium Laser".into(), + location: Some("Right Arm".into()), + rear: false, + }, + Mount { + name: "AC/20".into(), + location: Some("Right Torso".into()), + rear: false, + }, + Mount { + name: "Medium Laser".into(), + location: Some("Center Torso".into()), + rear: true, + }, + ], + ..Default::default() + }; + let f = UnitFacets::from_unit(&unit, None); + assert_eq!(f.loadout.len(), 2); + assert_eq!(f.loadout[0], ("Medium Laser".to_string(), 3)); + assert_eq!(f.loadout[1], ("AC/20".to_string(), 1)); + assert!(Equipment::carries_at_least("Medium Laser", 3).matches(&f.loadout)); + } + + #[test] + fn computed_facets_come_from_the_stats_and_are_absent_without_them() { + let unit = Unit { + chassis: "Atlas".into(), + model: "AS7-D".into(), + name: "Atlas AS7-D".into(), + year: Some(2755), + ..Default::default() + }; + + let bare = UnitFacets::from_unit(&unit, None); + assert_eq!(bare.battle_value, None); + // Declared values survive with no producer. + assert_eq!(bare.year, Some(2755)); + // And a battle value filter can no longer be satisfied, by design. + let q = FacetQuery { + battle_value: Range::at_most(5000), + ..Default::default() + }; + assert!(!q.matches(&bare)); + + let mut stats = ComputedStats::new("Atlas AS7-D"); + stats.battle_value = Some(1897); + let joined = UnitFacets::from_unit(&unit, Some(&stats)); + assert_eq!(joined.battle_value, Some(1897)); + assert!(q.matches(&joined)); + } + + #[test] + fn filter_preserves_order() { + let units = vec![ + facets("Atlas AS7-D"), + facets("Banshee BNC-3E"), + facets("Atlas AS7-K"), + ]; + let q = FacetQuery { + name: Some("atlas".into()), + ..Default::default() + }; + let got: Vec<&str> = q.filter(&units).iter().map(|u| u.name.as_str()).collect(); + assert_eq!(got, vec!["Atlas AS7-D", "Atlas AS7-K"]); + } +} diff --git a/scripts/check-boundaries.sh b/scripts/check-boundaries.sh index b24df81..89ec621 100755 --- a/scripts/check-boundaries.sh +++ b/scripts/check-boundaries.sh @@ -24,7 +24,7 @@ note() { printf ' %s\n' "$1"; } fail() { printf 'FAIL: %s\n' "$1" >&2; FAIL=1; } # Crates that must reach wasm, and so must stay clear of these. -WASM_CRATES=(helm-core) +WASM_CRATES=(helm-core helm-facet) FORBIDDEN=(rusqlite zip serde_json libsqlite3-sys) # Crates that must not depend on a producer of ComputedStats. -- 2.51.2