From a54c23ffa04a93d00d43840cbdbd2efe2693a6ea Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 17 Aug 2026 19:37:05 -0400 Subject: [PATCH] test(scenarios): the guards two merged PRs claimed, and one they needed Three tests I said were in #236 and #239 are not on main, and were never in the commits either. The edit that inserted them keyed off a doc comment added by #234, which had not merged when those branches were cut, and a string replace whose needle is missing changes nothing and says nothing. The code landed; the tests silently did not, and I asserted them in both PR bodies without looking. So: every reported unit carries a battle value and the two names that prove the join is by designation land on the right figures; no faction repeats a designation, which is what makes that join unambiguous; and a scenario carries the patched bytes rather than a path to the image's unpatched copy. The fourth is new and is the one this pair needed. The report and the files it measures are two committed artifacts and nothing regenerates one when the other changes, so editing a vendored scenario without re-running ScenarioReport would leave every figure on a force card describing a fight nobody is playing. Both patches this repo carries were that shape of edit. Checked by mutation rather than by assertion: swapping a unit fails the report test, and reverting the CTR patch fails the carried-text test. --- services/api/src/matches/scenarios.rs | 129 ++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) 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 -- 2.51.2