"""The audit separates "did not fire" from "could not fire".""" import io import json import unittest from pathlib import Path from tempfile import TemporaryDirectory from sds import firingaudit def gun(weapon, name, usable=True, dead=False, blind=False): return { "weapon": weapon, "name": name, "location": "RA", "rearMounted": False, "usable": usable, "locationDestroyed": dead, "longRange": 9, "bears": [] if blind else [9], "bearsTwisted": [9], } def offer(weapon, name, rung, alpha, declared): return { "weapon": weapon, "name": name, "target": 9, "range": 5, "toHit": 7, "damage": 5.0, "heat": 3, "rung": rung, "alpha": alpha, "declared": declared, } def line(arsenal, offered): return json.dumps( { "seat": "sds", "phase": "FIRING", "unit": 1, "candidates": [], "chosen": 0, "firing": { "arsenal": arsenal, "offered": offered, "enemies": [{"unit": 9, "x": 1, "y": 1, "facing": 0, "range": 5}], }, } ) class FiringAudit(unittest.TestCase): def setUp(self): self.dir = TemporaryDirectory() self.addCleanup(self.dir.cleanup) self.log = Path(self.dir.name) / "a.decisions.jsonl" def test_a_destroyed_gun_never_counts_as_declining_a_shot(self): self.log.write_text( line( [gun(3, "PPC"), gun(4, "Medium Laser", usable=False, dead=True)], [offer(3, "PPC", 1, 2, True)], ) + "\n" ) counts = firingaudit.tally(firingaudit.rows(self.log)) self.assertEqual(counts["Medium Laser"].dead, 1) self.assertEqual(counts["Medium Laser"].alive, 0) self.assertEqual(counts["Medium Laser"].offered, 0) self.assertEqual(counts["PPC"].declared, 1) def test_an_undeclared_offer_keeps_the_rung_it_sat_on(self): self.log.write_text( line( [gun(3, "PPC"), gun(4, "Medium Laser")], [offer(3, "PPC", 1, 2, True), offer(4, "Medium Laser", 2, 2, False)], ) + "\n" ) counts = firingaudit.tally(firingaudit.rows(self.log)) laser = counts["Medium Laser"] self.assertEqual((laser.alive, laser.offered, laser.declared), (1, 1, 0)) self.assertEqual(laser.rungs, {2: 1}) self.assertEqual(laser.rate, 0.0) def test_a_live_gun_with_no_legal_shot_is_its_own_column(self): self.log.write_text( line([gun(3, "PPC"), gun(4, "Medium Laser")], [offer(3, "PPC", 1, 1, True)]) + "\n" ) counts = firingaudit.tally(firingaudit.rows(self.log)) self.assertEqual(counts["Medium Laser"].no_offer, 1) self.assertEqual(counts["Medium Laser"].offered, 0) def test_an_enemy_in_range_and_out_of_arc_is_counted_as_an_arc(self): self.log.write_text( line( [gun(3, "PPC"), gun(4, "Medium Laser", blind=True)], [offer(3, "PPC", 1, 1, True)], ) + "\n" ) counts = firingaudit.tally(firingaudit.rows(self.log)) laser = counts["Medium Laser"] self.assertEqual((laser.in_range, laser.arc, laser.twist), (1, 1, 1)) self.assertEqual(counts["PPC"].arc, 0) def test_every_weapon_lands_under_exactly_one_reason(self): # The whole claim of the per-decision account: "three fired" is only # readable beside where the rest went, so the statuses must partition # the arsenal rather than overlap it. plan = json.loads( line( [ gun(3, "PPC"), gun(4, "Medium Laser"), gun(5, "Small Laser", blind=True), gun(6, "SRM 4", usable=False), gun(7, "LRM 5", dead=True), ], [offer(3, "PPC", 1, 2, True), offer(4, "Medium Laser", 2, 2, False)], ) )["firing"] found = firingaudit.account(plan) self.assertEqual(len(found["weapons"]), 5) self.assertEqual(sum(found["counts"].values()), 5) self.assertEqual( {w["name"]: w["status"] for w in found["weapons"]}, { "PPC": "fired", "Medium Laser": "held", "Small Laser": "twist", "SRM 4": "unusable", "LRM 5": "dead", }, ) self.assertEqual(found["declared"], 1) self.assertEqual((found["depth"], found["alpha"]), (1, 2)) def test_a_gun_the_volley_stopped_short_of_says_where_it_stopped(self): plan = json.loads( line( [gun(3, "PPC"), gun(4, "Medium Laser")], [offer(3, "PPC", 1, 2, True), offer(4, "Medium Laser", 2, 2, False)], ) )["firing"] held = next(w for w in firingaudit.account(plan)["weapons"] if w["status"] == "held") self.assertIn("stopped at 1", held["note"]) def test_a_gun_with_nothing_in_reach_is_out_of_range_not_out_of_arc(self): # The two read the same on the page and mean opposite things: one is # answered by turning, the other only by moving. far = gun(4, "Medium Laser") far["longRange"] = 2 plan = json.loads(line([gun(3, "PPC"), far], [offer(3, "PPC", 1, 1, True)]))["firing"] found = {w["name"]: w["status"] for w in firingaudit.account(plan)["weapons"]} self.assertEqual(found["Medium Laser"], "range") def test_the_page_is_handed_a_sentence_for_every_status_it_shows(self): # `sds watch` prints these rather than wording the same status a second # way, so a status with no sentence is a blank cell on the page. plan = json.loads(line([gun(3, "PPC")], [offer(3, "PPC", 1, 1, True)]))["firing"] found = firingaudit.account(plan) self.assertEqual(sorted(found["why"]), sorted(found["counts"])) for label, sentence in found["why"].values(): self.assertTrue(label and sentence) def test_a_row_without_the_diagnostic_is_skipped(self): self.log.write_text(json.dumps({"phase": "MOVEMENT", "candidates": []}) + "\n") self.assertEqual(firingaudit.rows(self.log), []) def test_the_report_prints_every_weapon(self): self.log.write_text( line( [gun(3, "PPC"), gun(4, "Medium Laser")], [offer(3, "PPC", 1, 2, True), offer(4, "Medium Laser", 2, 2, False)], ) + "\n" ) out = io.StringIO() firingaudit.report(firingaudit.rows(self.log), out=out) text = out.getvalue() self.assertIn("PPC", text) self.assertIn("Medium Laser", text) self.assertIn("winning volley size", text) if __name__ == "__main__": unittest.main()