squad-proto/tools/tests/test_agent_assets.py

435 lines
21 KiB
Python
Raw Permalink Normal View History

"""Тесты конвейера бумажной куклы: упаковка, выравнивание слоёв, манифест.
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()