"""Тесты конвейера бумажной куклы: упаковка, выравнивание слоёв, манифест. Blender здесь не запускается. Проверяется ровно то, что ломается молча: кадры, разложенные не в том порядке; экипировка, подогнанная по собственному bbox; sync, затирающий ручную blender-спеку. 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 ROOT = Path(__file__).resolve().parents[2] _spec = importlib.util.spec_from_file_location("agent_assets", ROOT / "tools/agent_assets.py") aa = importlib.util.module_from_spec(_spec) sys.modules["agent_assets"] = aa _spec.loader.exec_module(aa) # --------------------------------------------------------------------------- # Каркас: временный воркспейс с настоящим каталогом # --------------------------------------------------------------------------- class Sandbox(unittest.TestCase): def setUp(self) -> None: self.dir = Path(tempfile.mkdtemp(prefix="agent_assets_")) self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) self.ws = aa.Workspace(self.dir) self.ws.catalog.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(ROOT / "assets/agents/catalog.yaml", self.ws.catalog) self.cfg = aa.load_config(self.ws) # -- синтетические кадры Blender ------------------------------------- def render_frame(self, marks, supersample=1): """Кадр рендера: непрозрачные квадраты по координатам кадра рендера.""" 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), (200, 60, 40, 255)) image.paste(block, (x * supersample, y * supersample)) return image # -- синтетические доски 4x2 ----------------------------------------- def board(self, cell_size, shapes, key=(255, 0, 255)): """Доска 4 колонки x 2 строки; shapes[i] — список (x, y, w, h) внутри ячейки.""" cw, ch = cell_size board = Image.new("RGB", (cw * 4, ch * 2), key) for index, boxes in enumerate(shapes): ox, oy = (index % 4) * cw, (index // 4) * ch for x, y, w, h in boxes: board.paste(Image.new("RGB", (w, h), (30, 40, 60)), (ox + x, oy + y)) return board def write_board(self, name, image): path = self.dir / name path.parent.mkdir(parents=True, exist_ok=True) image.save(path) return path def humanoid(self, cell_size, lean=0): """Восемь ячеек «тела»: одинаковый прямоугольник, слегка гуляющий по X.""" cw, ch = cell_size w, h = cw // 6, int(ch * 0.6) top = int(ch * 0.2) return [[(cw // 2 - w // 2 + lean * (i - 4), top, w, h)] for i in range(8)] # --------------------------------------------------------------------------- # Упаковка кадров Blender # --------------------------------------------------------------------------- class TestPacking(Sandbox): def test_sheet_is_direction_major_with_catalog_geometry(self): cfg = self.cfg frames = [self.render_frame([(cfg.crop_x, cfg.crop_y, 1, 1)]) for _ in range(cfg.cells("walk"))] sheet = aa.pack_render_frames(cfg, "walk", frames, 1) self.assertEqual(sheet.size, (cfg.width * 8 * 8, cfg.height)) # колонка = направление * кадров + кадр self.assertEqual(cfg.cells("walk"), 64) def test_origin_lands_exactly_on_the_pivot(self): cfg = self.cfg # Пиксель в центре кадра рендера — это начало координат рига. centre = (cfg.render_width // 2, cfg.render_height // 2, 1, 1) sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([centre])] * 8, 1) for direction in range(8): box = aa.bbox_of(aa.cell_at(cfg, sheet, direction)) self.assertEqual((box[0], box[1]), (cfg.pivot_x, cfg.pivot_y), f"direction {direction} origin moved off the pivot") def test_every_cell_uses_the_same_crop_regardless_of_content(self): """Гарантия бумажной куклы: маленькое оружие не растягивается на клетку.""" cfg = self.cfg gear = (cfg.crop_x + 40, cfg.crop_y + 20, 6, 4) body = (cfg.crop_x + 10, cfg.crop_y + 5, 50, 80) gear_sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([gear])] * 8, 1) body_sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([body])] * 8, 1) self.assertEqual(aa.bbox_of(aa.cell_at(cfg, gear_sheet, 0)), (40, 20, 46, 24)) self.assertEqual(aa.bbox_of(aa.cell_at(cfg, body_sheet, 0)), (10, 5, 60, 85)) def test_supersampled_crop_is_an_exact_integer_reduction(self): cfg = self.cfg mark = (cfg.crop_x + 12, cfg.crop_y + 30, 8, 8) sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([mark], 4)] * 8, 4) self.assertEqual(aa.bbox_of(aa.cell_at(cfg, sheet, 0)), (12, 30, 20, 38)) def test_wrong_render_resolution_is_refused(self): cfg = self.cfg frames = [self.render_frame([(0, 0, 2, 2)])] * 8 with self.assertRaises(ValueError): aa.pack_render_frames(cfg, "idle", frames, 2) def test_wrong_cell_count_is_refused(self): with self.assertRaises(ValueError): aa.pack_render_frames(self.cfg, "idle", [self.render_frame([(0, 0, 2, 2)])] * 7, 1) # --------------------------------------------------------------------------- # Растровый ingest # --------------------------------------------------------------------------- class TestIngest(Sandbox): CELL = (200, 240) def ingest_body(self, lean=0): board = self.board(self.CELL, self.humanoid(self.CELL, lean)) path = self.write_board("body.png", board) return aa.ingest(self.ws, self.cfg, "body_sleek", "idle", [path]) def test_body_ingest_writes_calibration_and_sits_on_the_pivot(self): self.ingest_body() cal = aa.load_calibration(self.ws) self.assertIsNotNone(cal) self.assertEqual(cal.source, "body_sleek/idle") sheet = Image.open(self.ws.source_path("body_sleek", "idle")) box = aa.bbox_of(aa.cell_at(self.cfg, sheet, 0)) self.assertAlmostEqual(box[3], self.cfg.pivot_y, delta=1) self.assertAlmostEqual((box[0] + box[2]) / 2, self.cfg.pivot_x, delta=1) def test_equipment_keeps_its_position_relative_to_the_full_cell(self): """Кинжал в руке обязан остаться в руке, а не съехать к стопам.""" self.ingest_body() cw, ch = self.CELL # Маленький блок высоко в ячейке — «оружие у пояса». gear_x, gear_y, gear_w, gear_h = cw // 2 + 14, int(ch * 0.42), 10, 12 gear = self.board(self.CELL, [[(gear_x, gear_y, gear_w, gear_h)] for _ in range(8)]) path = self.write_board("gear.png", gear) aa.ingest(self.ws, self.cfg, "weapon_dagger", "idle", [path]) cal = aa.load_calibration(self.ws) scale, ox, oy = aa.cell_transform(self.cfg, cal, self.CELL) sheet = Image.open(self.ws.source_path("weapon_dagger", "idle")) box = aa.bbox_of(aa.cell_at(self.cfg, sheet, 0)) self.assertAlmostEqual(box[0], ox + gear_x * scale, delta=1.5) self.assertAlmostEqual(box[1], oy + gear_y * scale, delta=1.5) self.assertAlmostEqual(box[2] - box[0], gear_w * scale, delta=1.5) self.assertAlmostEqual(box[3] - box[1], gear_h * scale, delta=1.5) # И главное: НЕ прижат к pivot_y и НЕ растянут на клетку. self.assertLess(box[3], self.cfg.pivot_y - 10) self.assertLess(box[3] - box[1], (self.cfg.height - aa.FIT_MARGIN_Y) // 2) def test_equipment_before_body_is_refused(self): gear = self.board(self.CELL, [[(10, 10, 8, 8)] for _ in range(8)]) path = self.write_board("gear.png", gear) with self.assertRaises(ValueError): aa.ingest(self.ws, self.cfg, "weapon_dagger", "idle", [path]) def test_calibrate_flag_is_body_only(self): self.ingest_body() gear = self.board(self.CELL, [[(10, 10, 8, 8)] for _ in range(8)]) path = self.write_board("gear.png", gear) with self.assertRaises(ValueError): aa.ingest(self.ws, self.cfg, "armor_vest", "idle", [path], calibrate=True) def test_one_scale_for_every_direction(self): """Направление с более коротким силуэтом обязано остаться короче.""" cw, ch = self.CELL cells = self.humanoid(self.CELL) cells[4] = [(cw // 2 - 8, int(ch * 0.2), 16, int(ch * 0.45))] # север ниже прочих path = self.write_board("body.png", self.board(self.CELL, cells)) aa.ingest(self.ws, self.cfg, "body_sleek", "idle", [path]) sheet = Image.open(self.ws.source_path("body_sleek", "idle")) south = aa.bbox_of(aa.cell_at(self.cfg, sheet, 0)) north = aa.bbox_of(aa.cell_at(self.cfg, sheet, 4)) ratio = (north[3] - north[1]) / (south[3] - south[1]) self.assertAlmostEqual(ratio, 0.45 / 0.6, delta=0.05) def test_boards_of_different_resolutions_share_one_transform(self): """Калибровка нормирована на ячейку, значит разрешение доски не важно.""" self.ingest_body() cal = aa.load_calibration(self.ws) small = aa.cell_transform(self.cfg, cal, (100, 120)) large = aa.cell_transform(self.cfg, cal, (400, 480)) self.assertAlmostEqual(small[0] * 100, large[0] * 400, places=6) self.assertAlmostEqual(small[1], large[1], places=6) self.assertAlmostEqual(small[2], large[2], places=6) def test_frame_count_must_match_the_catalog(self): path = self.write_board("body.png", self.board(self.CELL, self.humanoid(self.CELL))) with self.assertRaises(ValueError): aa.ingest(self.ws, self.cfg, "body_sleek", "walk", [path]) def test_empty_cell_is_refused(self): cells = self.humanoid(self.CELL) cells[3] = [] path = self.write_board("body.png", self.board(self.CELL, cells)) with self.assertRaises(ValueError): aa.ingest(self.ws, self.cfg, "body_sleek", "idle", [path]) # --------------------------------------------------------------------------- # Манифест # --------------------------------------------------------------------------- class TestSync(Sandbox): def put_sheet(self, component, animation): path = self.ws.source_path(component, 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): import yaml return yaml.safe_load(self.ws.manifest.read_text(encoding="utf-8")) def test_rendered_png_becomes_an_import_asset(self): self.put_sheet("body_sleek", "idle") aa.sync(self.ws, self.cfg) raw = self.manifest() spec = raw["agent_body_sleek_idle"] self.assertEqual(spec["backend"], "import") self.assertEqual(spec["source"], "../sources/agents/sleek/body_sleek_idle.png") self.assertEqual(spec["params"], {"frame_width": 70, "frame_height": 90, "frames": 8}) self.assertEqual(raw["defaults"], {"dirs": 8, "pivot": "35,87"}) self.assertNotIn("agent_armor_vest_idle", raw) def test_walk_asset_counts_every_direction_and_frame(self): self.put_sheet("body_sleek", "walk") aa.sync(self.ws, self.cfg) self.assertEqual(self.manifest()["agent_body_sleek_walk"]["params"]["frames"], 64) def test_hand_written_blender_spec_survives_sync(self): self.put_sheet("weapon_rifle", "idle") aa.sync(self.ws, self.cfg) text = self.ws.manifest.read_text(encoding="utf-8") self.ws.manifest.write_text(text.replace( "agent_weapon_rifle_idle:\n backend: import\n" " source: ../sources/agents/sleek/weapon_rifle_idle.png\n", "agent_weapon_rifle_idle:\n backend: blender\n" " source: ../sources/agents/sleek/rig.blend\n scene: WEAPON\n"), encoding="utf-8", newline="\n") aa.sync(self.ws, self.cfg) spec = self.manifest()["agent_weapon_rifle_idle"] self.assertEqual(spec["backend"], "blender") self.assertEqual(spec["scene"], "WEAPON") def test_import_specs_are_regenerated_not_preserved(self): self.put_sheet("body_sleek", "idle") aa.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") aa.sync(self.ws, self.cfg) self.assertEqual(self.manifest()["agent_body_sleek_idle"]["fps"], 8) def test_removed_png_drops_out_of_the_manifest(self): path = self.put_sheet("body_sleek", "idle") aa.sync(self.ws, self.cfg) path.unlink() aa.sync(self.ws, self.cfg) self.assertNotIn("agent_body_sleek_idle", self.manifest()) def test_header_ids_match_the_manifest_names(self): self.put_sheet("body_sleek", "idle") aa.sync(self.ws, self.cfg) header = self.ws.header.read_text(encoding="utf-8") self.assertIn(f"UINT32_C(0x{aa.fnv1a('agent_body_sleek_idle'):08X})", header) self.assertIn(f"UINT32_C(0x{aa.fnv1a('agent_weapon_rifle_idle'):08X})", header) # --------------------------------------------------------------------------- # validate # --------------------------------------------------------------------------- class TestValidate(Sandbox): 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), (180, 60, 40, 255)), (x, y)) aa.paste_cell(cfg, sheet, index, layer) return sheet def save(self, component, animation, sheet): path = self.ws.source_path(component, animation) path.parent.mkdir(parents=True, exist_ok=True) sheet.save(path) return path def good_body(self): """Восемь направлений: зеркальные пары совпадают, юг и север различны.""" cfg = self.cfg wide, narrow = 22, 12 # (ширина, сдвиг от pivot) по каталожному порядку shape = [(wide, 0), (18, -4), (narrow, -7), (16, -4), (wide - 2, 0), (16, 4), (narrow, 7), (18, 4)] cells = [] for width, dx in shape: x = cfg.pivot_x - width // 2 + dx cells.append([(x, 20, width, cfg.pivot_y - 20)]) return self.sheet_from_cells("idle", cells) def test_clean_body_passes(self): self.save("body_sleek", "idle", self.good_body()) self.assertEqual(aa.validate(self.ws, self.cfg, strict=True), 0) def test_canvas_mismatch_is_an_error(self): path = self.save("body_sleek", "idle", self.good_body()) Image.open(path).resize((100, 100)).save(path) self.assertEqual(aa.validate(self.ws, self.cfg), 1) def test_body_off_the_pivot_is_an_error(self): cfg = self.cfg cells = [[(cfg.pivot_x - 10, 5, 20, 40)] for _ in range(8)] self.save("body_sleek", "idle", self.sheet_from_cells("idle", cells)) self.assertEqual(aa.validate(self.ws, self.cfg), 1) def test_equipment_fitted_on_its_own_is_an_error(self): self.save("body_sleek", "idle", self.good_body()) cfg = self.cfg # Классическая поломка: кинжал растянули на всю клетку и посадили на стопы. stretched = [[(2, 3, cfg.width - 4, cfg.pivot_y - 3)] for _ in range(8)] self.save("weapon_dagger", "idle", self.sheet_from_cells("idle", stretched)) self.assertEqual(aa.validate(self.ws, self.cfg), 1) def test_equipment_on_the_body_passes(self): self.save("body_sleek", "idle", self.good_body()) cfg = self.cfg gear = [[(cfg.pivot_x - 6, 40, 12, 14)] for _ in range(8)] self.save("weapon_dagger", "idle", self.sheet_from_cells("idle", gear)) self.assertEqual(aa.validate(self.ws, self.cfg), 0) def test_shuffled_direction_order_warns(self): cells = [aa.cell_at(self.cfg, self.good_body(), i) for i in range(8)] swapped = cells[:1] + [cells[2], cells[1]] + cells[3:] # ЮЗ и З местами sheet = Image.new("RGBA", self.cfg.sheet_size("idle"), (0, 0, 0, 0)) for index, cell in enumerate(swapped): aa.paste_cell(self.cfg, sheet, index, cell) self.save("body_sleek", "idle", sheet) report = aa.Report([], []) aa.check_direction_order(self.cfg, "body", sheet, "idle", report) self.assertTrue(report.warnings) self.assertEqual(aa.validate(self.ws, self.cfg, strict=True), 1) def test_one_direction_repeated_eight_times_warns(self): cfg = self.cfg cells = [[(cfg.pivot_x - 11, 20, 22, cfg.pivot_y - 20)] for _ in range(8)] sheet = self.sheet_from_cells("idle", cells) report = aa.Report([], []) aa.check_direction_order(cfg, "body", sheet, "idle", report) self.assertTrue(any("same silhouette" in w for w in report.warnings)) def test_walk_mirror_check_uses_the_whole_cycle(self): """Зеркальным парам разрешено расходиться внутри шага, но не в сумме.""" cfg = self.cfg frames = cfg.frames("walk") cells = [] for direction in range(8): for frame in range(frames): lead = 6 if (direction in (1, 2, 3) and frame < frames // 2) else -6 if direction in (5, 6, 7): lead = -lead x = cfg.pivot_x - 9 + (lead if direction not in (0, 4) else 0) cells.append([(x, 20, 18, cfg.pivot_y - 20)]) self.save("body_sleek", "walk", self.sheet_from_cells("walk", cells)) self.assertEqual(aa.validate(self.ws, self.cfg, strict=True), 0) def test_complete_flag_reports_missing_layers(self): self.save("body_sleek", "idle", self.good_body()) self.assertEqual(aa.validate(self.ws, self.cfg, complete=True), 1) def test_manifest_pivot_drift_is_an_error(self): self.save("body_sleek", "idle", self.good_body()) aa.sync(self.ws, self.cfg) self.ws.manifest.write_text( self.ws.manifest.read_text(encoding="utf-8").replace('"35,87"', '"35,80"'), encoding="utf-8", newline="\n") self.assertEqual(aa.validate(self.ws, self.cfg), 1) # --------------------------------------------------------------------------- # Каталог и брифы # --------------------------------------------------------------------------- class TestCatalog(Sandbox): def test_render_frame_is_symmetric_about_the_pivot(self): cfg = self.cfg self.assertEqual(cfg.render_width, 70) self.assertEqual(cfg.render_height, 174) self.assertEqual((cfg.crop_x, cfg.crop_y), (0, 0)) self.assertEqual(cfg.render_height // 2 - cfg.crop_y, cfg.pivot_y) def test_camera_matches_the_game_tile_projection(self): import math self.assertAlmostEqual(self.cfg.camera["elevation_deg"], math.degrees(math.atan(16 / 32)), places=2) def test_prompt_names_the_catalog_geometry(self): text = aa.prompt_for(self.cfg, "armor_vest", "walk") self.assertIn("70x90", text) self.assertIn("(35,87)", text) self.assertIn("8 frame(s)", text) self.assertIn("south, southwest, west", text) def test_prompt_rejects_unknown_names(self): with self.assertRaises(ValueError): aa.prompt_for(self.cfg, "armor_nope", "idle") with self.assertRaises(ValueError): aa.prompt_for(self.cfg, "armor_vest", "dance") if __name__ == "__main__": unittest.main()