diff --git a/services/api/src/matches/scenarios.rs b/services/api/src/matches/scenarios.rs index 9349649..8c1fb1a 100644 --- a/services/api/src/matches/scenarios.rs +++ b/services/api/src/matches/scenarios.rs @@ -309,6 +309,135 @@ mod tests { assert_eq!(steiner.deploy, "E"); } + /// Every unit the report covers gets its battle value, and the values are + /// the ones a real load produced rather than anything computed here. + #[test] + fn units_carry_the_reported_battle_value() { + let monte = find("MonteDiablo/MonteDiablo-Four_Players.mms").unwrap(); + let steiner = monte.slots.iter().find(|s| s.name == "Steiner2nd").unwrap(); + let bv = |name: &str| { + steiner + .units + .iter() + .find(|u| u.name == name) + .and_then(|u| u.bv) + }; + // Joined by name, not position: the report hands these back in a + // different order from the file, so a positional join would give the + // Falconer the Rakshasa's figure. + assert_eq!(bv("Fafnir FNR-5"), Some(3796)); + assert_eq!(bv("Falconer FLC-8R"), Some(2945)); + for scenario in all() { + for slot in &scenario.slots { + for unit in &slot.units { + assert!(unit.bv.is_some(), "{} has no battle value", unit.name); + } + } + } + } + + /// The join above is by designation, so a faction fielding two of the same + /// machine would be ambiguous - and ambiguous precisely when the two crews + /// differ, which is when the figures differ too. + #[test] + fn report_names_are_unique_within_a_faction() { + for scenario in all() { + for slot in &scenario.slots { + let mut names: Vec<&str> = slot.units.iter().map(|u| u.name.as_str()).collect(); + names.sort_unstable(); + let before = names.len(); + names.dedup(); + assert_eq!( + names.len(), + before, + "{} {} fields a repeated designation", + scenario.path, + slot.name + ); + } + } + } + + /// A scenario carries its own bytes, because those are the bytes that get + /// played. Naming a path instead let arena load its image's copy, which is + /// the unpatched one. + #[test] + fn a_scenario_carries_the_file_it_parsed() { + let monte = find("MonteDiablo/MonteDiablo-Four_Players.mms").unwrap(); + assert!( + monte.text.contains("team_Steiner2nd=1"), + "the carried text is not the patched file" + ); + assert!( + monte.text.contains("Location_Davion=CTR"), + "the carried text is not the patched file" + ); + for scenario in all() { + assert!( + !scenario.text.is_empty(), + "{} carries nothing", + scenario.path + ); + } + } + + /// The report and the files it measures are two committed artifacts, and + /// nothing regenerates one when the other changes. Editing a vendored + /// scenario without re-running `ScenarioReport` would leave every figure on + /// a force card describing a fight nobody is playing - silently, because a + /// battle value that is merely wrong still renders. + /// + /// Both patches this repo carries were that shape of edit. Neither changed + /// a unit, so neither invalidated a figure; the next one might. + #[test] + fn the_report_describes_the_files_it_sits_beside() { + let parsed: serde_json::Value = serde_json::from_str(REPORT).unwrap(); + let reported = parsed.as_array().unwrap(); + assert_eq!( + reported.len(), + FILES.len(), + "the report covers a different set of scenarios than the catalog" + ); + for scenario in all() { + let entry = reported + .iter() + .find(|r| r["file"].as_str() == Some(scenario.path)) + .unwrap_or_else(|| panic!("{} has no report entry", scenario.path)); + + let sides: Vec<&str> = scenario.slots.iter().map(|s| s.name.as_str()).collect(); + let measured: Vec<&str> = entry["factions"] + .as_array() + .unwrap() + .iter() + .map(|f| f["name"].as_str().unwrap_or_default()) + .collect(); + assert_eq!(sides, measured, "{}: factions differ", scenario.path); + + for (slot, faction) in scenario + .slots + .iter() + .zip(entry["factions"].as_array().unwrap()) + { + // Sorted, because the report hands units back in MegaMek's own + // order rather than the file's - see `reported_bv`. + let mut mine: Vec<&str> = slot.units.iter().map(|u| u.name.as_str()).collect(); + let mut theirs: Vec<&str> = faction["units"] + .as_array() + .unwrap() + .iter() + .map(|u| u["name"].as_str().unwrap_or_default()) + .collect(); + mine.sort_unstable(); + theirs.sort_unstable(); + assert_eq!( + mine, theirs, + "{} {}: the report measures different machines", + scenario.path, slot.name + ); + } + } + } + /// Nobody deploys "Any". Upstream leaves the two defending forces without /// a zone, which is a tabletop instruction - the defender places them - /// and becomes "scatter across the whole three-map board" once MegaMek is