Всё, из чего собираются спрайты, и ничего собранного. Источник истины —
assets/manifests и assets/*/catalog.yaml; PNG, .sfa и contact sheet остаются
за бортом, потому что воспроизводятся командой (docs/12-assets.md).
assets/manifests что и как рендерить: окружение, твари, агенты, предметы
assets/agents паперdoll-риг восьми направлений, каталог снаряжения
assets/monsters пять морфологий, каждая своим ригом (docs/14-monsters.md)
assets/sources исходные развёртки и импорты
assets/art_masters мастер-развёртки, по которым сверяется стиль
tools/ сборка ассетов, покрытие, генераторы, blender-скрипты
generated/ заголовки с константами SF_ASSET_*: без них игра не
соберётся, поэтому они в репозитории, хотя и производные
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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()
|