"""Tests for design_rules.py. python3 test_design_rules.py # unittest + printed ground-truth report python3 test_design_rules.py -v Ground truth = every design in the three real saves (stock_designs.json, regenerated from the .sav files by stock_designs.py when missing) and the scenario fleet templates (data/*_FleetTemplates.csv). """ from __future__ import annotations import csv import json import sys import unittest from collections import Counter from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) from design_rules import (Catalog, SLOTS, applied_techs, derive_stats, validate, # noqa: E402 weapon_fits_bank) STOCK = HERE / "stock_designs.json" def stock_players() -> list[dict]: if not STOCK.exists(): import stock_designs stock_designs.main([]) return json.loads(STOCK.read_text()) def errors(viols): return [v for v in viols if v.level == "error"] class TestCatalog(unittest.TestCase): @classmethod def setUpClass(cls): cls.cat = Catalog.load() def test_counts(self): c = self.cat self.assertEqual(sum(len(v) for v in c.sections.values()), 875) self.assertEqual(len(c.weapons), 207) self.assertEqual(len(c.techs), 293) self.assertEqual(len(c.turrets), 42) self.assertEqual(len(c.default_weapons), 31) def test_lookup_case_insensitive(self): c = self.cat self.assertIsNotNone(c.section("human", "decommand")) self.assertIsNotNone(c.section("Human", "Species/Human/sections/DEArmor.shipsection")) self.assertIs(c.weapon("BAL_GAUSS"), c.weapon(8)) self.assertIs(c.weapon("Weapons/mis.weapon"), c.weapon(21)) self.assertTrue(c.tech_known("GRP_Torps", {"wep_phottrp"})) self.assertFalse(c.tech_known("GRP_Torps", {"wep_redlas"})) def test_race_gating(self): c = self.cat self.assertIn("tarkas", c.unobtainable["drv_node"]) self.assertNotIn("human", c.unobtainable["drv_node"]) self.assertIn("human", c.unobtainable["drv_hyper"]) def test_every_player_bank_has_a_fitting_player_weapon(self): """Catalog self-consistency: every bank of every player-race section can take at least one player weapon under the fit rule.""" c = self.cat player_weapons = [w for w in c.weapons.values() if w.scope == "player"] unfit = [] for race, secs in c.sections.items(): if race == "_NPC": continue for sd in secs.values(): for b in sd.banks: if b.fixed_weapon or b.size is None: continue if not any(weapon_fits_bank(c, b, w)[0] for w in player_weapons): unfit.append((race, sd.stem, b.index, b.size, b.cls)) self.assertEqual(unfit, []) def test_every_player_weapon_fits_some_bank(self): c = self.cat banks = [b for race, secs in c.sections.items() if race != "_NPC" for sd in secs.values() for b in sd.banks if b.size] orphans = [] for w in c.weapons.values(): if w.scope != "player": continue if not any(weapon_fits_bank(c, b, w)[0] for b in banks): orphans.append((w.stem, w.size, w.cls)) self.assertEqual(orphans, []) class TestRules(unittest.TestCase): @classmethod def setUpClass(cls): cls.cat = Catalog.load() def armor(self, **over): d = { "name": "Armor", "race": "Human", "command": {"section": "DECommand", "weapons": ["bal_gauss"]}, "mission": {"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"]}, "engine": {"section": "DEFission", "weapons": ["bal_gauss"]}, "known_techs": ["DRV_Fissn", "DRV_Node", "WEP_GsDrvr"], } d.update(over) return d def rules(self, design): return sorted({v.rule for v in errors(validate(design, self.cat))}) def test_valid(self): self.assertEqual(validate(self.armor(), self.cat), []) def test_A2_missing_mission(self): self.assertEqual(self.rules(self.armor(mission=None)), ["A2"]) def test_A3_hull_needs_command_and_engine(self): self.assertEqual(self.rules(self.armor(engine=None)), ["A3"]) def test_A3_standalone_cannot_take_partners(self): d = self.armor(mission={"section": "DEDefencePlatform", "weapons": ["bal_gauss"] * 4 + ["mis"]}) self.assertEqual(self.rules(d), ["A3"]) d.update(command=None, engine=None) self.assertEqual(self.rules(d), []) def test_A4_wrong_slot(self): d = self.armor(command={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"]}) self.assertEqual(self.rules(d), ["A4"]) def test_A5_wrong_race(self): self.assertEqual(self.rules(self.armor(race="Klingon", known_techs=None)), ["A5"]) # DENodeMissile exists only in the Human catalog d = self.armor(race="Zuul", known_techs=None, mission={"section": "DENodeMissile", "weapons": ["mis_node"]}) self.assertEqual(self.rules(d), ["A5"]) def test_A6_class_mix(self): d = self.armor(engine={"section": "CRFission", "weapons": ["bal_gauss"] * 2}, known_techs=None) # CRFission has 2 banks in the Human catalog? use whatever the catalog says sd = self.cat.section("Human", "CRFission") d["engine"]["weapons"] = ["bal_gauss"] * len(sd.banks) self.assertIn("A6", self.rules(d)) def test_A7_exclude(self): cat = self.cat cmd = cat.section("Hiver", "DERamScoop") eng = cat.section("Hiver", "DEFission") mis = cat.section("Hiver", "DEArmor") def fill(sd): return {"section": sd.stem, "weapons": [None] * len(sd.banks)} d = {"race": "Hiver", "command": fill(cmd), "mission": fill(mis), "engine": fill(eng)} self.assertIn("A7", self.rules(d)) def test_A8_station_partners(self): cat = self.cat st = cat.section("Human", "DNStationCommand") fore = cat.section("Human", "DNStationCommand_Fore") aft = cat.section("Human", "DNStationCommand_Aft") def fill(sd): return {"section": sd.stem, "weapons": ["bal_gauss"] * len(sd.banks)} d = {"race": "Human", "command": fill(fore), "mission": fill(st), "engine": fill(aft)} self.assertNotIn("A8", self.rules(d)) d["command"] = fill(cat.section("Human", "DNCommand")) self.assertIn("A8", self.rules(d)) def test_B1_bank_count(self): d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss"]}) self.assertEqual(self.rules(d), ["B1", "B2"]) def test_B4_fit(self): # large-only torpedo into a small standard bank d = self.armor(command={"section": "DECommand", "weapons": ["trp_photon"]}, known_techs=None) self.assertEqual(self.rules(d), ["B4"]) # PD (tiny) fits small, not medium d = self.armor(mission={"section": "DEArmor", "weapons": ["las_pd", "las_pd", "las_pd"]}, known_techs=None) self.assertEqual([v.bank for v in errors(validate(d, self.cat))], [2]) def test_B4_missile_in_standard(self): d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"]}) self.assertEqual(self.rules(d), []) d = self.armor(mission={"section": "DEArmor", "weapons": ["mis", "bal_gauss", "mis"]}) self.assertEqual(self.rules(d), ["B4"]) # medium missile does not fit a small bank def test_B6_exclusive_species(self): d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "bal_grapple"]}, known_techs=None) self.assertEqual(self.rules(d), ["B6"]) def test_C_tech_gating(self): self.assertEqual(self.rules(self.armor(known_techs=["DRV_Fissn", "DRV_Node"])), ["C2"]) self.assertEqual(self.rules(self.armor(known_techs=["WEP_GsDrvr", "DRV_Node"])), ["C1"]) d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"], "options": ["IND_PlyAlloy"]}) self.assertEqual(self.rules(d), ["C3"]) def test_C4_hull_class_tech(self): cat = self.cat def fill(stem): sd = cat.section("Human", stem) return {"section": sd.stem, "weapons": ["bal_gauss"] * len(sd.banks)} d = {"race": "Human", "command": fill("CRCommand"), "mission": fill("CRArmor"), "engine": fill("CRFission"), "known_techs": ["DRV_Fissn", "DRV_Node", "WEP_GsDrvr"]} self.assertEqual(self.rules(d), ["C4"]) d["known_techs"].append("IND_CruisCon") self.assertEqual(self.rules(d), []) def test_C5_race_can_never(self): cat = self.cat sd = cat.section("Tarkas", "DEFission") # requires DRV_Hyper: fine for Tarkas self.assertTrue(all(v.rule != "C5" for v in validate( {"race": "Tarkas", "command": {"section": "DECommand", "weapons": ["bal_gauss"] * 1}, "mission": {"section": "DEArmor", "weapons": ["bal_gauss", "mis"]}, "engine": {"section": sd.stem, "weapons": ["bal_gauss"] * len(sd.banks)}}, cat))) def test_D1_options(self): d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"], "options": ["IND_PlyAlloy", "IND_MagLat"]}, known_techs=["DRV_Fissn", "DRV_Node", "WEP_GsDrvr", "IND_PlyAlloy", "IND_MagLat", "SLD_MkOne"]) self.assertEqual(self.rules(d), ["D1"]) d["mission"]["options"] = ["SLD_MkOne"] self.assertEqual(self.rules(d), ["D1"]) d["mission"]["options"] = ["IND_MagLat"] self.assertEqual(self.rules(d), []) def test_applied_techs_order(self): cat = self.cat sd = cat.section("_NPC", "_DEEngine") self.assertEqual(applied_techs({"options": ["IND_RefCoat", "DRV_RecFiss", "IND_PlyAlloy"]}, sd, cat), ["DRV_Fissn", "DRV_Node", "IND_PlyAlloy", "IND_RefCoat", "DRV_RecFiss"]) sd = cat.section("_NPC", "_CRRipperCommand") self.assertEqual(applied_techs({"options": ["IND_MagLat", "IND_RefCoat"]}, sd, cat), ["IND_CruisCon", "IND_MagLat", "IND_RefCoat"]) sd = cat.section("_NPC", "_AsteroidMonitor") # station: no hull-class tech self.assertEqual(applied_techs({"options": ["IND_QrkRes"]}, sd, cat), ["IND_QrkRes"]) def test_derive_stats(self): st = derive_stats(self.armor(), self.cat) self.assertEqual(st["mass"], 800 + 2000 + 2500) self.assertEqual(st["section_cost"], 2000 + 4000 + 5000) self.assertEqual(st["turrets"], 1 + 3 + 3 + 1 + 1) self.assertEqual(st["weapon_cost_per_bank"], 50 * 4 + 3000) self.assertEqual(st["ftlspeed"], 0.2) self.assertEqual(st["applied_techs"]["engine"], ["DRV_Fissn", "DRV_Node"]) class TestStockDesigns(unittest.TestCase): """Every design in the real saves must validate (structure + weapons + options + tech gating against that player's researched techs) and its DOpts must equal applied_techs().""" @classmethod def setUpClass(cls): cls.cat = Catalog.load() cls.players = stock_players() def all_designs(self): for p in self.players: for d in p["designs"]: yield p, d def test_have_designs(self): n = sum(1 for _ in self.all_designs()) self.assertGreater(n, 30) def test_no_mixed_species(self): for p, d in self.all_designs(): self.assertNotIn("mixed_species", d, f"{p['save']} {d['name']}") def test_player_designs_validate(self): failures = [] for p, d in self.all_designs(): if p["npc"]: continue errs = errors(validate(d, self.cat)) if errs: failures.append((p["save"], p["player"], d["name"], [str(e) for e in errs])) self.assertEqual(failures, []) def test_hidden_default_rider_designs_bypass_tech_gating(self): """Code rule the data does not show: the engine creates the hidden 'Default Assault Shuttle' design for every race at game start even when the rider section's `requires` is unresearched (Tarkas _AssaultShuttle requires DRN_AdvFrm).""" seen = 0 for p, d in self.all_designs(): if d["name"] != "Default Assault Shuttle" or d["race"] != "Tarkas": continue seen += 1 viols = validate(d, self.cat) self.assertTrue(d["hidden"]) self.assertEqual(errors(viols), []) self.assertIn("C1", {v.rule for v in viols if v.level == "warn"}) self.assertGreaterEqual(seen, 3) def test_npc_designs_validate_structurally(self): failures = [] for p, d in self.all_designs(): if not p["npc"]: continue dd = dict(d) dd["known_techs"] = None if p["n_techs"] == 0 else p["researched"] errs = errors(validate(dd, self.cat)) if errs: failures.append((p["save"], p["player"], d["name"], [str(e) for e in errs])) self.assertEqual(failures, []) def test_dopts_equals_applied_techs(self): mism = [] for p, d in self.all_designs(): for slot in SLOTS: use = d.get(slot) if not use: continue sd = self.cat.section(d["race"], use["section"]) got = applied_techs(use, sd, self.cat) want = use.get("save_opts", []) if [g.lower() for g in got] != [w.lower() for w in want]: mism.append((p["save"], d["name"], slot, want, got)) self.assertEqual(mism, []) def test_bank_count_matches_save(self): for p, d in self.all_designs(): for slot, raw in zip(SLOTS, d["raw_slots"]): use = d.get(slot) if not use: continue sd = self.cat.section(d["race"], use["section"]) self.assertEqual(raw["bank_count"], len(sd.banks), f"{d['name']} {slot} {sd.stem}") class TestScenarioTemplates(unittest.TestCase): """Scenario fleet templates are (command, mission, engine) stems; check the structural rules for every race that carries all three sections.""" @classmethod def setUpClass(cls): cls.cat = Catalog.load() def templates(self): for f in sorted((HERE / "data").glob("*FleetTemplates.csv")): for row in csv.reader(l for l in f.read_text().splitlines() if l.strip() and not l.startswith("#")): if len(row) >= 4: yield f.name, row[0].strip(), row[1].strip(), row[2].strip(), row[3].strip() def test_templates_structurally_valid(self): cat = self.cat checked = 0 failures = [] for fname, fleet, c, m, e in self.templates(): for race in cat.sections: if race == "_NPC": continue sds = [cat.section(race, x) for x in (c, m, e)] if any(s is None for s in sds): continue d = {"race": race, "hidden": True} for slot, sd in zip(SLOTS, sds): d[slot] = {"section": sd.stem, "weapons": [None] * len(sd.banks)} errs = [v for v in validate(d, cat) if v.level == "error" and v.rule not in ("B2",)] checked += 1 if errs: failures.append((fname, fleet, race, c, m, e, [str(x) for x in errs])) self.assertGreater(checked, 100) self.assertEqual(failures, []) def report(): cat = Catalog.load() players = stock_players() print("\n=== ground truth: designs in real saves ===") tot = Counter() for p in players: for d in p["designs"]: viols = validate(d, cat) errs = errors(viols) tot["designs"] += 1 tot["with_errors"] += bool(errs) secs = " + ".join(f"{d[s]['section']}" for s in SLOTS if d.get(s)) flag = "FAIL" if errs else "ok " print(f"{flag} {p['save']:16} {p['player'][:18]:18} {d['race']:7} {d['name'][:24]:24} {secs}") for v in viols: print(f" {v}") print(f"{tot['designs']} designs, {tot['with_errors']} with error-level violations") if __name__ == "__main__": if "--report" in sys.argv: sys.argv.remove("--report") report() unittest.main()