Files
airdef/tools/test_gen_assets.py
chexit 325119b726 refactor: validate specs without a per-kind name list
The armed/support check in load_specs named the ten armed kinds, so a new
unit type would still have required a code edit and repeated the coupling
#91 is removing. Drop it: optional missile/gun rows are still validated
generically, and the existing kind-coverage check against content.json
already ties specs to the catalog.

validate_specs now takes the document, content identities and sound
events, and a test shows a hypothetical unit kind is accepted from a
specs row plus its content entry alone. No data or balance changes.

Verified: gen_catalog --check fresh, test_gen_assets 10 tests, core
release 195 passed + 8 scenarios (7 ignored), adapter 64 unit +
1 boundary + 5 scenarios, WASM contract 27 steps.
2026-11-04 21:00:35 +03:00

182 lines
8.6 KiB
Python

"""Safety and reproducibility tests for AirDef's asset generation pipeline."""
import hashlib
import importlib.util
import json
import subprocess
import os
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
TOOLS = Path(__file__).resolve().parent
sys.path.insert(0, str(TOOLS))
from airdef_assets import catalog
from airdef_assets import pipeline
from airdef_assets import icons
import gen_tex
SPEC = importlib.util.spec_from_file_location("gen_models", TOOLS / "gen_models.py")
gen_models = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(gen_models)
class ContentCatalogTests(unittest.TestCase):
def test_catalog_rust_entries_are_current_and_registered_items_match_assets(self):
root = TOOLS.parent
subprocess.run([sys.executable, str(TOOLS / "gen_catalog.py"), "--check"], check=True)
language = json.loads((root / "airdef-mod/assets/airdef/lang/ru_ru.json").read_text(encoding="utf-8"))
for name, title, _ in icons.ITEMS:
self.assertEqual(language[f"item.airdef.{name}"], title)
self.assertTrue((root / "airdef-mod/data/airdef/horizon/item" / (name + ".json")).is_file())
def test_a_new_unit_kind_needs_only_catalog_entries(self):
# The validator must not carry its own list of kind names: a hypothetical unit is
# accepted from a specs row plus its content entry, with no code change.
data = json.loads(json.dumps(catalog.load_specs()))
content = json.loads(json.dumps(catalog.load()))
template = next(entry for entry in data["units"] if entry["gun"] is not None)
added = dict(template, kind="TestGun", key="testgun", name="Test gun")
data["units"].append(added)
content["units"].append({"kind": "TestGun", "item": None})
events = {entry["event"] for entry in catalog.load_sounds()}
catalog.validate_specs(data, content, events)
class ManifestTests(unittest.TestCase):
def test_manifest_rejects_traversal_and_duplicate_paths(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "manifest.txt"
path.write_text("0" * 64 + " ../outside\n", encoding="utf-8")
with self.assertRaises(ValueError):
pipeline.read_manifest(path)
path.write_text("0" * 64 + " safe.txt\n" + "1" * 64 + " safe.txt\n", encoding="utf-8")
with self.assertRaises(ValueError):
pipeline.read_manifest(path)
def test_plan_protects_untracked_and_modified_files_and_keeps_edited_obsolete_file(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "tracked.txt").write_text("edited", encoding="utf-8")
(root / "obsolete.txt").write_text("artist edit", encoding="utf-8")
(root / "untracked.txt").write_text("user", encoding="utf-8")
old = {
"tracked.txt": hashlib.sha256(b"old generated").hexdigest(),
"obsolete.txt": hashlib.sha256(b"old generated").hexdigest(),
}
plan = pipeline.make_plan(root, old, {"tracked.txt": hashlib.sha256(b"new").hexdigest(),
"untracked.txt": hashlib.sha256(b"output").hexdigest()})
self.assertEqual(plan.conflict, ["tracked.txt", "untracked.txt"])
self.assertEqual(plan.keep, ["obsolete.txt"])
self.assertEqual(plan.remove, [])
def test_install_rolls_back_files_and_manifest_after_replace_error(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "runtime"
stage = Path(tmp) / "stage"
root.mkdir()
stage.mkdir()
manifest = root / "generated-manifest.txt"
manifest.write_text("old manifest\n", encoding="utf-8")
for name in ("a.txt", "b.txt"):
(root / name).write_text("old " + name, encoding="utf-8")
(stage / name).write_text("new " + name, encoding="utf-8")
old = {name: pipeline.sha256(root / name) for name in ("a.txt", "b.txt")}
new = {name: pipeline.sha256(stage / name) for name in ("a.txt", "b.txt")}
plan = pipeline.make_plan(root, old, new)
real_replace = os.replace
calls = 0
def fail_once(src, dst):
nonlocal calls
calls += 1
if calls == 3:
raise OSError("simulated write failure")
real_replace(src, dst)
with self.assertRaisesRegex(OSError, "simulated"):
pipeline.install(root, stage, manifest, plan, new, replace=fail_once)
self.assertEqual((root / "a.txt").read_text(), "old a.txt")
self.assertEqual((root / "b.txt").read_text(), "old b.txt")
self.assertEqual(manifest.read_text(), "old manifest\n")
class ReproducibilityTests(unittest.TestCase):
def test_texture_renders_are_seed_deterministic(self):
first = gen_tex.camo("nato_green", 1234, 32)
second = gen_tex.camo("nato_green", 1234, 32)
self.assertEqual(first.tobytes(), second.tobytes())
def test_authored_override_is_merged_without_changing_source(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "source-assets"
stage = root / "stage"
authored = source / "assets/airdef/textures/item/custom.png"
authored.parent.mkdir(parents=True)
authored.write_bytes(b"authored")
stage.mkdir()
pipeline.overlay_sources(stage, source)
self.assertEqual((stage / "assets/airdef/textures/item/custom.png").read_bytes(), b"authored")
self.assertEqual(authored.read_bytes(), b"authored")
def test_full_pipeline_installs_valid_icon_override_and_rejects_bad_one_without_mutation(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "mod"
root.mkdir()
source = root / "source-assets"
manifest = root / "generated-manifest.txt"
icon_source = source / "assets/airdef/textures/item/deploy_gepard.png"
icon_source.parent.mkdir(parents=True)
self.assertTrue(gen_models.regenerate(root, source, manifest))
previous_icon = (root / "assets/airdef/textures/item/deploy_gepard.png").read_bytes()
previous_manifest = manifest.read_bytes()
self.assertFalse(gen_models.regenerate(root, source, manifest))
Image.new("RGBA", (16, 16), (12, 34, 56, 255)).save(icon_source)
authored_bytes = icon_source.read_bytes()
self.assertTrue(gen_models.regenerate(root, source, manifest))
installed = root / "assets/airdef/textures/item/deploy_gepard.png"
self.assertEqual(installed.read_bytes(), authored_bytes)
self.assertEqual(icon_source.read_bytes(), authored_bytes)
self.assertNotEqual(installed.read_bytes(), previous_icon)
installed_before_bad_override = installed.read_bytes()
manifest_before_bad_override = manifest.read_bytes()
Image.new("RGBA", (20, 16), (255, 0, 0, 255)).save(icon_source)
invalid_source = icon_source.read_bytes()
with self.assertRaisesRegex(ValueError, "не квадратная"):
gen_models.regenerate(root, source, manifest)
self.assertEqual(installed.read_bytes(), installed_before_bad_override)
self.assertEqual(manifest.read_bytes(), manifest_before_bad_override)
self.assertEqual(icon_source.read_bytes(), invalid_source)
self.assertNotEqual(previous_manifest, manifest_before_bad_override)
def test_icon_catalog_has_stable_explicit_renderer_api(self):
entry = next(item for item in icons.ITEMS if item[0] == "deploy_gepard")
first = icons.render_icon(entry[0], entry[2], 1234)
second = icons.render_icon(entry[0], entry[2], 1234)
self.assertEqual(first.tobytes(), second.tobytes())
def test_full_generated_tree_is_reproducible(self):
with tempfile.TemporaryDirectory() as tmp:
outputs = []
for name in ("first", "second"):
root = Path(tmp) / name
root.mkdir()
gen_models.generate(str(root))
outputs.append({
str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
for path in sorted(root.rglob("*")) if path.is_file()
})
self.assertEqual(outputs[0], outputs[1])
if __name__ == "__main__":
unittest.main()