McRogueFace/tests/unit/shade_sprite_factions_test.py
John McCardle 80e14163f9 Shade sprite module: faction generation, asset scanning, TextureCache
Extends the shade_sprite module (for merchant-shade.itch.io character
sprite sheets) with procedural faction generation and asset management:

- FactionGenerator: seed-based faction recipes with Biome, Element,
  Aesthetic, and RoleType enums for thematic variety
- AssetLibrary: filesystem scanner that discovers and categorizes
  layer PNGs by type (skins, clothes, hair, etc.)
- TextureCache: avoids redundant disk I/O when building many variants
- CharacterAssembler: HSL shift documentation, method improvements
- Demo expanded to 6 interactive scenes (animation viewer, HSL recolor,
  character gallery, faction generator, layer compositing, equipment)
- EVALUATION.md: 7DRL readiness assessment of the full module
- 329-line faction generation test suite

Assets themselves are not included -- sprite sheets are external
dependencies, some under commercial license.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:17:24 -05:00

329 lines
12 KiB
Python

"""Unit tests for shade_sprite.factions and shade_sprite.assets modules."""
import mcrfpy
import sys
import os
# Add project root to path
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.assets import AssetLibrary, LayerFile, _parse_species_from_skin
from shade_sprite.factions import (
FactionRecipe, FactionGenerator, RoleDefinition,
Biome, Element, Aesthetic, RoleType,
_AESTHETIC_ROLE_POOLS,
)
from shade_sprite.assembler import CharacterAssembler, TextureCache
from shade_sprite.formats import PUNY_29
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}")
# ===================================================================
# AssetLibrary tests
# ===================================================================
print("=== AssetLibrary ===")
lib = AssetLibrary()
has_assets = lib.available
test("AssetLibrary instantiates", lib is not None)
test("AssetLibrary repr", "AssetLibrary" in repr(lib))
if has_assets:
print(" (Paid asset pack detected - running full asset tests)")
test("has species", len(lib.species) > 0, f"got {lib.species}")
test("Human in species", "Human" in lib.species,
f"species: {lib.species}")
test("Orc in species", "Orc" in lib.species)
# Skins
human_skins = lib.skins_for("Human")
test("skins_for Human returns files", len(human_skins) > 0,
f"got {len(human_skins)}")
test("skin is LayerFile", isinstance(human_skins[0], LayerFile))
test("skin has path", os.path.isfile(human_skins[0].path))
test("skin category is skins", human_skins[0].category == "skins")
# No skins for nonexistent species
test("no skins for Alien", len(lib.skins_for("Alien")) == 0)
# Categories
cats = lib.categories
test("has categories", len(cats) >= 5,
f"got {len(cats)}: {cats}")
test("skins in categories", "skins" in cats)
test("clothes in categories", "clothes" in cats)
# Clothes
clothes = lib.clothes
test("has clothes", len(clothes) > 0, f"got {len(clothes)}")
# Subcategories
clothes_subs = lib.subcategories("clothes")
test("clothes has subcategories", len(clothes_subs) > 0,
f"got {clothes_subs}")
# clothes_by_style
armour = lib.clothes_by_style("armour")
test("armour clothes found", len(armour) > 0, f"got {len(armour)}")
test("armour subcategory", "Armour" in armour[0].subcategory)
# Headgears
melee_hg = lib.headgears_for_class("melee")
test("melee headgears found", len(melee_hg) > 0)
mage_hg = lib.headgears_for_class("mage")
test("mage headgears found", len(mage_hg) > 0)
# Add-ons
orc_addons = lib.addons_for("Orc")
test("Orc add-ons found", len(orc_addons) > 0)
elf_addons = lib.addons_for("Elf")
test("Elf add-ons found", len(elf_addons) > 0)
# Summary
summary = lib.summary()
test("summary has entries", len(summary) > 0)
test("summary values are ints", all(isinstance(v, int) for v in summary.values()))
# Shoes, gloves, etc.
test("has shoes", len(lib.shoes) > 0)
test("has gloves", len(lib.gloves) > 0)
test("has hairstyles", len(lib.hairstyles) > 0)
test("has eyes", len(lib.eyes) > 0)
test("has headgears", len(lib.headgears) > 0)
test("has addons", len(lib.addons) > 0)
else:
print(" (No paid asset pack - running minimal tests)")
test("unavailable lib has no species", len(lib.species) == 0)
test("unavailable lib layers empty", len(lib.layers("skins")) == 0)
test("unavailable lib summary empty", len(lib.summary()) == 0)
# ---- Species parsing ----
print("\n=== Species Parsing ===")
test("parse Human1 -> Human", _parse_species_from_skin("Human1") == "Human")
test("parse Human10 -> Human", _parse_species_from_skin("Human10") == "Human")
test("parse Orc2 -> Orc", _parse_species_from_skin("Orc2") == "Orc")
test("parse NightElf1 -> NightElf", _parse_species_from_skin("NightElf1") == "NightElf")
test("parse Cyclops1 -> Cyclops", _parse_species_from_skin("Cyclops1") == "Cyclops")
test("parse bare name -> itself", _parse_species_from_skin("Demon") == "Demon")
# ===================================================================
# FactionGenerator determinism tests
# ===================================================================
print("\n=== FactionGenerator Determinism ===")
gen1 = FactionGenerator(seed=42, library=lib)
recipe1 = gen1.generate()
gen2 = FactionGenerator(seed=42, library=lib)
recipe2 = gen2.generate()
test("same seed -> same name", recipe1.name == recipe2.name,
f"'{recipe1.name}' vs '{recipe2.name}'")
test("same seed -> same biome", recipe1.biome == recipe2.biome)
test("same seed -> same species", recipe1.species == recipe2.species)
test("same seed -> same element", recipe1.element == recipe2.element)
test("same seed -> same aesthetic", recipe1.aesthetic == recipe2.aesthetic)
test("same seed -> same ally count", len(recipe1.ally_species) == len(recipe2.ally_species))
test("same seed -> same role count", len(recipe1.roles) == len(recipe2.roles))
# Check each role matches
for i, (r1, r2) in enumerate(zip(recipe1.roles, recipe2.roles)):
test(f"role {i} same type", r1.role_type == r2.role_type)
test(f"role {i} same species", r1.species == r2.species)
test(f"role {i} same skin hue", abs(r1.skin_hue - r2.skin_hue) < 0.001,
f"{r1.skin_hue} vs {r2.skin_hue}")
# Different seeds -> different results
gen3 = FactionGenerator(seed=99, library=lib)
recipe3 = gen3.generate()
# Not guaranteed to be different in every field, but extremely likely
# Check at least one attribute differs
differs = (recipe1.name != recipe3.name or
recipe1.biome != recipe3.biome or
recipe1.species != recipe3.species or
recipe1.element != recipe3.element or
recipe1.aesthetic != recipe3.aesthetic)
test("different seed -> likely different recipe", differs)
# ===================================================================
# FactionRecipe structure tests
# ===================================================================
print("\n=== FactionRecipe Structure ===")
test("recipe has name", isinstance(recipe1.name, str) and len(recipe1.name) > 0)
test("recipe has biome", isinstance(recipe1.biome, Biome))
test("recipe has element", isinstance(recipe1.element, Element))
test("recipe has aesthetic", isinstance(recipe1.aesthetic, Aesthetic))
test("recipe has species", isinstance(recipe1.species, str))
test("recipe has seed", recipe1.seed == 42)
test("recipe has 3-5 roles", 3 <= len(recipe1.roles) <= 5,
f"got {len(recipe1.roles)}")
# ===================================================================
# Role generation counts by aesthetic
# ===================================================================
print("\n=== Role Counts by Aesthetic ===")
# Generate many factions to verify aesthetic influence on role pools
role_type_counts = {aesthetic: {} for aesthetic in Aesthetic}
for seed in range(100):
gen = FactionGenerator(seed=seed, library=lib)
recipe = gen.generate()
aes = recipe.aesthetic
for role in recipe.roles:
rt = role.role_type
role_type_counts[aes][rt] = role_type_counts[aes].get(rt, 0) + 1
# Militaristic should have more melee/ranged
mil = role_type_counts[Aesthetic.MILITARISTIC]
test("militaristic has melee fighters",
mil.get(RoleType.MELEE_FIGHTER, 0) > 0)
test("militaristic has ranged fighters",
mil.get(RoleType.RANGED_FIGHTER, 0) > 0)
# Fanatical should have spellcasters
fan = role_type_counts[Aesthetic.FANATICAL]
test("fanatical has spellcasters",
fan.get(RoleType.SPELLCASTER, 0) > 0)
# Cowardly should have pets/flankers
cow = role_type_counts[Aesthetic.COWARDLY]
test("cowardly has pet flankers",
cow.get(RoleType.PET_FLANKER, 0) > 0)
# Slavery should have pets (as enslaved allies)
slv = role_type_counts[Aesthetic.SLAVERY]
test("slavery has pet roles",
slv.get(RoleType.PET_RUSHER, 0) + slv.get(RoleType.PET_FLANKER, 0) > 0)
# ===================================================================
# RoleDefinition skin hue variation
# ===================================================================
print("\n=== Skin Hue Variation ===")
# Within a single faction, main-species roles should have close but distinct hues
gen_hue = FactionGenerator(seed=7, library=lib)
recipe_hue = gen_hue.generate()
main_roles = [r for r in recipe_hue.roles if r.species == recipe_hue.species]
if len(main_roles) >= 2:
hues = [r.skin_hue for r in main_roles]
# Check that hues are distinct (not identical)
unique_hues = len(set(round(h, 2) for h in hues))
test("main species roles have distinct hues",
unique_hues == len(hues),
f"hues: {[f'{h:.1f}' for h in hues]}")
# Check that hues are within reasonable range of each other (within 30 degrees)
# The generator uses +/-15 degree variation from a base
base_hue = sum(hues) / len(hues)
all_close = all(
min(abs(h - base_hue), 360 - abs(h - base_hue)) < 30
for h in hues
)
test("main species hues are close (within 30 degrees of mean)",
all_close, f"hues: {[f'{h:.1f}' for h in hues]}, mean: {base_hue:.1f}")
else:
print(f" SKIP: only {len(main_roles)} main-species roles (need >= 2 for hue test)")
# RoleDefinition label
test("role has label",
"(" in recipe1.roles[0].label and ")" in recipe1.roles[0].label)
# ===================================================================
# RoleDefinition layer assignments (if assets available)
# ===================================================================
if has_assets:
print("\n=== Layer Assignments ===")
# Generate a faction and check that roles have actual file paths
gen_layers = FactionGenerator(seed=55, library=lib)
recipe_layers = gen_layers.generate()
for i, role in enumerate(recipe_layers.roles):
has_skin = role._skin_path is not None
test(f"role {i} ({role.role_type.value}) has skin path", has_skin)
if has_skin:
test(f"role {i} skin file exists", os.path.isfile(role._skin_path))
# Non-pet roles should generally have clothing
if role.role_type not in (RoleType.PET_RUSHER, RoleType.PET_FLANKER):
# Check at least one of clothing/headgear/shoes is assigned
has_any = (len(role.clothing_layers) > 0 or
role.headgear_layer is not None or
role.shoe_layer is not None)
# Not guaranteed for slavery aesthetic allies, but main species should have gear
if not role.is_ally:
test(f"role {i} ({role.role_type.value}) has equipment",
has_any)
# ===================================================================
# TextureCache tests
# ===================================================================
print("\n=== TextureCache ===")
cache = TextureCache()
test("cache starts empty", len(cache) == 0)
# Create a test texture via from_bytes
tex_data = bytes([100, 150, 200, 255] * (928 * 256))
test_tex_path = None # Can't test file loading without real files
# Test cache contains
test("cache doesn't contain missing key",
("nonexistent.png", 0.0, 0.0, 0.0) not in cache)
cache.clear()
test("cache clear works", len(cache) == 0)
# ===================================================================
# Enum coverage
# ===================================================================
print("\n=== Enum Coverage ===")
test("Biome has 5 values", len(Biome) == 5)
test("Element has 4 values", len(Element) == 4)
test("Aesthetic has 4 values", len(Aesthetic) == 4)
test("RoleType has 6 values", len(RoleType) == 6)
# All aesthetics have role pools
for aes in Aesthetic:
pool = _AESTHETIC_ROLE_POOLS[aes]
test(f"{aes.value} has role pool", len(pool) >= 3,
f"got {len(pool)}")
# ===================================================================
# 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)