Shade (merchant-shade.itch.io) entity animation tests
This commit is contained in:
parent
2681cbd957
commit
6fdf7279ce
10 changed files with 1813 additions and 3 deletions
190
tests/unit/shade_sprite_test.py
Normal file
190
tests/unit/shade_sprite_test.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""Unit tests for shade_sprite module."""
|
||||
import mcrfpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path so shade_sprite can be imported
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(os.path.dirname(script_dir))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from shade_sprite import (
|
||||
AnimatedSprite, CharacterAssembler, Direction,
|
||||
PUNY_29, PUNY_24, CREATURE_RPGMAKER, SLIME,
|
||||
ALL_FORMATS, detect_format, AnimFrame, AnimDef,
|
||||
)
|
||||
|
||||
errors = []
|
||||
|
||||
def test(name, condition, msg=""):
|
||||
if not condition:
|
||||
errors.append(f"FAIL: {name} - {msg}")
|
||||
print(f" FAIL: {name} {msg}")
|
||||
else:
|
||||
print(f" PASS: {name}")
|
||||
|
||||
|
||||
# ---- Format definitions ----
|
||||
print("=== Format Definitions ===")
|
||||
|
||||
test("PUNY_29 dimensions", PUNY_29.tile_w == 32 and PUNY_29.tile_h == 32)
|
||||
test("PUNY_29 columns", PUNY_29.columns == 29)
|
||||
test("PUNY_29 rows", PUNY_29.rows == 8)
|
||||
test("PUNY_29 directions", PUNY_29.directions == 8)
|
||||
test("PUNY_29 has 10 animations", len(PUNY_29.animations) == 10,
|
||||
f"got {len(PUNY_29.animations)}: {list(PUNY_29.animations.keys())}")
|
||||
test("PUNY_29 idle is looping", PUNY_29.animations["idle"].loop)
|
||||
test("PUNY_29 death no chain", PUNY_29.animations["death"].chain_to is None)
|
||||
test("PUNY_29 slash chains to idle", PUNY_29.animations["slash"].chain_to == "idle")
|
||||
|
||||
test("PUNY_24 dimensions", PUNY_24.tile_w == 32 and PUNY_24.tile_h == 32)
|
||||
test("PUNY_24 columns", PUNY_24.columns == 24)
|
||||
test("PUNY_24 has 8 animations", len(PUNY_24.animations) == 8,
|
||||
f"got {len(PUNY_24.animations)}: {list(PUNY_24.animations.keys())}")
|
||||
|
||||
test("CREATURE_RPGMAKER tile", CREATURE_RPGMAKER.tile_w == 24 and CREATURE_RPGMAKER.tile_h == 24)
|
||||
test("CREATURE_RPGMAKER 4-dir", CREATURE_RPGMAKER.directions == 4)
|
||||
|
||||
test("SLIME columns", SLIME.columns == 15)
|
||||
test("SLIME 1-dir", SLIME.directions == 1)
|
||||
|
||||
test("ALL_FORMATS count", len(ALL_FORMATS) == 4)
|
||||
|
||||
|
||||
# ---- Format detection ----
|
||||
print("\n=== Format Detection ===")
|
||||
|
||||
test("detect 928x256 -> PUNY_29", detect_format(928, 256) is PUNY_29)
|
||||
test("detect 768x256 -> PUNY_24", detect_format(768, 256) is PUNY_24)
|
||||
test("detect 480x32 -> SLIME", detect_format(480, 32) is SLIME)
|
||||
test("detect 72x96 -> CREATURE_RPGMAKER", detect_format(72, 96) is CREATURE_RPGMAKER)
|
||||
test("detect unknown -> None", detect_format(100, 100) is None)
|
||||
|
||||
|
||||
# ---- Direction ----
|
||||
print("\n=== Direction ===")
|
||||
|
||||
test("Direction.S == 0", Direction.S == 0)
|
||||
test("Direction.N == 4", Direction.N == 4)
|
||||
test("Direction.E == 6", Direction.E == 6)
|
||||
|
||||
# 8-dir row mapping
|
||||
test("8-dir S -> row 0", PUNY_29.direction_row(Direction.S) == 0)
|
||||
test("8-dir N -> row 4", PUNY_29.direction_row(Direction.N) == 4)
|
||||
test("8-dir E -> row 6", PUNY_29.direction_row(Direction.E) == 6)
|
||||
|
||||
# 4-dir mapping
|
||||
test("4-dir S -> row 0", CREATURE_RPGMAKER.direction_row(Direction.S) == 0)
|
||||
test("4-dir W -> row 1", CREATURE_RPGMAKER.direction_row(Direction.W) == 1)
|
||||
test("4-dir E -> row 2", CREATURE_RPGMAKER.direction_row(Direction.E) == 2)
|
||||
test("4-dir N -> row 3", CREATURE_RPGMAKER.direction_row(Direction.N) == 3)
|
||||
test("4-dir SW -> row 0 (rounds to S)", CREATURE_RPGMAKER.direction_row(Direction.SW) == 0)
|
||||
|
||||
|
||||
# ---- Sprite index calculation ----
|
||||
print("\n=== Sprite Index ===")
|
||||
|
||||
# PUNY_29: row * 29 + col
|
||||
test("PUNY_29 col=0 S -> 0", PUNY_29.sprite_index(0, Direction.S) == 0)
|
||||
test("PUNY_29 col=5 S -> 5", PUNY_29.sprite_index(5, Direction.S) == 5)
|
||||
test("PUNY_29 col=0 N -> 116", PUNY_29.sprite_index(0, Direction.N) == 4 * 29)
|
||||
test("PUNY_29 col=5 E -> 6*29+5", PUNY_29.sprite_index(5, Direction.E) == 6 * 29 + 5)
|
||||
|
||||
# SLIME: always row 0 (1-dir)
|
||||
test("SLIME col=3 any dir -> 3", SLIME.sprite_index(3, Direction.E) == 3)
|
||||
|
||||
|
||||
# ---- AnimatedSprite (with mock sprite) ----
|
||||
print("\n=== AnimatedSprite ===")
|
||||
|
||||
# Create a real mcrfpy.Sprite with a from_bytes texture for testing
|
||||
tex_data = bytes([128, 128, 128, 255] * (768 * 256))
|
||||
tex = mcrfpy.Texture.from_bytes(tex_data, 768, 256, 32, 32)
|
||||
sprite = mcrfpy.Sprite(texture=tex, pos=(0, 0))
|
||||
|
||||
anim = AnimatedSprite(sprite, PUNY_24, Direction.S)
|
||||
test("AnimatedSprite created", anim is not None)
|
||||
test("AnimatedSprite starts with idle", anim.animation_name == "idle")
|
||||
test("AnimatedSprite direction is S", anim.direction == Direction.S)
|
||||
|
||||
# Play walk
|
||||
anim.play("walk")
|
||||
test("play walk", anim.animation_name == "walk")
|
||||
test("walk frame 0", anim.frame_index == 0)
|
||||
|
||||
# Tick through first frame (walk frame 0 is col 1, 200ms)
|
||||
anim.tick(100) # half of 200ms
|
||||
test("tick 100ms stays on frame 0", anim.frame_index == 0)
|
||||
|
||||
anim.tick(100) # now 200ms total -> advance to frame 1
|
||||
test("tick 200ms advances to frame 1", anim.frame_index == 1)
|
||||
|
||||
# Sprite index should reflect col 2 (walk frame 1), row 0 (S)
|
||||
expected_idx = 0 * 24 + 2 # row=0, col=2
|
||||
test("sprite_index after advance",
|
||||
sprite.sprite_index == expected_idx,
|
||||
f"got {sprite.sprite_index}, expected {expected_idx}")
|
||||
|
||||
# Direction change
|
||||
anim.set_direction(Direction.E)
|
||||
test("direction changed to E", anim.direction == Direction.E)
|
||||
# Same column but different row
|
||||
expected_idx = 6 * 24 + 2 # row=6 (E), col=2
|
||||
test("sprite_index after dir change",
|
||||
sprite.sprite_index == expected_idx,
|
||||
f"got {sprite.sprite_index}, expected {expected_idx}")
|
||||
|
||||
# One-shot animation
|
||||
anim.play("slash")
|
||||
test("slash starts at frame 0", anim.frame_index == 0)
|
||||
# Slash: 4 frames of 100ms each, then chains to idle
|
||||
anim.tick(100)
|
||||
anim.tick(100)
|
||||
anim.tick(100)
|
||||
test("slash frame 3 after 300ms", anim.frame_index == 3)
|
||||
anim.tick(100) # finish last frame -> chain to idle
|
||||
test("slash chains to idle", anim.animation_name == "idle")
|
||||
|
||||
# Death animation (no chain)
|
||||
anim.play("death")
|
||||
anim.tick(100) # frame 0
|
||||
anim.tick(100) # frame 1 (last frame for PUNY_24 death)
|
||||
# death has chain_to=None, so it stays finished
|
||||
# Wait for last frame to expire
|
||||
anim.tick(1000) # way past duration
|
||||
test("death finished", anim.finished)
|
||||
test("death stays on last frame", anim.frame_index == 1)
|
||||
|
||||
# Invalid animation name
|
||||
try:
|
||||
anim.play("nonexistent")
|
||||
test("invalid anim raises KeyError", False, "no exception")
|
||||
except KeyError:
|
||||
test("invalid anim raises KeyError", True)
|
||||
|
||||
|
||||
# ---- CharacterAssembler (basic) ----
|
||||
print("\n=== CharacterAssembler ===")
|
||||
|
||||
asm = CharacterAssembler(PUNY_24)
|
||||
test("assembler created", asm is not None)
|
||||
|
||||
# Empty build should fail
|
||||
try:
|
||||
asm.build()
|
||||
test("empty build raises", False, "no exception")
|
||||
except ValueError:
|
||||
test("empty build raises ValueError", True)
|
||||
|
||||
|
||||
# ---- Summary ----
|
||||
print()
|
||||
if errors:
|
||||
print(f"FAILED: {len(errors)} tests failed")
|
||||
for e in errors:
|
||||
print(f" {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("All tests passed!")
|
||||
sys.exit(0)
|
||||
123
tests/unit/texture_methods_test.py
Normal file
123
tests/unit/texture_methods_test.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Tests for Texture.from_bytes(), Texture.composite(), texture.hsl_shift()"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
errors = []
|
||||
|
||||
def test(name, condition, msg=""):
|
||||
if not condition:
|
||||
errors.append(f"FAIL: {name} - {msg}")
|
||||
print(f" FAIL: {name} {msg}")
|
||||
else:
|
||||
print(f" PASS: {name}")
|
||||
|
||||
# ---- Test from_bytes ----
|
||||
print("=== Texture.from_bytes ===")
|
||||
|
||||
# Create a 4x4 red image (RGBA)
|
||||
w, h = 4, 4
|
||||
red_bytes = bytes([255, 0, 0, 255] * (w * h))
|
||||
tex = mcrfpy.Texture.from_bytes(red_bytes, w, h, 2, 2)
|
||||
test("from_bytes returns Texture", isinstance(tex, mcrfpy.Texture))
|
||||
test("from_bytes sprite_width", tex.sprite_width == 2, f"got {tex.sprite_width}")
|
||||
test("from_bytes sprite_height", tex.sprite_height == 2, f"got {tex.sprite_height}")
|
||||
test("from_bytes sheet_width", tex.sheet_width == 2, f"got {tex.sheet_width}")
|
||||
test("from_bytes sheet_height", tex.sheet_height == 2, f"got {tex.sheet_height}")
|
||||
test("from_bytes sprite_count", tex.sprite_count == 4, f"got {tex.sprite_count}")
|
||||
|
||||
# Wrong size should raise ValueError
|
||||
try:
|
||||
mcrfpy.Texture.from_bytes(b"\x00\x00", 4, 4, 2, 2)
|
||||
test("from_bytes wrong size raises", False, "no exception raised")
|
||||
except ValueError as e:
|
||||
test("from_bytes wrong size raises ValueError", True)
|
||||
|
||||
# bytearray should work too
|
||||
ba = bytearray([0, 255, 0, 128] * 16)
|
||||
tex2 = mcrfpy.Texture.from_bytes(ba, 4, 4, 4, 4)
|
||||
test("from_bytes accepts bytearray", tex2.sprite_count == 1)
|
||||
|
||||
# With name parameter
|
||||
tex3 = mcrfpy.Texture.from_bytes(red_bytes, 4, 4, 2, 2, name="test_red")
|
||||
test("from_bytes with name", tex3.source == "test_red", f"got '{tex3.source}'")
|
||||
|
||||
# ---- Test composite ----
|
||||
print("\n=== Texture.composite ===")
|
||||
|
||||
# Two 4x4 layers: red bottom, semi-transparent blue top
|
||||
red_data = bytes([255, 0, 0, 255] * 16)
|
||||
blue_data = bytes([0, 0, 255, 128] * 16) # 50% alpha blue
|
||||
|
||||
red_tex = mcrfpy.Texture.from_bytes(red_data, 4, 4, 4, 4)
|
||||
blue_tex = mcrfpy.Texture.from_bytes(blue_data, 4, 4, 4, 4)
|
||||
|
||||
comp = mcrfpy.Texture.composite([red_tex, blue_tex], 4, 4)
|
||||
test("composite returns Texture", isinstance(comp, mcrfpy.Texture))
|
||||
test("composite sprite_count", comp.sprite_count == 1)
|
||||
|
||||
# Fully transparent over opaque should equal opaque
|
||||
transparent_data = bytes([0, 0, 0, 0] * 16)
|
||||
trans_tex = mcrfpy.Texture.from_bytes(transparent_data, 4, 4, 4, 4)
|
||||
comp2 = mcrfpy.Texture.composite([red_tex, trans_tex], 4, 4)
|
||||
test("composite transparent over red", isinstance(comp2, mcrfpy.Texture))
|
||||
|
||||
# Opaque over opaque should equal top
|
||||
green_data = bytes([0, 255, 0, 255] * 16)
|
||||
green_tex = mcrfpy.Texture.from_bytes(green_data, 4, 4, 4, 4)
|
||||
comp3 = mcrfpy.Texture.composite([red_tex, green_tex], 4, 4)
|
||||
test("composite opaque over opaque", isinstance(comp3, mcrfpy.Texture))
|
||||
|
||||
# Empty list should raise
|
||||
try:
|
||||
mcrfpy.Texture.composite([], 4, 4)
|
||||
test("composite empty raises", False, "no exception")
|
||||
except ValueError:
|
||||
test("composite empty raises ValueError", True)
|
||||
|
||||
# Wrong type in list
|
||||
try:
|
||||
mcrfpy.Texture.composite([red_tex, "not a texture"], 4, 4)
|
||||
test("composite wrong type raises", False, "no exception")
|
||||
except TypeError:
|
||||
test("composite wrong type raises TypeError", True)
|
||||
|
||||
# With name
|
||||
comp4 = mcrfpy.Texture.composite([red_tex], 2, 2, name="my_composite")
|
||||
test("composite with name", comp4.source == "my_composite", f"got '{comp4.source}'")
|
||||
|
||||
# ---- Test hsl_shift ----
|
||||
print("\n=== texture.hsl_shift ===")
|
||||
|
||||
# Shift pure red by 120 degrees -> should become green-ish
|
||||
shifted = red_tex.hsl_shift(120.0)
|
||||
test("hsl_shift returns Texture", isinstance(shifted, mcrfpy.Texture))
|
||||
test("hsl_shift preserves sprite dims",
|
||||
shifted.sprite_width == red_tex.sprite_width and
|
||||
shifted.sprite_height == red_tex.sprite_height)
|
||||
|
||||
# Zero shift should still work
|
||||
same = red_tex.hsl_shift(0.0)
|
||||
test("hsl_shift zero produces Texture", isinstance(same, mcrfpy.Texture))
|
||||
|
||||
# With sat and lit shifts
|
||||
darkened = red_tex.hsl_shift(0.0, 0.0, -0.3)
|
||||
test("hsl_shift with lit_shift", isinstance(darkened, mcrfpy.Texture))
|
||||
|
||||
# Transparent pixels should be unchanged
|
||||
trans_shifted = trans_tex.hsl_shift(180.0)
|
||||
test("hsl_shift on transparent", isinstance(trans_shifted, mcrfpy.Texture))
|
||||
|
||||
# Shift by 360 should be same as 0
|
||||
full_circle = red_tex.hsl_shift(360.0)
|
||||
test("hsl_shift 360 degrees", isinstance(full_circle, mcrfpy.Texture))
|
||||
|
||||
# ---- Summary ----
|
||||
print()
|
||||
if errors:
|
||||
print(f"FAILED: {len(errors)} tests failed")
|
||||
for e in errors:
|
||||
print(f" {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("All tests passed!")
|
||||
sys.exit(0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue