From da64c5cc27c703b52f8c83f302afdba5422fed0e Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 13:16:10 -0400 Subject: [PATCH] feat(unit-rules)!: read .mul with a real XML parser, and write it back The hand-written scanner claimed to put XML's entities back and did not, so a chassis with an ampersand in it matched no design and said nothing about why. quick-xml costs one small dependency, does no entity expansion and resolves no external entities, and left the wasm build 483 bytes smaller than the code it replaced. write_mul makes the reader testable: all 30 .mul files the install ships are read, written and read again unchanged, and the 35 units in them that this crate can score are worth the same on both sides of the trip. Change-Id: Id56b29605061ca7c8e020ab5c8cfb70d061a3505 --- Cargo.lock | 10 + Cargo.toml | 5 + crates/helm-bv/tests/conformance.rs | 110 +++++++ crates/helm-cli/src/main.rs | 2 +- crates/helm-unitfile/Cargo.toml | 1 + crates/helm-unitfile/src/lib.rs | 2 +- crates/helm-unitfile/src/mul.rs | 470 +++++++++++++++++++++------- 7 files changed, 489 insertions(+), 111 deletions(-) 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")); } } -- 2.51.2