"""Тесты конвейера тварей: упаковка, манифест, проверки, покрытие. Blender здесь не запускается. Проверяется ровно то, что ломается молча: кадры, разложенные не в том порядке; тварь, вылезшая за кромку клетки; sync, затирающий ручную спеку; и — главное для ЭТОГО конвейера — расхождение имён между манифестом и tools/asset_coverage.py. python -m unittest discover -s tools/tests """ from __future__ import annotations import importlib.util import shutil import sys import tempfile import unittest from pathlib import Path from PIL import Image import yaml ROOT = Path(__file__).resolve().parents[2] def _load(name: str, relative: str): spec = importlib.util.spec_from_file_location(name, ROOT / relative) module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) return module ma = _load("monster_assets", "tools/monster_assets.py") coverage = _load("asset_coverage", "tools/asset_coverage.py") class Sandbox(unittest.TestCase): def setUp(self) -> None: self.dir = Path(tempfile.mkdtemp(prefix="monster_assets_")) self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) self.ws = ma.Workspace(self.dir) self.ws.catalog.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(ROOT / "assets/monsters/catalog.yaml", self.ws.catalog) self.cfg = ma.load_config(self.ws) def render_frame(self, marks, supersample=1): """Кадр Blender: непрозрачные квадраты в координатах кадра рендера.""" size = (self.cfg.render_width * supersample, self.cfg.render_height * supersample) image = Image.new("RGBA", size, (0, 0, 0, 0)) for x, y, w, h in marks: block = Image.new("RGBA", (w * supersample, h * supersample), (150, 40, 40, 255)) image.paste(block, (x * supersample, y * supersample)) return image def sheet_from_cells(self, animation, boxes): cfg = self.cfg sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0)) for index, cell_boxes in enumerate(boxes): layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0)) for x, y, w, h in cell_boxes: layer.paste(Image.new("RGBA", (w, h), (150, 40, 40, 255)), (x, y)) ma.paste_cell(cfg, sheet, index, layer) return sheet def save(self, kind, animation, sheet): path = self.ws.source_path(kind, animation) path.parent.mkdir(parents=True, exist_ok=True) sheet.save(path) return path def creature(self, animation, width=20, height=30, lean=None): """Клип: зеркальные пары совпадают точно, соседние — заметно разные. Снос по X берётся от запаса до кромки: чем уже тварь, тем сильнее её разносит по направлениям. Иначе прямоугольники соседей перекрываются настолько, что перестановка направлений проходит проверку незамеченной. """ cfg = self.cfg lean = (cfg.width - width) // 6 if lean is None else lean # (ширина, снос от pivot_x) по каталожному порядку направлений shape = [(width, 0), (width - 2, -lean), (width - 8, -3 * lean), (width - 2, -lean), (width - 2, 0), (width - 2, lean), (width - 8, 3 * lean), (width - 2, lean)] cells = [] for w, dx in shape: for _ in range(cfg.frames(animation)): cells.append([(cfg.pivot_x - w // 2 + dx, cfg.pivot_y - height, w, height)]) return self.sheet_from_cells(animation, cells) # --------------------------------------------------------------------------- # Геометрия каталога и упаковка кадров Blender # --------------------------------------------------------------------------- class TestCatalog(Sandbox): def test_render_frame_is_symmetric_about_the_pivot(self): cfg = self.cfg self.assertEqual((cfg.width, cfg.height), (96, 112)) self.assertEqual(cfg.render_width, 96) self.assertEqual(cfg.render_height, 176) self.assertEqual((cfg.crop_x, cfg.crop_y), (0, 0)) self.assertEqual(cfg.render_height // 2 - cfg.crop_y, cfg.pivot_y) def test_pivot_leaves_room_for_the_ground_plane_behind_the_creature(self): """Под точкой опоры обязан быть запас: хвост и задние лапы уходят ВНИЗ.""" self.assertGreaterEqual(self.cfg.height - self.cfg.pivot_y, 8) def test_camera_matches_the_agent_and_the_game_tile_projection(self): import math agent = yaml.safe_load((ROOT / "assets/agents/catalog.yaml").read_text(encoding="utf-8")) self.assertAlmostEqual(self.cfg.camera["elevation_deg"], agent["camera"]["elevation_deg"], places=3) self.assertAlmostEqual(self.cfg.camera["elevation_deg"], math.degrees(math.atan(16 / 32)), places=2) def test_every_kind_declares_all_four_clips(self): self.assertEqual(set(self.cfg.animations), {"walk", "attack", "hit", "death"}) self.assertEqual(list(self.cfg.kinds), ["shambler", "rusher", "spitter", "brute", "lurker"]) def test_fps_follows_the_creature_tempo(self): # Бегун частит, громила волочится: одинаковое число кадров, разный fps. self.assertGreater(self.cfg.fps("rusher", "walk"), self.cfg.fps("brute", "walk")) self.assertEqual(self.cfg.fps("shambler", "walk"), 8) class TestPacking(Sandbox): def test_sheet_is_direction_major(self): cfg = self.cfg frames = [self.render_frame([(cfg.crop_x, cfg.crop_y, 1, 1)]) for _ in range(cfg.cells("walk"))] sheet = ma.pack_render_frames(cfg, "walk", frames, 1) self.assertEqual(cfg.cells("walk"), 64) self.assertEqual(sheet.size, (cfg.width * 64, cfg.height)) def test_origin_lands_exactly_on_the_pivot(self): cfg = self.cfg centre = (cfg.render_width // 2, cfg.render_height // 2, 1, 1) sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([centre])] * cfg.cells("hit"), 1) for index in range(cfg.cells("hit")): box = ma.bbox_of(ma.cell_at(cfg, sheet, index)) self.assertEqual((box[0], box[1]), (cfg.pivot_x, cfg.pivot_y), f"cell {index} origin moved off the pivot") def test_every_cell_uses_the_same_crop_regardless_of_content(self): """Общий pivot: мелкая тварь не растягивается на клетку, крупная не жмётся.""" cfg = self.cfg small = (cfg.crop_x + 20, cfg.crop_y + 30, 6, 5) large = (cfg.crop_x + 4, cfg.crop_y + 6, 40, 46) cells = cfg.cells("hit") small_sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([small])] * cells, 1) large_sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([large])] * cells, 1) self.assertEqual(ma.bbox_of(ma.cell_at(cfg, small_sheet, 0)), (20, 30, 26, 35)) self.assertEqual(ma.bbox_of(ma.cell_at(cfg, large_sheet, 0)), (4, 6, 44, 52)) def test_supersampled_crop_is_an_exact_integer_reduction(self): cfg = self.cfg mark = (cfg.crop_x + 10, cfg.crop_y + 12, 8, 8) sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([mark], 4)] * cfg.cells("hit"), 4) self.assertEqual(ma.bbox_of(ma.cell_at(cfg, sheet, 0)), (10, 12, 18, 20)) def test_wrong_render_resolution_is_refused(self): cfg = self.cfg frames = [self.render_frame([(0, 0, 2, 2)])] * cfg.cells("hit") with self.assertRaises(ValueError): ma.pack_render_frames(cfg, "hit", frames, 2) def test_wrong_cell_count_is_refused(self): with self.assertRaises(ValueError): ma.pack_render_frames(self.cfg, "hit", [self.render_frame([(0, 0, 2, 2)])] * 7, 1) # --------------------------------------------------------------------------- # Манифест и заголовок # --------------------------------------------------------------------------- class TestSync(Sandbox): def put_sheet(self, kind, animation): path = self.ws.source_path(kind, animation) path.parent.mkdir(parents=True, exist_ok=True) Image.new("RGBA", self.cfg.sheet_size(animation), (0, 0, 0, 0)).save(path) return path def manifest(self): return yaml.safe_load(self.ws.manifest.read_text(encoding="utf-8")) def test_asset_id_is_kind_underscore_animation(self): self.put_sheet("shambler", "walk") ma.sync(self.ws, self.cfg) raw = self.manifest() self.assertIn("shambler_walk", raw) self.assertNotIn("monster_shambler_walk", raw) self.assertNotIn("enemy_shambler_walk", raw) def test_rendered_png_becomes_an_import_asset(self): self.put_sheet("brute", "attack") ma.sync(self.ws, self.cfg) spec = self.manifest()["brute_attack"] self.assertEqual(spec["backend"], "import") self.assertEqual(spec["source"], "../sources/monsters/brute_attack.png") self.assertEqual(spec["params"], {"frame_width": 96, "frame_height": 112, "frames": 48}) self.assertEqual(self.manifest()["defaults"], {"dirs": 8, "pivot": "48,88"}) def test_frames_count_every_direction_and_frame(self): self.put_sheet("lurker", "death") ma.sync(self.ws, self.cfg) self.assertEqual(self.manifest()["lurker_death"]["params"]["frames"], 64) def test_fps_is_scaled_per_kind(self): self.put_sheet("rusher", "walk") self.put_sheet("brute", "walk") ma.sync(self.ws, self.cfg) raw = self.manifest() self.assertEqual(raw["rusher_walk"]["fps"], self.cfg.fps("rusher", "walk")) self.assertGreater(raw["rusher_walk"]["fps"], raw["brute_walk"]["fps"]) def test_hand_written_spec_survives_sync(self): self.put_sheet("spitter", "hit") ma.sync(self.ws, self.cfg) text = self.ws.manifest.read_text(encoding="utf-8") self.ws.manifest.write_text(text.replace( "spitter_hit:\n backend: import\n" " source: ../sources/monsters/spitter_hit.png\n", "spitter_hit:\n backend: blender\n" " source: ../monsters/monster_rig.blend\n scene: SPITTER\n"), encoding="utf-8", newline="\n") ma.sync(self.ws, self.cfg) spec = self.manifest()["spitter_hit"] self.assertEqual(spec["backend"], "blender") self.assertEqual(spec["scene"], "SPITTER") def test_import_specs_are_regenerated_not_preserved(self): self.put_sheet("shambler", "walk") ma.sync(self.ws, self.cfg) self.ws.manifest.write_text( self.ws.manifest.read_text(encoding="utf-8").replace("fps: 8", "fps: 999"), encoding="utf-8", newline="\n") ma.sync(self.ws, self.cfg) self.assertEqual(self.manifest()["shambler_walk"]["fps"], 8) def test_removed_png_drops_out_of_the_manifest(self): path = self.put_sheet("shambler", "walk") ma.sync(self.ws, self.cfg) path.unlink() ma.sync(self.ws, self.cfg) self.assertNotIn("shambler_walk", self.manifest()) def test_header_hashes_match_the_manifest_names(self): self.put_sheet("shambler", "walk") ma.sync(self.ws, self.cfg) header = self.ws.header.read_text(encoding="utf-8") for kind in self.cfg.kinds: for animation in self.cfg.animations: aid = ma.asset_id(kind, animation) self.assertIn(f"MONSTER_ASSET_{ma.symbol(aid)} = " f"UINT32_C(0x{ma.fnv1a(aid):08X});", header) def test_header_maps_engine_enums_onto_the_clips(self): self.put_sheet("shambler", "walk") ma.sync(self.ws, self.cfg) header = self.ws.header.read_text(encoding="utf-8") # AnimKind у движка: HURT/DIE, а клипы каталога — hit/death. self.assertIn("case AnimKind::HURT: return MONSTER_ASSET_SHAMBLER_HIT;", header) self.assertIn("case AnimKind::DIE: return MONSTER_ASSET_SHAMBLER_DEATH;", header) self.assertIn("case EnemyKind::LURKER:", header) self.assertNotIn("EnemyKind::DUMMY", header) def test_hash_uses_the_same_fnv1a_constants_as_the_engine(self): """Разойдись хоть один бит — ассет молча не найдётся, без ошибки в логе.""" source = (ROOT / "src/render/anim.h").read_text(encoding="utf-8") self.assertIn("0x811C9DC5u", source) self.assertIn("0x01000193u", source) self.assertEqual(ma.fnv1a(""), 0x811C9DC5) self.assertEqual(ma.fnv1a("a"), ((0x811C9DC5 ^ 0x61) * 0x01000193) & 0xFFFFFFFF) def test_collision_with_the_legacy_manifest_is_reported(self): self.put_sheet("shambler", "walk") legacy = self.ws.manifest_dir / "enemies.yaml" legacy.parent.mkdir(parents=True, exist_ok=True) legacy.write_text("version: 1\nshambler_walk: {backend: import, source: x.png}\n", encoding="utf-8") ma.sync(self.ws, self.cfg) report = ma.Report([], []) ma.check_manifest(self.ws, self.cfg, report) self.assertTrue(any("enemies.yaml" in w for w in report.warnings)) # --------------------------------------------------------------------------- # Покрытие: имена конвейера обязаны совпасть с тем, что требует гейт # --------------------------------------------------------------------------- class TestCoverage(unittest.TestCase): def setUp(self) -> None: self.spec = yaml.safe_load( (ROOT / "assets/content_coverage.yaml").read_text(encoding="utf-8")) self.cfg = ma.load_config(ma.Workspace(ROOT)) def test_pipeline_ids_are_exactly_what_the_gate_asks_for(self): wanted = set(coverage.requirements(self.spec)["enemy"]) produced = {ma.asset_id(kind, animation) for kind in self.cfg.kinds for animation in self.cfg.animations} self.assertEqual(produced, wanted) def test_catalog_kinds_match_the_coverage_spec(self): self.assertEqual(list(self.cfg.kinds), list(self.spec["enemies"]["kinds"])) self.assertEqual(set(self.cfg.animations), set(self.spec["enemies"]["animations"])) def test_the_gate_reads_the_manifest_the_pipeline_writes(self): """Гейт собирает ID из assets/manifests/*.yaml — sync пишет ровно туда.""" ws = ma.Workspace(ROOT) self.assertEqual(ws.manifest.parent, coverage.MANIFESTS) self.assertTrue(ws.manifest.name.endswith(".yaml")) self.assertEqual(len(coverage.requirements(self.spec)["enemy"]), len(self.cfg.kinds) * len(self.cfg.animations)) # --------------------------------------------------------------------------- # validate # --------------------------------------------------------------------------- class TestValidate(Sandbox): def test_clean_sheet_passes(self): self.save("shambler", "walk", self.creature("walk")) self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 0) def test_canvas_mismatch_is_an_error(self): path = self.save("shambler", "walk", self.creature("walk")) Image.open(path).resize((100, 100)).save(path) self.assertEqual(ma.validate(self.ws, self.cfg), 1) def test_empty_cell_is_an_error(self): cfg = self.cfg sheet = self.creature("hit") ma.paste_cell(cfg, sheet, 5, Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0))) self.save("rusher", "hit", sheet) self.assertEqual(ma.validate(self.ws, self.cfg), 1) def test_creature_floating_above_the_ground_line_is_an_error(self): cfg = self.cfg cells = [[(cfg.pivot_x - 8, 4, 16, 20)] for _ in range(cfg.cells("walk"))] self.save("lurker", "walk", self.sheet_from_cells("walk", cells)) self.assertEqual(ma.validate(self.ws, self.cfg), 1) def test_origin_off_the_turn_axis_is_an_error(self): cfg = self.cfg cells = [[(cfg.pivot_x + 12, cfg.pivot_y - 30, 14, 30)] for _ in range(cfg.cells("walk"))] self.save("lurker", "walk", self.sheet_from_cells("walk", cells)) self.assertEqual(ma.validate(self.ws, self.cfg), 1) def test_silhouette_clipped_by_the_cell_edge_warns(self): """Замах, не влезший в клетку, в игре выглядит как отрубленная лапа.""" cfg = self.cfg sheet = self.creature("attack") layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0)) layer.paste(Image.new("RGBA", (cfg.width, 30), (150, 40, 40, 255)), (0, cfg.pivot_y - 30)) ma.paste_cell(cfg, sheet, 2, layer) self.save("brute", "attack", sheet) report = ma.Report([], []) ma.check_framing(cfg, "brute_attack.png", sheet, "attack", report) self.assertTrue(any("clipped" in w for w in report.warnings)) self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 1) def test_death_is_allowed_to_leave_the_ground_line(self): cfg = self.cfg # Осевшая туша: низкая, широкая, не достаёт до линии пола — это норма. cells = [[(cfg.pivot_x - 14, cfg.pivot_y - 12, 28, 8)] for _ in range(cfg.cells("death"))] self.save("brute", "death", self.sheet_from_cells("death", cells)) report = ma.Report([], []) masks = ma.union_masks(cfg, self.sheet_from_cells("death", cells), "death") ma.check_ground(cfg, "brute_death.png", masks, "death", report) self.assertFalse(report.errors) def test_shuffled_direction_order_warns(self): cfg = self.cfg frames = cfg.frames("walk") good = self.creature("walk") cells = [ma.cell_at(cfg, good, i) for i in range(cfg.cells("walk"))] # Меняем местами юго-запад и запад целиком. a, b = slice(frames, 2 * frames), slice(2 * frames, 3 * frames) cells[a], cells[b] = cells[b], cells[a] sheet = Image.new("RGBA", cfg.sheet_size("walk"), (0, 0, 0, 0)) for index, cell in enumerate(cells): ma.paste_cell(cfg, sheet, index, cell) report = ma.Report([], []) ma.check_direction_order(cfg, "shambler_walk.png", ma.union_masks(cfg, sheet, "walk"), report) self.assertTrue(report.warnings) def test_one_direction_repeated_eight_times_warns(self): cfg = self.cfg cells = [[(cfg.pivot_x - 10, cfg.pivot_y - 30, 20, 30)] for _ in range(cfg.cells("walk"))] sheet = self.sheet_from_cells("walk", cells) report = ma.Report([], []) ma.check_direction_order(cfg, "shambler_walk.png", ma.union_masks(cfg, sheet, "walk"), report) self.assertTrue(any("same silhouette" in w for w in report.warnings)) def test_two_kinds_with_the_same_silhouette_warn(self): """Пять видов обязаны различаться пятном, иначе это пять капсул.""" self.save("shambler", "walk", self.creature("walk")) self.save("brute", "walk", self.creature("walk")) self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 1) def test_different_morphologies_pass(self): # Узкий и высокий, широкий и высокий, широкий и плоский — ровно то, чем # бредущий, громила и затаившийся обязаны различаться в игре. self.save("shambler", "walk", self.creature("walk", width=10, height=40)) self.save("brute", "walk", self.creature("walk", width=34, height=44)) self.save("lurker", "walk", self.creature("walk", width=32, height=10)) self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 0) def test_complete_flag_reports_missing_sheets(self): self.save("shambler", "walk", self.creature("walk")) self.assertEqual(ma.validate(self.ws, self.cfg, complete=True), 1) def test_manifest_pivot_drift_is_an_error(self): self.save("shambler", "walk", self.creature("walk")) ma.sync(self.ws, self.cfg) self.ws.manifest.write_text( self.ws.manifest.read_text(encoding="utf-8").replace('"48,88"', '"48,80"'), encoding="utf-8", newline="\n") self.assertEqual(ma.validate(self.ws, self.cfg), 1) if __name__ == "__main__": unittest.main()