Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
37 kB · 966 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967//! The filter as it travels: one JSON dialect, and one reading of it.//!//! [`helm_facet::FacetQuery`] is the predicate, and it is a Rust value with//! ranges and an expression tree in it. What crosses a wire is flatter than//! that - `bv_min`, `carries`, `tech_base` - because it is written by an agent//! filling in a tool call or by a page collecting form fields.//!//! Both of those readers used to be one reader, in helm-mcp, and a browser//! that wanted the same filter would have had to grow a second table of forty//! field names. Two tables is how "filtering means the same thing everywhere"//! stops being true: not in the predicate, which is shared, but in the names//! that reach it.//!//! So the dialect is here, the mapping to a predicate is here, and what is//! left to the caller is decoding the document and dressing an error up in//! whatever shape it answers in.
mod build;mod facets;mod names;
pub use build::Build;pub use facets::{ Art, Chunk, Columns, Combat, Counts, Curve, Extras, Figures, Index, Loadout, Mismatch, Paperwork, Part, Quirks, Spine,};pub use names::{Named, Names};
use std::collections::BTreeSet;
use helm_core::normalize;use helm_facet::{Equipment, FacetQuery, Range, Tri, UnitFacets, WeaponClass};use serde::Deserialize;
/// The weight classes by the names a caller types, and the codes a unit/// carries.pub const WEIGHT_CLASSES: &[(&str, i64)] = &[("light", 1), ("medium", 2), ("heavy", 3), ("assault", 4)];
/// The filter shared by every caller that selects units.////// Every field is optional and absent means no opinion, so an empty document/// selects the whole library and a filter is built by narrowing. Ranges are/// two fields rather than one object because a form and a tool call both/// collect them that way.#[derive(Debug, Default, Clone, Deserialize)]#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]#[serde(default, deny_unknown_fields)]pub struct Filter { /// Substring of the unit's name, case and punctuation insensitive. /// "timber wolf" finds "Mad Cat (Timber Wolf) B". pub name: Option<String>, /// Any of: "Inner Sphere", "Clan", "Mixed Clan", "Mixed Inner Sphere". pub tech_base: Option<Vec<String>>, /// Any of: Light, Medium, Heavy, Assault. Case insensitive. pub weight_class: Option<Vec<String>>, /// Any of MegaMek's roles: Juggernaut, Striker, Sniper, Brawler, Scout, /// Missile Boat, Skirmisher, Ambusher, Undetermined. pub role: Option<Vec<String>>, /// Any-of, by the book a design appears in: `TR:3039`, `TR:SW`. A design /// in any one of them matches. pub source: Option<Vec<String>>, /// Earliest year of introduction, inclusive. pub year_min: Option<i64>, /// Latest year of introduction, inclusive. A unit is available from its /// introduction onwards, so an era is usually year_max alone. pub year_max: Option<i64>, pub tons_min: Option<i64>, pub tons_max: Option<i64>, /// Battle value: combat capability, and the usual currency for balancing /// a fight. Not the same thing as cost. pub bv_min: Option<i64>, pub bv_max: Option<i64>, pub walk_mp_min: Option<i64>, pub jump_mp_min: Option<i64>,
/// What a design expects to land at the range that suits it best. /// /// Weighted by how often each weapon hits: a gunnery 4 crew, open ground, /// nothing moving, and every modifier a weapon carries with it. Describes /// how hard a unit hits, which tonnage does not. pub firepower_min: Option<i64>, pub firepower_max: Option<i64>, /// Longest weapon reach in hexes. Separates a brawler from fire support /// more reliably than `role`, which is a label somebody assigned. pub max_range_min: Option<i64>, pub max_range_max: Option<i64>, /// Heat shed per turn minus heat from firing everything. 0 or more means /// it can alpha strike indefinitely; negative means it builds heat. pub heat_efficiency_min: Option<i64>, pub heat_efficiency_max: Option<i64>, /// How much of what a design hits with runs out, as a percentage of its /// firepower. `ammo_dependence_max: 0` is a design that never needs /// resupply; 100 is one whose guns are all fed from magazines. pub ammo_dependence_min: Option<i64>, pub ammo_dependence_max: Option<i64>, /// How much of what a design can expect to land it can only land once, as /// a percentage. `one_shot_max: 25` keeps out the rocket boats, whose /// alpha is real and happens once; `one_shot_min: 50` is how to find them. /// /// A column rather than a rule, so where the line falls is the caller's: /// a scenario fought over one turn and a campaign fought over twenty do /// not agree about what counts as a lot. pub one_shot_min: Option<i64>, pub one_shot_max: Option<i64>, /// Armour as a percentage of the most the design could carry. pub armor_pct_min: Option<i64>, /// Armour as a percentage of the most the design could carry. pub armor_pct_max: Option<i64>, /// True for designs built to fight at arm's length, false to exclude /// them: a hatchet, a claw, spikes, a shield, triple strength myomer. /// /// Neither answer returns infantry or battle armour. Swarming a Mek is /// what infantry does rather than a thing one platoon is built for and /// another is not, so the question does not apply to them. pub melee: Option<bool>, /// True for designs built the same way on both sides, false to exclude /// them: every mirrored pair of locations - both arms, both side torsos, /// both legs - holding the same things. /// /// Neither answer returns anything that is not a Mek. A tank has a left /// side and a right side and no rules-level notion of mirroring them, so /// the question does not apply to it. pub symmetrical: Option<bool>, /// True for OmniMeks only, false to exclude them. pub omni: Option<bool>, /// True for designs that stand on two legs, false to exclude them. A LAM /// is one. /// /// The same question `config` asks by name, as a yes or a no. Neither /// answer returns anything that is not a Mek: nothing else has a /// configuration, so the question does not apply to it. pub biped: Option<bool>, /// True for designs that stand on four legs, false to exclude them. A /// QuadVee is one. pub quad: Option<bool>, /// True for designs that stand on three legs, false to exclude them. pub tripod: Option<bool>, /// Guns the design must carry, by any name MegaMek answers to. /// /// `"large laser"`, `"particle"`, `"ISERPPC"` - every word has to appear /// in one of the item's names, in any order, and a word that names /// several items finds all of them: asking for a PPC does not mean /// excluding the ER version. Needs the names document. pub weapon: Option<Vec<String>>, /// The plate the design is wearing. pub armor: Option<Vec<String>>, /// Anything else it carries: ECM, jump jets, CASE, a hatchet. pub equipment: Option<Vec<String>>, /// The magazines aboard. `ammo_dependence` is the better question about /// whether a design runs out; this is for a particular round. pub ammo: Option<Vec<String>>,
/// Equipment keys the unit must carry, exactly as the loadout records /// them - `ISGaussRifle`, not `Gauss Rifle`. /// /// The untyped form, kept for a caller that already holds a key and does /// not care which of the four kinds of thing it is. A person types /// `weapon` or `equipment`; a program that resolved a name already knows. pub carries: Option<Vec<String>>, /// Equipment keys the unit must not carry. pub lacks: Option<Vec<String>>,
/// Any of: Introductory, Standard, Advanced, Experimental. This is how to /// say "nothing experimental". pub rules_level: Option<Vec<String>>, /// Any of: Biped, Quad, Tripod. OmniMeks are included under their base /// config; ask for those with `omni`. `biped`, `quad` and `tripod` ask /// the same question as a yes or a no. pub config: Option<Vec<String>>, /// Purchase price in C-bills. A different question from battle value, /// which is combat capability - say which one a budget means. pub cost_min: Option<i64>, pub cost_max: Option<i64>, pub run_mp_min: Option<i64>, /// Quirks the unit must have, all of them. pub has_quirks: Option<Vec<String>>, /// An exact masterunitlist.info record id. pub mul_id: Option<i64>,
/// Equipment classes the unit must carry at least one of, all of them. /// Far easier than naming weapons: "Energy", "Ballistic", "Missile", /// "Gauss", "PPC", "Pulse Laser". /// /// Counted per class across the whole loadout, so two medium lasers and a /// large laser is three energy weapons. pub carries_class: Option<Vec<String>>, /// Equipment classes the unit must carry none of. pub lacks_class: Option<Vec<String>>,
/// What to rank the selection by. Unranked, it comes back in library /// order. pub sort: Option<Sort>,}
/// What a selection is ranked by.////// Its own type rather than a field per sort, because a query ranks by one/// thing: two sort fields set at once would need a rule about which wins, and/// there is no good answer to that question.#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]#[serde(rename_all = "snake_case", deny_unknown_fields)]pub enum Sort { /// Hardest first, by what each design can expect to land across a band /// of hexes: `{"firepower_at": [3, 9]}` is "what hits hardest at /// brawling range". /// /// Firepower rather than damage, and the distinction is the model: this /// is what a design lands against a gunnery 4 crew on open ground, not /// what it throws. Raw damage is a different question and will want a /// different word when it arrives. /// /// A band rather than a hex, because nobody wants to know what a design /// does at exactly six. A sort rather than a filter, because the useful /// question is which of these is hardest here, not which of them clear /// some bar - and the bar would be different for every band. FirepowerAt(Vec<i64>),}
/// What a filter named that the library does not have.////// Rejecting an unknown value rather than quietly matching nothing is the/// difference between one call and three, so every one of these carries what/// the caller could have said instead.#[derive(Debug, Clone, PartialEq, Eq)]pub enum Invalid { /// A value of a field whose vocabulary the library states. Unknown { field: &'static str, got: String, valid: Vec<String>, }, /// A quirk no unit has. Answered with near spellings rather than the /// whole list, of which there are hundreds. Quirk { got: String, did_you_mean: Vec<String>, }, /// A term named equipment and there is no names document to resolve it /// against. Not the caller's mistake: they are told to go and fetch one. NoNames { got: String },}
impl Invalid { /// The field that was wrong. pub fn field(&self) -> &'static str { match self { Invalid::Unknown { field, .. } => field, Invalid::Quirk { .. } => "quirk", Invalid::NoNames { .. } => "equipment", } }
/// What the caller said. pub fn got(&self) -> &str { match self { Invalid::Unknown { got, .. } | Invalid::Quirk { got, .. } | Invalid::NoNames { got } => got, } }}
impl std::fmt::Display for Invalid { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Invalid::Unknown { field, got, .. } => write!(f, "unknown {field} {got:?}"), Invalid::Quirk { got, .. } => write!(f, "no unit has the quirk {got:?}"), Invalid::NoNames { got } => write!( f, "{got:?} names equipment and no equipment names are loaded" ), } }}
impl std::error::Error for Invalid {}
/// What the library being filtered actually holds, for checking a filter/// against before running it.////// A caller with the library in hand builds one from it. A caller without -/// a page that has been handed a filter and a page of results - uses/// [`Vocabulary::unchecked`], and then an unknown role matches nothing rather/// than being refused. Both are legitimate: the check is a courtesy to/// whoever wrote the filter, not part of the predicate.#[derive(Debug, Default, Clone)]pub struct Vocabulary { known: Option<Known>, names: Option<Names>,}
#[derive(Debug, Default, Clone)]struct Known { tech_bases: BTreeSet<String>, roles: BTreeSet<String>, rules_levels: BTreeSet<String>, configs: BTreeSet<String>, quirks: BTreeSet<String>,}
impl Vocabulary { /// Check nothing: every named value is taken at face value. pub fn unchecked() -> Self { Vocabulary { known: None, names: None, } }
/// The names equipment answers to, so a filter can name a gun the way a /// person does. pub fn with_names(mut self, names: Names) -> Self { self.names = Some(names); self }
/// What a typed equipment term means, or why it means nothing. fn equipment(&self, category: helm_core::Category, typed: &str) -> Result<Equipment, Invalid> { let Some(names) = &self.names else { return Err(Invalid::NoNames { got: typed.to_string(), }); }; let found = names.resolve(category, typed); if found.is_empty() { return Err(Invalid::Unknown { field: category.name(), got: typed.to_string(), valid: Vec::new(), }); } // Any of them: a word that names four lasers means a design carrying // any one of the four, not one carrying all four. Ok(Equipment::Any( found.into_iter().map(Equipment::carries).collect(), )) }
/// Everything the units in hand name, which is what a filter may name. pub fn from_facets(facets: &[UnitFacets]) -> Self { let mut known = Known::default(); for facet in facets { if let Some(v) = &facet.tech_base { known.tech_bases.insert(v.clone()); } if let Some(v) = &facet.role { known.roles.insert(v.clone()); } if let Some(v) = &facet.rules_level { known.rules_levels.insert(v.clone()); } if let Some(v) = &facet.config { known.configs.insert(v.clone()); } for quirk in &facet.quirks { known.quirks.insert(quirk.clone()); } } Vocabulary { known: Some(known), names: None, } }
fn check( &self, field: &'static str, got: &str, set: fn(&Known) -> &BTreeSet<String>, ) -> Result<(), Invalid> { let Some(known) = &self.known else { return Ok(()); }; let set = set(known); if set.contains(got) { return Ok(()); } Err(Invalid::Unknown { field, got: got.to_string(), valid: set.iter().cloned().collect(), }) }
fn check_quirk(&self, got: &str) -> Result<(), Invalid> { let Some(known) = &self.known else { return Ok(()); }; if known.quirks.contains(got) { return Ok(()); } let wanted = normalize(got); let did_you_mean = known .quirks .iter() .filter(|k| normalize(k).contains(&wanted)) .take(10) .cloned() .collect(); Err(Invalid::Quirk { got: got.to_string(), did_you_mean, }) }}
/// A wire filter's optional yes or no, as the predicate's tri-state.////// Absent is no opinion, which is not the same as either answer.fn tri(asked: Option<bool>) -> Tri { match asked { Some(true) => Tri::Yes, Some(false) => Tri::No, None => Tri::Any, }}
impl Filter { /// Whether this filter names equipment the way a person does, and so /// needs the names document to resolve it. pub fn needs_names(&self) -> bool { [&self.weapon, &self.armor, &self.equipment, &self.ammo] .into_iter() .any(|list| list.as_ref().is_some_and(|l| !l.is_empty())) }
/// Which chunks of the index this filter reads. /// /// A library that has not joined one of them answers those terms with /// silence - every design having nothing - which reads exactly like an /// honest empty result. So a caller is told what to go and fetch instead /// of being shown one. /// /// The spine's own columns are not a chunk: a library that has been loaded /// at all has them, and art is a chunk nothing filters on. pub fn needs(&self) -> Vec<Chunk> { let some = |list: &Option<Vec<String>>| list.as_ref().is_some_and(|l| !l.is_empty()); let mut needs = Vec::new(); if some(&self.carries) || some(&self.lacks) || some(&self.carries_class) || some(&self.lacks_class) || some(&self.weapon) || some(&self.armor) || some(&self.equipment) || some(&self.ammo) { needs.push(Chunk::Loadout); } if some(&self.has_quirks) { needs.push(Chunk::Quirks); } if matches!(self.sort, Some(Sort::FirepowerAt(_))) { needs.push(Chunk::Curve); } if some(&self.tech_base) || some(&self.role) || some(&self.rules_level) || some(&self.source) || self.year_min.is_some() || self.year_max.is_some() { needs.push(Chunk::Paperwork); } if some(&self.weight_class) || self.tons_min.is_some() || self.tons_max.is_some() || self.bv_min.is_some() || self.bv_max.is_some() || self.cost_min.is_some() || self.cost_max.is_some() || self.omni.is_some() { needs.push(Chunk::Figures); } if self.walk_mp_min.is_some() || self.jump_mp_min.is_some() || self.run_mp_min.is_some() || self.firepower_min.is_some() || self.firepower_max.is_some() || self.max_range_min.is_some() || self.max_range_max.is_some() || self.heat_efficiency_min.is_some() || self.heat_efficiency_max.is_some() || self.ammo_dependence_min.is_some() || self.ammo_dependence_max.is_some() || self.one_shot_min.is_some() || self.one_shot_max.is_some() || self.armor_pct_min.is_some() || self.armor_pct_max.is_some() || self.melee.is_some() || self.symmetrical.is_some() { needs.push(Chunk::Combat); } needs }
/// The predicate this filter means, or the first thing in it the library /// does not have. pub fn to_query(&self, vocabulary: &Vocabulary) -> Result<FacetQuery, Invalid> { let mut weight_classes = Vec::new(); for name in self.weight_class.iter().flatten() { match WEIGHT_CLASSES .iter() .find(|(label, _)| *label == name.trim().to_ascii_lowercase()) { Some((_, code)) => weight_classes.push(*code), None => { return Err(Invalid::Unknown { field: "weight_class", got: name.clone(), valid: WEIGHT_CLASSES .iter() .map(|(label, _)| (*label).to_string()) .collect(), }); } } }
for value in self.tech_base.iter().flatten() { vocabulary.check("tech_base", value, |k| &k.tech_bases)?; } for value in self.role.iter().flatten() { vocabulary.check("role", value, |k| &k.roles)?; } for value in self.rules_level.iter().flatten() { vocabulary.check("rules_level", value, |k| &k.rules_levels)?; } for value in self.config.iter().flatten() { vocabulary.check("config", value, |k| &k.configs)?; } for quirk in self.has_quirks.iter().flatten() { vocabulary.check_quirk(quirk)?; }
let mut equipment = Vec::new(); for label in self.carries_class.iter().flatten() { equipment.push(Equipment::carries_class(class(label)?)); } for label in self.lacks_class.iter().flatten() { equipment.push(Equipment::lacks_class(class(label)?)); } for (category, typed) in [ (helm_core::Category::Weapon, &self.weapon), (helm_core::Category::Armor, &self.armor), (helm_core::Category::Equipment, &self.equipment), (helm_core::Category::Ammo, &self.ammo), ] { for words in typed.iter().flatten() { equipment.push(vocabulary.equipment(category, words)?); } } for name in self.carries.iter().flatten() { equipment.push(Equipment::carries(name)); } for name in self.lacks.iter().flatten() { equipment.push(Equipment::lacks(name)); }
Ok(FacetQuery { name: self.name.clone(), unit_types: Vec::new(), tech_bases: self.tech_base.clone().unwrap_or_default(), roles: self.role.clone().unwrap_or_default(), sources: self.source.clone().unwrap_or_default(), weight_classes, year: Range::new(self.year_min, self.year_max), tons: Range::new(self.tons_min, self.tons_max), battle_value: Range::new(self.bv_min, self.bv_max), walk_mp: Range::new(self.walk_mp_min, None), jump_mp: Range::new(self.jump_mp_min, None), firepower: Range::new(self.firepower_min, self.firepower_max), max_range: Range::new(self.max_range_min, self.max_range_max), heat: Range::any(), dissipation: Range::any(), heat_efficiency: Range::new(self.heat_efficiency_min, self.heat_efficiency_max), ammo_dependence: Range::new(self.ammo_dependence_min, self.ammo_dependence_max), one_shot: Range::new(self.one_shot_min, self.one_shot_max), melee: tri(self.melee), symmetrical: tri(self.symmetrical), armor_pct: Range::new(self.armor_pct_min, self.armor_pct_max), cost: Range::new(self.cost_min, self.cost_max), run_mp: Range::new(self.run_mp_min, None), rules_levels: self.rules_level.clone().unwrap_or_default(), configs: self.config.clone().unwrap_or_default(), quirks: self.has_quirks.clone().unwrap_or_default(), mul_id: self.mul_id, canon: Tri::Any, invalid: Tri::Any, omni: tri(self.omni), clan: Tri::Any, biped: tri(self.biped), quad: tri(self.quad), tripod: tri(self.tripod), equipment: if equipment.is_empty() { None } else { Some(Equipment::All(equipment)) }, }) }}
impl Filter { /// The band this filter ranks by, checked. /// /// `None` when it ranks by nothing, which is most filters. pub fn band(&self) -> Result<Option<(i64, i64)>, Invalid> { let Some(Sort::FirepowerAt(band)) = &self.sort else { return Ok(None); }; let bad = |got: String| Invalid::Unknown { field: "firepower_at", got, valid: vec![format!("two hexes from 1 to {}", helm_core::REACH)], }; let [from, to] = band[..] else { return Err(bad(format!("{band:?}"))); }; if from < 1 || to > helm_core::REACH || from > to { return Err(bad(format!("{from} to {to}"))); } Ok(Some((from, to))) }
/// What a design expects to land at an average hex of the band. /// /// The mean rather than the sum. Both rank the same - every design is /// measured over the same band, so dividing by its width cannot reorder /// anything - but only one of them is a number worth printing: 40 damage /// a hex says something, and 280 damage-hexes says it in a unit nobody /// uses. /// /// Not rounded, because rounding would tie designs the sum keeps apart /// and the tie would be broken by where they happen to sit in the /// library. Nought for a design with no curve loaded, which cannot happen /// once `needs` has been honoured. pub fn firepower_at(band: (i64, i64), unit: &UnitFacets) -> f64 { let (from, to) = band; let hexes = (to - from + 1).max(1); let total: i64 = unit .curve .iter() .skip((from - 1).max(0) as usize) .take(hexes as usize) .sum(); total as f64 / hexes as f64 }}
/// One equipment class by name, or what the names are.fn class(label: &str) -> Result<WeaponClass, Invalid> { WeaponClass::parse(label).ok_or_else(|| Invalid::Unknown { field: "equipment class", got: label.to_string(), valid: WeaponClass::ALL .iter() .map(|c| c.label().to_string()) .collect(), })}
#[cfg(test)]mod tests { use super::*;
fn facets(role: &str, quirk: &str) -> Vec<UnitFacets> { vec![UnitFacets { role: Some(role.into()), quirks: vec![quirk.into()], ..Default::default() }] }
/// The whole point of the crate: one document, read once, and the caller /// that holds a library and the caller that holds a page get the same /// predicate out of it. #[test] fn an_empty_filter_selects_everything() { let query = Filter::default() .to_query(&Vocabulary::unchecked()) .expect("nothing to be wrong with"); assert!(query.matches(&UnitFacets::default())); }
#[test] fn a_range_pair_becomes_one_range() { let filter = Filter { bv_min: Some(1000), bv_max: Some(2000), ..Default::default() }; let query = filter.to_query(&Vocabulary::unchecked()).expect("valid"); assert_eq!(query.battle_value, Range::between(1000, 2000)); }
#[test] fn a_weight_class_is_named_and_travels_as_a_code() { let filter = Filter { weight_class: Some(vec!["Assault".into()]), ..Default::default() }; let query = filter.to_query(&Vocabulary::unchecked()).expect("valid"); assert_eq!(query.weight_classes, vec![4]); }
/// An unchecked vocabulary takes a role at face value; one built from the /// library refuses a role it does not have, and says what it does. #[test] fn a_role_is_checked_only_where_there_is_something_to_check_against() { let filter = Filter { role: Some(vec!["Skulker".into()]), ..Default::default() }; assert!(filter.to_query(&Vocabulary::unchecked()).is_ok());
let err = filter .to_query(&Vocabulary::from_facets(&facets("Scout", "Rugged"))) .expect_err("no unit has that role"); assert_eq!(err.field(), "role"); assert_eq!(err.got(), "Skulker"); assert!(matches!(err, Invalid::Unknown { valid, .. } if valid == ["Scout"])); }
#[test] fn a_misspelled_quirk_is_answered_with_near_ones() { let filter = Filter { has_quirks: Some(vec!["rug".into()]), ..Default::default() }; let err = filter .to_query(&Vocabulary::from_facets(&facets("Scout", "Rugged"))) .expect_err("no unit has that quirk"); assert!(matches!(err, Invalid::Quirk { did_you_mean, .. } if did_you_mean == ["Rugged"])); }
#[test] fn an_unknown_equipment_class_names_the_ones_there_are() { let filter = Filter { carries_class: Some(vec!["Phaser".into()]), ..Default::default() }; let err = filter .to_query(&Vocabulary::unchecked()) .expect_err("there is no such class"); assert_eq!(err.field(), "equipment class"); assert!( matches!(err, Invalid::Unknown { valid, .. } if valid.contains(&"Energy".to_string())) ); }
/// Asking for melee returns a design built for it, refuses one that is /// not, and returns neither for a platoon. /// /// The third case is the one worth a test. A filter that sorted infantry /// into "not melee" would be answering a question nobody asked - every /// platoon can swarm a Mek, so the answer would be about infantry rather /// than about this platoon - so a design with no answer is returned by /// neither side. #[test] fn melee_is_asked_of_machines_and_not_of_platoons() { let unit = |melee: Option<bool>| UnitFacets { melee, ..Default::default() }; let query = |melee: Option<bool>| { Filter { melee, ..Default::default() } .to_query(&Vocabulary::unchecked()) .expect("valid") };
assert!(query(Some(true)).matches(&unit(Some(true)))); assert!(!query(Some(true)).matches(&unit(Some(false)))); assert!(!query(Some(true)).matches(&unit(None)));
assert!(query(Some(false)).matches(&unit(Some(false)))); assert!(!query(Some(false)).matches(&unit(Some(true)))); assert!(!query(Some(false)).matches(&unit(None)));
// And asking nothing returns all three, the platoon included. for answer in [Some(true), Some(false), None] { assert!(query(None).matches(&unit(answer))); }
// It is a column of the combat chunk, so a page that filters on it // has to have fetched that chunk. assert_eq!( Filter { melee: Some(true), ..Default::default() } .needs(), vec![Chunk::Combat] ); }
/// Asking for symmetry returns a design built the same on both sides, /// refuses one that is not, and returns neither for a tank. /// /// The third case is the one worth a test. A vehicle has a left side and /// a right side, but nothing in the rules mirrors them the way a Mek's /// arms are mirrored, so helm declines the question rather than answering /// "no" for every tank in the library. #[test] fn symmetry_is_asked_of_meks_and_not_of_tanks() { let unit = |symmetrical: Option<bool>| UnitFacets { symmetrical, ..Default::default() }; let query = |symmetrical: Option<bool>| { Filter { symmetrical, ..Default::default() } .to_query(&Vocabulary::unchecked()) .expect("valid") };
assert!(query(Some(true)).matches(&unit(Some(true)))); assert!(!query(Some(true)).matches(&unit(Some(false)))); assert!(!query(Some(true)).matches(&unit(None)));
assert!(query(Some(false)).matches(&unit(Some(false)))); assert!(!query(Some(false)).matches(&unit(Some(true)))); assert!(!query(Some(false)).matches(&unit(None)));
// And asking nothing returns all three, the tank included. for answer in [Some(true), Some(false), None] { assert!(query(None).matches(&unit(answer))); }
// It is a column of the combat chunk, so a page that filters on it // has to have fetched that chunk. assert_eq!( Filter { symmetrical: Some(true), ..Default::default() } .needs(), vec![Chunk::Combat] ); }
/// The legs a design stands on, as a yes or a no. /// /// `config` already answers this by name, and an any-of list of names /// cannot say "not a quad" - which is what a control offering yes and no /// has to be able to mean. #[test] fn legs_are_asked_of_meks_and_read_off_the_spine() { let unit = |config: &str| UnitFacets { config: Some(config.into()), ..Default::default() }; let query = |filter: Filter| filter.to_query(&Vocabulary::unchecked()).expect("valid");
let quads = query(Filter { quad: Some(true), ..Default::default() }); assert!(quads.matches(&unit("Quad"))); // MegaMek's own reading: a QuadVee stands on four legs and a LAM on // two, neither of which the spelling gives away. assert!(quads.matches(&unit("QuadVee"))); assert!(!quads.matches(&unit("Biped"))); assert!( query(Filter { biped: Some(true), ..Default::default() }) .matches(&unit("LAM")) ); assert!( query(Filter { tripod: Some(true), ..Default::default() }) .matches(&unit("Tripod OmniMek")) );
// A design with no configuration is returned by neither answer. let not_a_quad = query(Filter { quad: Some(false), ..Default::default() }); assert!(not_a_quad.matches(&unit("Biped"))); assert!(!not_a_quad.matches(&UnitFacets::default()));
// Config is a spine column, so a page needs no chunk to ask this. assert_eq!( Filter { quad: Some(true), ..Default::default() } .needs(), vec![] ); }
/// Every chunk a filter can read is asked for by some term, and an empty /// filter asks for none. A term whose chunk is not named here filters on a /// column the browser may never have fetched. #[test] fn every_chunk_is_asked_for_by_some_term() { assert!(Filter::default().needs().is_empty()); let everything = Filter { carries: Some(vec!["ISGaussRifle".into()]), has_quirks: Some(vec!["easy_maintain".into()]), year_max: Some(3025), bv_max: Some(2000), firepower_min: Some(10), sort: Some(Sort::FirepowerAt(vec![3, 9])), ..Default::default() }; let mut asked: Vec<&str> = everything.needs().iter().map(|c| c.name()).collect(); asked.sort_unstable(); // Art is the exception, and the only one: it is a column a screen // draws and nothing a filter reads or ranks by. let mut filterable: Vec<&str> = Chunk::ALL .iter() .map(|c| c.name()) .filter(|name| *name != "art") .collect(); filterable.sort_unstable(); assert_eq!(asked, filterable); }
/// The asterisk is a column and the line is the caller's: a page decides /// what counts as a lot of one-shot damage, and helm decides how much /// there is. #[test] fn a_one_shot_threshold_is_the_callers_to_set() { let boat = UnitFacets { one_shot: Some(80), ..Default::default() }; let workhorse = UnitFacets { one_shot: Some(0), ..Default::default() }; let filter = Filter { one_shot_max: Some(25), ..Default::default() }; let modest = filter.to_query(&Vocabulary::unchecked()).expect("valid"); assert!(modest.matches(&workhorse)); assert!(!modest.matches(&boat)); assert!(filter.needs().contains(&Chunk::Combat)); }
/// A field nobody knows is a typo in a filter, and a filter that silently /// ignores one selects the whole library while looking narrow. #[test] fn an_unknown_field_is_refused_rather_than_ignored() { let err = serde_json::from_str::<Filter>(r#"{"bv_minimum": 1000}"#) .expect_err("the field is not one of ours"); assert!(err.to_string().contains("bv_minimum"), "{err}"); }}