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.