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