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