diff --git a/Cargo.lock b/Cargo.lock index 6fb1cae..d065d4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -398,6 +398,7 @@ name = "helm-unitfile" version = "0.1.0" dependencies = [ "helm-core", + "quick-xml", "zip", ] @@ -551,6 +552,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" diff --git a/Cargo.toml b/Cargo.toml index 36c91e7..c1b99ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,3 +45,8 @@ zip = { version = "6", default-features = false, features = ["deflate"] } # hand-written against MegaMek's formats and pull in nothing. serde = { version = "1", features = ["derive"] } serde_json = "1" + +# A real parser for the one real format MegaMek uses. Pull-based, no entity +# expansion and no external entities, so the hostile-input cases XML is known +# for are not reachable. +quick-xml = { version = "0.38", default-features = false } diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index bcf6bf8..74da9e0 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -381,3 +381,113 @@ fn damaged_designs_agree_with_megamek() { } assert!(wrong.is_empty(), "{} damaged designs disagree", wrong.len()); } + +/// Every `.mul` the MegaMek install ships, read, written back, and read again. +/// +/// The structural round trip is checked in helm-unitfile against files written +/// by hand; this checks it against every file MegaMek itself produced, and +/// checks the thing that actually matters - that a force is worth the same +/// after the trip as before it. A round trip that loses a point of armour is +/// one nobody notices until a battle value is quietly wrong. +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn every_mul_megamek_ships_survives_a_round_trip() { + let Some(inputs) = inputs() else { + panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump"); + }; + let root = PathBuf::from(std::env::var("HELM_MEGAMEK").unwrap()); + + let mut files = Vec::new(); + collect_muls(&root, &mut files); + assert!( + !files.is_empty(), + "the install ships no .mul files to check" + ); + + let by_name: std::collections::HashMap<(&str, &str), &helm_core::Unit> = inputs + .library + .units + .iter() + .map(|u| ((u.chassis.as_str(), u.model.as_str()), u)) + .collect(); + + let mut units = 0; + let mut scored = 0; + let mut wrong: Vec = Vec::new(); + for path in &files { + let Ok(text) = std::fs::read_to_string(path) else { + continue; + }; + let Ok(once) = helm_unitfile::parse_mul(&text) else { + wrong.push(format!("{}: could not be read", path.display())); + continue; + }; + let written = helm_unitfile::write_mul(&once); + let twice = match helm_unitfile::parse_mul(&written) { + Ok(twice) => twice, + Err(e) => { + wrong.push(format!( + "{}: what we wrote is not readable: {e}", + path.display() + )); + continue; + } + }; + if once != twice { + wrong.push(format!( + "{}: the force changed on the way round", + path.display() + )); + continue; + } + // And the same again through the part that counts. + for (before, after) in once.machines().zip(twice.machines()) { + units += 1; + let Some(unit) = by_name.get(&(before.chassis.as_str(), before.model.as_str())) else { + continue; + }; + let a = helm_bv::battle_value_in(unit, &inputs.catalogue, &before.into()); + let b = helm_bv::battle_value_in(unit, &inputs.catalogue, &after.into()); + if let (Ok(a), Ok(b)) = (a, b) { + scored += 1; + if a != b { + wrong.push(format!( + "{}: {} is {a} before and {b} after", + path.display(), + before.display_name() + )); + } + } + } + } + + println!( + "{} files, {units} units, {scored} of them scored, all worth the same after a round trip", + files.len() + ); + for line in wrong.iter().take(10) { + println!(" {line}"); + } + assert!( + wrong.is_empty(), + "{} round trips lost something", + wrong.len() + ); +} + +fn collect_muls(dir: &std::path::Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_muls(&path, out); + } else if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("mul")) + { + out.push(path); + } + } +} diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index 5e9134e..64bcbd9 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -884,7 +884,7 @@ fn force(args: &[String]) -> Result<(), String> { let catalogue = helm_bridge::read_catalogue(&bridge.join("equipment.jsonl")).map_err(|e| e.to_string())?; let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; - let mul = helm_unitfile::parse_mul(&text); + let mul = helm_unitfile::parse_mul(&text).map_err(|e| format!("{path}: {e}"))?; // Chassis and model, because that is the pair a `.mul` records and a // display name is not a key - a Clan design folds a second name into it. diff --git a/crates/helm-unitfile/Cargo.toml b/crates/helm-unitfile/Cargo.toml index 15a7e28..f73c173 100644 --- a/crates/helm-unitfile/Cargo.toml +++ b/crates/helm-unitfile/Cargo.toml @@ -14,4 +14,5 @@ library = ["dep:zip"] [dependencies] helm-core.workspace = true +quick-xml.workspace = true zip = { workspace = true, optional = true } diff --git a/crates/helm-unitfile/src/lib.rs b/crates/helm-unitfile/src/lib.rs index b0ce483..69de4c9 100644 --- a/crates/helm-unitfile/src/lib.rs +++ b/crates/helm-unitfile/src/lib.rs @@ -27,7 +27,7 @@ mod mul; pub use blk::parse_blk; pub use mtf::{armor_location_order, parse_mtf, split_pipe_list, split_system_field}; -pub use mul::{Mul, MulUnit, parse_mul}; +pub use mul::{Mul, MulUnit, parse_mul, write_mul}; #[cfg(feature = "library")] mod library; diff --git a/crates/helm-unitfile/src/mul.rs b/crates/helm-unitfile/src/mul.rs index d245df5..8eb2a5e 100644 --- a/crates/helm-unitfile/src/mul.rs +++ b/crates/helm-unitfile/src/mul.rs @@ -22,16 +22,40 @@ //! //! ``` //! -//! Read by hand, like the other MegaMek formats here. The grammar this needs is -//! four tags and their attributes; an XML crate would be a dependency in the -//! one place the rules cannot afford one. +//! Read with `quick-xml` rather than by hand, unlike the `.mtf` and `.blk` +//! readers beside it. Those are line formats with no grammar to speak of and +//! nothing to get subtly wrong; this is real XML, arriving from wherever a +//! player got it. A hand-written scanner has to be right about attribute +//! quoting, entity references, comments, CDATA and namespaces, and being +//! wrong about the first two is silent: `chassis="Bob&s Mek"` simply +//! never matches the library. //! -//! Two traps, both of which MegaMek documents in the files themselves. Slot -//! numbers are one-based where every other index in this crate is zero-based, -//! and `points` is not always a number - `Destroyed` and `N/A` are values. +//! `quick-xml` is a pull parser with no entity expansion and no external +//! entity resolution, so the attacks XML is known for - billion laughs, XXE - +//! are not reachable through it. It costs one small dependency, `memchr`. +//! +//! Two traps in the format itself, both of which MegaMek documents in the +//! files. Slot numbers are one-based where every other index in this crate is +//! zero-based, and `points` is not always a number - `Destroyed` and `N/A` +//! are values. use std::collections::{BTreeMap, BTreeSet}; +use quick_xml::Reader; +use quick_xml::events::Event; + +/// Why a `.mul` could not be read. +#[derive(Debug)] +pub struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "not a readable .mul: {}", self.0) + } +} + +impl std::error::Error for Error {} + /// A force, as a `.mul` records it. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Mul { @@ -113,74 +137,121 @@ const MEK_LOCATIONS: [(&str, &str); 8] = [ /// carries a great deal this crate has no use for - camouflage, deployment /// zones, edge - and a MegaMek that adds more should not stop the file being /// readable. -pub fn parse_mul(text: &str) -> Mul { +pub fn parse_mul(text: &str) -> Result { + let mut reader = Reader::from_str(text); + reader.config_mut().trim_text(true); + let mut mul = Mul::default(); let mut unit: Option = None; let mut location: Option = None; - for tag in tags(text) { - match tag.name { - "entity" => { - if let Some(done) = unit.take() { - mul.units.push(done); - } - unit = Some(MulUnit { - chassis: tag.attr("chassis").unwrap_or_default().to_string(), - model: tag.attr("model").unwrap_or_default().to_string(), - unit_type: tag.attr("type").map(str::to_string), - // A crew the file does not describe is the regular 4/5, - // which is what an unqualified battle value assumes. - gunnery: 4, - piloting: 5, - ..Default::default() - }); - location = None; - } - "pilot" | "crewMember" => { - if let Some(u) = unit.as_mut() { - u.pilot_name = tag.attr("name").map(str::to_string); - if let Some(g) = tag.attr("gunnery").and_then(|v| v.parse().ok()) { - u.gunnery = g; + loop { + match reader.read_event() { + Err(e) => return Err(Error(e.to_string())), + Ok(Event::Eof) => break, + Ok(Event::Start(tag) | Event::Empty(tag)) => { + let name = tag.name(); + let attrs = Attrs::read(&tag).map_err(Error)?; + match name.as_ref() { + b"entity" => { + if let Some(done) = unit.take() { + mul.units.push(done); + } + unit = Some(MulUnit { + chassis: attrs.get("chassis").unwrap_or_default(), + model: attrs.get("model").unwrap_or_default(), + unit_type: attrs.get("type"), + // A crew the file does not describe is the regular + // 4/5, which is what an unqualified battle value + // assumes. + gunnery: 4, + piloting: 5, + ..Default::default() + }); + location = None; } - if let Some(p) = tag.attr("piloting").and_then(|v| v.parse().ok()) { - u.piloting = p; + b"pilot" | b"crewMember" => { + if let Some(u) = unit.as_mut() { + u.pilot_name = attrs.get("name"); + if let Some(g) = attrs.number("gunnery") { + u.gunnery = g as u8; + } + if let Some(p) = attrs.number("piloting") { + u.piloting = p as u8; + } + u.pilot_hits = attrs.number("hits").unwrap_or(0) as u8; + } } - u.pilot_hits = tag.attr("hits").and_then(|v| v.parse().ok()).unwrap_or(0); - } - } - "location" => { - location = tag.attr("index").and_then(|v| v.parse().ok()); - } - "armor" => { - if let (Some(u), Some(index)) = (unit.as_mut(), location) { - read_armor(u, index, &tag); - } - } - "slot" => { - if let (Some(u), Some(index)) = (unit.as_mut(), location) { - read_slot(u, index, &tag); + b"location" => { + location = attrs.number("index").and_then(|n| usize::try_from(n).ok()); + } + b"armor" => { + if let (Some(u), Some(index)) = (unit.as_mut(), location) { + read_armor(u, index, &attrs); + } + } + b"slot" => { + if let (Some(u), Some(index)) = (unit.as_mut(), location) { + read_slot(u, index, &attrs); + } + } + _ => {} } } - _ => {} + Ok(_) => {} } } if let Some(done) = unit.take() { mul.units.push(done); } - mul + Ok(mul) +} + +/// One tag's attributes, unescaped once. +#[derive(Default)] +struct Attrs(Vec<(String, String)>); + +impl Attrs { + fn read(tag: &quick_xml::events::BytesStart<'_>) -> Result { + let mut out = Vec::new(); + for attr in tag.attributes() { + let attr = attr.map_err(|e| e.to_string())?; + let key = String::from_utf8_lossy(attr.key.as_ref()).into_owned(); + // Unescaped here and nowhere else: `&` in a chassis name is a + // real ampersand, and a name that keeps the escape matches no + // design in the library. + let value = attr + .unescape_value() + .map_err(|e| e.to_string())? + .into_owned(); + out.push((key, value)); + } + Ok(Attrs(out)) + } + + fn get(&self, name: &str) -> Option { + self.0 + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + } + + fn number(&self, name: &str) -> Option { + self.get(name)?.trim().parse().ok() + } } /// Plate or frame left in one location. -fn read_armor(unit: &mut MulUnit, index: usize, tag: &Tag<'_>) { +fn read_armor(unit: &mut MulUnit, index: usize, attrs: &Attrs) { let Some((code, _)) = MEK_LOCATIONS.get(index) else { return; }; - let Some(points) = tag.attr("points") else { + let Some(points) = attrs.get("points") else { return; }; // `Destroyed` is nothing left; `N/A` is a location that never had any, // which is not damage and must not be recorded as zero. - let points = match points { + let points = match points.as_str() { "Destroyed" => 0, "N/A" => return, n => match n.parse::() { @@ -188,7 +259,7 @@ fn read_armor(unit: &mut MulUnit, index: usize, tag: &Tag<'_>) { Err(_) => return, }, }; - match tag.attr("type") { + match attrs.get("type").as_deref() { Some("Internal") => { unit.structure.insert((*code).to_string(), points); } @@ -205,18 +276,20 @@ fn read_armor(unit: &mut MulUnit, index: usize, tag: &Tag<'_>) { } /// One critical slot: shot out, or a magazine with something left in it. -fn read_slot(unit: &mut MulUnit, index: usize, tag: &Tag<'_>) { +fn read_slot(unit: &mut MulUnit, index: usize, attrs: &Attrs) { let Some((_, name)) = MEK_LOCATIONS.get(index) else { return; }; - let Some(slot) = tag.attr("index").and_then(|v| v.parse::().ok()) else { + let Some(slot) = attrs.number("index").and_then(|n| usize::try_from(n).ok()) else { return; }; // A `.mul` numbers slots from one and everything else here from zero. let Some(slot) = slot.checked_sub(1) else { return; }; - if tag.attr("isDestroyed") == Some("true") || tag.attr("isMissing") == Some("true") { + if attrs.get("isDestroyed").as_deref() == Some("true") + || attrs.get("isMissing").as_deref() == Some("true") + { unit.destroyed .entry((*name).to_string()) .or_default() @@ -224,7 +297,7 @@ fn read_slot(unit: &mut MulUnit, index: usize, tag: &Tag<'_>) { } // Only a magazine that is written and empty. A bin the file does not // mention is full. - if tag.attr("shots") == Some("0") { + if attrs.number("shots") == Some(0) { unit.empty_ammo .entry((*name).to_string()) .or_default() @@ -232,60 +305,108 @@ fn read_slot(unit: &mut MulUnit, index: usize, tag: &Tag<'_>) { } } -/// One tag and its attributes. -struct Tag<'a> { - name: &'a str, - body: &'a str, -} +/// Write a force back out as a `.mul`. +/// +/// Enough of the format for MegaMek to read the file and put the units back in +/// the state they were in: which designs, who is flying them, and what each +/// has lost. A `.mul` MegaMek writes carries a great deal besides - camouflage, +/// deployment zones, edge, portraits - and none of it survives a trip through +/// here, so this writes a force rather than rewriting somebody's file. +/// +/// The two indexing traps are undone on the way out: slots go back to being +/// numbered from one, and a location with nothing to say is not written at +/// all rather than written as zero. +pub fn write_mul(mul: &Mul) -> String { + let mut out = + String::from("\n\n\n"); + for unit in &mul.units { + out.push_str(" \n 0 { + out.push_str(&format!(" hits=\"{}\"", unit.pilot_hits)); + } + out.push_str("/>\n"); -impl<'a> Tag<'a> { - /// The value of one attribute, with XML's five entities put back. - fn attr(&self, name: &str) -> Option<&'a str> { - let mut rest = self.body; - while let Some(at) = rest.find(name) { - let (before, after) = rest.split_at(at); - // A name only counts at a word boundary, or `type` matches - // `crewType` and `armorDivisor` matches `armor`. - let boundary = before.chars().last().is_none_or(char::is_whitespace); - let after = &after[name.len()..]; - let value = after.trim_start(); - if boundary && let Some(value) = value.strip_prefix('=') { - let value = value.trim_start(); - let quote = value.chars().next()?; - if quote == '"' || quote == '\'' { - let value = &value[1..]; - return value.find(quote).map(|end| &value[..end]); - } + for (index, (code, name)) in MEK_LOCATIONS.iter().enumerate() { + let rear = format!("RT{}", code.chars().next().unwrap_or('C')); + let front = unit.armor.get(*code); + let behind = unit.armor.get(&rear); + let internal = unit.structure.get(*code); + let gone = unit.destroyed.get(*name); + let empty = unit.empty_ammo.get(*name); + if front.is_none() + && behind.is_none() + && internal.is_none() + && gone.is_none() + && empty.is_none() + { + continue; + } + out.push_str(&format!(" {name}\n")); + if let Some(points) = front { + out.push_str(&format!(" \n")); + } + if let Some(points) = behind { + out.push_str(&format!( + " \n" + )); + } + if let Some(points) = internal { + out.push_str(&format!( + " \n" + )); + } + // Back to one-based, which is how the format numbers them. + for slot in gone.into_iter().flatten() { + out.push_str(&format!( + " \n", + slot + 1 + )); } - rest = after; + for slot in empty.into_iter().flatten() { + out.push_str(&format!( + " \n", + slot + 1 + )); + } + out.push_str(" \n"); } - None + out.push_str(" \n"); } + out.push_str("\n"); + out } -/// Every tag in the document, in order. -fn tags(text: &str) -> impl Iterator> { - let mut rest = text; - std::iter::from_fn(move || { - loop { - let open = rest.find('<')?; - let after = &rest[open + 1..]; - let close = after.find('>')?; - let inner = &after[..close]; - rest = &after[close + 1..]; - // Skip closing tags, declarations, comments and doctypes. - if inner.starts_with('/') || inner.starts_with('?') || inner.starts_with('!') { - continue; - } - let name_end = inner - .find(|c: char| c.is_whitespace() || c == '/') - .unwrap_or(inner.len()); - return Some(Tag { - name: &inner[..name_end], - body: &inner[name_end..], - }); +/// The five characters XML will not take literally in an attribute. +fn escape(out: &mut String, text: &str) { + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), } - }) + } } #[cfg(test)] @@ -319,7 +440,7 @@ mod tests { #[test] fn a_force_is_read_unit_by_unit() { - let mul = parse_mul(AFTER_A_FIGHT); + let mul = parse_mul(AFTER_A_FIGHT).unwrap(); assert_eq!(mul.units.len(), 2); assert_eq!(mul.units[0].display_name(), "Shadow Hawk SHD-2H"); assert_eq!(mul.units[1].display_name(), "Locust LCT-1V"); @@ -329,7 +450,7 @@ mod tests { #[test] fn the_crew_is_read_with_its_wounds() { - let unit = &parse_mul(AFTER_A_FIGHT).units[0]; + let unit = &parse_mul(AFTER_A_FIGHT).unwrap().units[0]; assert_eq!((unit.gunnery, unit.piloting), (4, 5)); assert_eq!(unit.pilot_hits, 2); assert_eq!(unit.pilot_name.as_deref(), Some("North 3")); @@ -340,7 +461,7 @@ mod tests { // not damage at all. #[test] fn destroyed_plate_is_none_left_and_n_a_is_not_damage() { - let unit = &parse_mul(AFTER_A_FIGHT).units[0]; + let unit = &parse_mul(AFTER_A_FIGHT).unwrap().units[0]; assert_eq!(unit.armor.get("CT"), Some(&0)); assert_eq!(unit.armor.get("RTC"), Some(&4)); assert_eq!(unit.structure.get("CT"), Some(&16)); @@ -350,7 +471,7 @@ mod tests { // A .mul numbers slots from one and everything else here from zero. #[test] fn slot_numbers_are_moved_to_zero_based() { - let unit = &parse_mul(AFTER_A_FIGHT).units[0]; + let unit = &parse_mul(AFTER_A_FIGHT).unwrap().units[0]; assert_eq!( unit.destroyed .get("Center Torso") @@ -378,7 +499,8 @@ mod tests { "#, - ); + ) + .unwrap(); assert_eq!(mul.units.len(), 3); assert_eq!( mul.machines() @@ -388,13 +510,143 @@ mod tests { ); } + // A name with an escape in it has to come back as the real character: + // MegaMek writes `&` and the library holds `&`, so a reader that keeps + // the escape matches nothing. This is what the hand-written scanner got + // wrong, silently. + #[test] + fn escapes_in_a_name_are_put_back() { + let mul = parse_mul( + r#""#, + ) + .unwrap(); + assert_eq!(mul.units[0].chassis, "Bob & Sons"); + assert_eq!(mul.units[0].model, "A'B <1>"); + } + + // Malformed input is refused rather than half-read. + #[test] + fn a_broken_file_is_an_error_not_a_guess() { + assert!(parse_mul(r#" (Mul, Mul) { + let once = parse_mul(text).expect("readable"); + let twice = parse_mul(&write_mul(&once)).expect("what we wrote is readable"); + (once, twice) + } + + #[test] + fn a_force_survives_being_written_and_read_again() { + let (once, twice) = round_trip(AFTER_A_FIGHT); + assert_eq!(once, twice); + } + + // Writing twice must give the same bytes: a round trip that changes the + // file every time is not one, and would make a diff of match state noise. + #[test] + fn writing_is_stable() { + let once = parse_mul(AFTER_A_FIGHT).unwrap(); + let first = write_mul(&once); + let second = write_mul(&parse_mul(&first).unwrap()); + assert_eq!(first, second); + } + + // Every shape the format has, in one unit, through the trip. + #[test] + fn each_kind_of_damage_survives() { + let (once, twice) = round_trip(AFTER_A_FIGHT); + let a = &once.units[0]; + let b = &twice.units[0]; + assert_eq!((a.armor.get("CT"), b.armor.get("CT")), (Some(&0), Some(&0))); + assert_eq!(a.armor.get("RTC"), b.armor.get("RTC"), "rear plate"); + assert_eq!(a.structure, b.structure, "internal structure"); + assert_eq!(a.destroyed, b.destroyed, "shot-out slots"); + assert_eq!(a.empty_ammo, b.empty_ammo, "empty magazines"); + assert_eq!( + (a.gunnery, a.piloting, a.pilot_hits), + (b.gunnery, b.piloting, b.pilot_hits), + "the crew and its wounds" + ); + } + + // Slot numbers cross the boundary twice and have to come back where they + // started: one-based in the file, zero-based here. + #[test] + fn slot_numbers_come_back_where_they_started() { + let mut unit = MulUnit { + chassis: "Atlas".into(), + model: "AS7-D".into(), + gunnery: 4, + piloting: 5, + ..Default::default() + }; + unit.destroyed + .insert("Right Arm".into(), [0, 5, 11].into_iter().collect()); + let written = write_mul(&Mul { + units: vec![unit.clone()], + }); + // Written one higher than they are held. + assert!(written.contains(r#"index="1" isHit="true""#)); + assert!(written.contains(r#"index="12" isHit="true""#)); + assert_eq!( + parse_mul(&written).unwrap().units[0].destroyed, + unit.destroyed + ); + } + + // A name with an escape in it survives being written and read back, which + // is the case a hand-written writer gets wrong in the other direction. + #[test] + fn an_escaped_name_survives_the_trip() { + let unit = MulUnit { + chassis: "Bob & Sons".into(), + model: "A'B <1> \"x\"".into(), + gunnery: 4, + piloting: 5, + ..Default::default() + }; + let back = parse_mul(&write_mul(&Mul { + units: vec![unit.clone()], + })) + .unwrap(); + assert_eq!(back.units[0].chassis, unit.chassis); + assert_eq!(back.units[0].model, unit.model); + } + + // A location that has nothing to say must not come back saying zero: an + // unmentioned location is undamaged, and writing it as zero would strip + // the design's armour on the next read. + #[test] + fn silence_about_a_location_is_not_a_reading_of_zero() { + let unit = MulUnit { + chassis: "Locust".into(), + model: "LCT-1V".into(), + gunnery: 4, + piloting: 5, + armor: [("CT".to_string(), 7)].into_iter().collect(), + ..Default::default() + }; + let written = write_mul(&Mul { + units: vec![unit.clone()], + }); + assert!(!written.contains("Left Arm"), "{written}"); + let back = parse_mul(&written).unwrap(); + assert_eq!(back.units[0].armor.len(), 1); + assert_eq!(back.units[0].armor.get("CT"), Some(&7)); + } + // `type` must not match `crewType`, and `armor` must not match // `armorDivisor`: an attribute name only counts at a word boundary. #[test] fn an_attribute_name_is_not_matched_inside_a_longer_one() { let mul = parse_mul( r#""#, - ); + ) + .unwrap(); assert_eq!(mul.units[0].unit_type.as_deref(), Some("Biped")); } }