"""Every tactic a formation can open on has a scenario that produces it. A tactic reaches the scorer because some force's composition asked for it. `Bombard` sat in the vocabulary with a live formation - `Fire Support` - asking for it every match, opened on `Engage` instead because the tactic was unbuilt, and nothing noticed. `Harass` was in the same state for a different reason: it was built, and no scenario in the repository fielded a force that produced it. Both were found by hand. This is the test that finds the next one, and it fails on the **addition** rather than on the omission: a tactic added to the opening vocabulary without a scenario exercising it cannot land quietly. **This proves a formation opens on a tactic, not that the tactic's behaviour ever happens.** A scenario cannot force a behaviour the bot is correctly declining to choose - it can only create the conditions under which a tactic would choose it. A passing row here means some force asked for the tactic, and says nothing about whether what the tactic is *for* occurred in the match. **What this does not cover.** `Break`, `Regroup` and `Withdraw` have `Assignment` variants and no `Opening` variant: they are the coordinator's response to match *state* - damage, cohesion, a losing exchange - rather than to who is in the lance. A scenario can force an opening by composition; it cannot force a transition, and a scenario that reliably produced the *situation* would be a much weaker test. Those three have no scenario-level cover and this test does not pretend otherwise. **A force of fewer than three machines cannot hold a formation** - `Formed::holds` refuses below that - so its opening never reaches the scorer. Counting every force would roughly double the sample with rows that decide nothing, which is why only `n >= 3` counts here. **This is not enforced on commit, and believing otherwise is the failure it was written to prevent.** Classifying every scenario starts a container per suite and costs about a minute, which is too much for a hook that fires on any `.py` edit, so the half that needs Docker runs only under `SDS_TACTIC_COVERAGE=1`: SDS_TACTIC_COVERAGE=1 python3 -m unittest tests.test_tactic_coverage The structural half - that `UNCOVERED` names openings which exist - is free and always runs. **There is no periodic job in this repository to hang the rest on** today: no release script, no scheduled suite, nothing that runs the `#[ignore]`d work. Until there is, this runs when somebody runs it, and a tactic added with no scenario will not be caught at the moment it lands. """ import os import re import subprocess import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] #: Set to run the half that classifies scenarios, which needs Docker and about a #: minute. Off by default so the harness suite stays fast and hermetic. COVERAGE = os.environ.get("SDS_TACTIC_COVERAGE") WHY_SKIPPED = "set SDS_TACTIC_COVERAGE=1 to classify scenarios (needs Docker, ~1 min)" #: Openings a formation can ask for, read off `Opening` in `formation.rs` so a #: variant added there without a scenario fails this rather than being missed. OPENING_ENUM = re.compile(r"pub enum Opening \{(.*?)\n\}", re.S) #: Openings with no scenario, each with the reason it has none. An entry here is #: a boundary and has to carry its argument; an empty reason is a hole. UNCOVERED: dict[str, str] = {} def openings() -> set[str]: """The openings a formation may ask for, from the Rust enum itself.""" text = (ROOT / "crates/sds-core/src/formation.rs").read_text() body = OPENING_ENUM.search(text) assert body, "no `Opening` enum in formation.rs" found = set(re.findall(r"^ (\w+)", body.group(1), re.M)) # `Blocked` names a tactic nothing can reach, which is the opposite of a # tactic to write a scenario for. return {name.lower() for name in found - {"Blocked"}} def largest_force(scenario: Path) -> int: """The biggest single force in a scenario, counted off its `Unit_` lines.""" sides: dict[str, int] = {} for line in scenario.read_text().splitlines(): named = re.match(r"Unit_(\w+?)_\d+=", line) if named: sides[named.group(1)] = sides.get(named.group(1), 0) + 1 return max(sides.values(), default=0) def produced() -> dict[str, set[str]]: """Which scenario produces each opening, for forces that can hold one. Classified by the same path production uses - `sds forces` loads the `.mms` through the bridge and asks `sds_core::formation`. A test that classified scenarios by a second route would be a test of the route. """ found: dict[str, set[str]] = {} for suite in sorted((ROOT / "scenarios").iterdir()): if not suite.is_dir() or not any(suite.glob("*.mms")): continue # A directory whose largest force is under three machines cannot hold a # formation, so classifying it starts a container to learn nothing. The # count is off the file rather than off the classifier, which is the one # thing about a scenario that can be read without loading it. if max(largest_force(f) for f in suite.glob("*.mms")) < 3: continue done = subprocess.run( ["./sds.sh", "forces", "--suite", str(suite)], cwd=ROOT, capture_output=True, text=True, ) for line in done.stdout.splitlines(): row = re.match( r"(\S+)\s+(?:North|South)\s+(\d+)\s+.*?\b" r"(engage|advance|harass|flank|entrench|bombard)\b", line, ) if row and int(row.group(2)) >= 3: found.setdefault(row.group(3), set()).add(row.group(1)) return found class TestTacticCoverage(unittest.TestCase): @unittest.skipUnless(COVERAGE, WHY_SKIPPED) def test_every_opening_has_a_scenario_that_produces_it(self) -> None: seen = produced() missing = sorted(openings() - set(seen) - set(UNCOVERED)) self.assertEqual( missing, [], "no scenario fields a force that opens on these, so nothing " "exercises them: write one under `scenarios/tactics/`, or record " "why it cannot be written in `UNCOVERED` with its reason", ) def test_an_uncovered_opening_is_still_an_opening(self) -> None: """`UNCOVERED` cannot drift into naming something that does not exist.""" self.assertEqual(sorted(set(UNCOVERED) - openings()), []) @unittest.skipUnless(COVERAGE, WHY_SKIPPED) def test_an_uncovered_opening_is_actually_uncovered(self) -> None: """An entry that a scenario now produces must be deleted, not kept. A stale exemption is worse than a missing one: it says nothing tests this while something does, and the next person believes it. """ seen = produced() stale = sorted(name for name in UNCOVERED if name in seen) self.assertEqual(stale, [], "these are covered now; drop them from UNCOVERED") if __name__ == "__main__": unittest.main()