Python library for parsing BattleScribe/NewRecruit roster JSON. Extracted from Nachmund Tracker's processJSON, ported JS→Python. - parse_roster(json_string) / parse_roster_file(path) → RosterSummary - Extracts: unit name, pts, CP, model count, weapon breakdown per model variant - Handles: variable-model-count units, nested costs, compound upgrades - Unicode apostrophe-safe unit lookup (find_unit) - to_dict() for JSON serialization - 8/8 tests passing on real roster data (27 units, 2815pts)
137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
||
"""Tests for bs-roster-parser using the Nachmund Tracker example roster."""
|
||
|
||
import json
|
||
import sys
|
||
import os
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||
|
||
from bs_roster_parser import parse_roster, parse_roster_file, RosterSummary, Unit
|
||
|
||
TEST_FILE = os.path.join(os.path.dirname(__file__), "example_roster.json")
|
||
|
||
|
||
def test_parse_file():
|
||
"""Parse the example roster file and verify basic structure."""
|
||
summary = parse_roster_file(TEST_FILE)
|
||
assert isinstance(summary, RosterSummary)
|
||
assert summary.roster_name == "Cadian 67th Legion (1)"
|
||
assert summary.game_system == "Warhammer 40,000 10th Edition"
|
||
assert summary.points_limit == 2000
|
||
print(f"✓ Parsed: {summary.roster_name}")
|
||
print(f" Game: {summary.game_system}")
|
||
print(f" Points limit: {summary.points_limit}")
|
||
print(f" Faction: {summary.faction}")
|
||
print(f" Units: {summary.unit_count}")
|
||
print(f" Total pts: {summary.total_pts}")
|
||
print(f" Total CP: {summary.total_cp}")
|
||
print(f" Total models: {summary.total_models}")
|
||
|
||
|
||
def test_units_extracted():
|
||
"""Verify units are extracted with correct names and costs."""
|
||
summary = parse_roster_file(TEST_FILE)
|
||
assert summary.units, "No units extracted"
|
||
# Should have at least some units with pts
|
||
units_with_pts = [u for u in summary.units if u.pts > 0]
|
||
assert units_with_pts, "No units with pts > 0 found"
|
||
|
||
# Gaunt's Ghosts should be 100 pts (from the example JSON)
|
||
# Note: the roster uses a unicode curly apostrophe (') not a straight one (')
|
||
ghosts = summary.find_unit("Gaunt's Ghosts")
|
||
assert ghosts is not None, "Gaunt's Ghosts not found"
|
||
assert ghosts.pts == 100, f"Gaunt's Ghosts pts = {ghosts.pts}, expected 100"
|
||
assert ghosts.type == "unit"
|
||
print(f"✓ Gaunt's Ghosts: {ghosts.pts}pts, {ghosts.model_count} models")
|
||
|
||
|
||
def test_model_count():
|
||
"""Verify model counting works for unit nodes."""
|
||
summary = parse_roster_file(TEST_FILE)
|
||
for u in summary.units:
|
||
if u.model_count > 0:
|
||
print(f"✓ {u.name}: {u.model_count} models, {u.pts}pts")
|
||
|
||
|
||
def test_crusade_points():
|
||
"""Verify crusade points extraction."""
|
||
summary = parse_roster_file(TEST_FILE)
|
||
if summary.total_cp > 0:
|
||
cp_units = [u for u in summary.units if u.cp > 0]
|
||
print(f"✓ Crusade points: {summary.total_cp} CP across {len(cp_units)} units")
|
||
for u in cp_units:
|
||
print(f" {u.name}: {u.cp} CP")
|
||
|
||
|
||
def test_model_breakdown():
|
||
"""Verify model variant breakdown (weapons, distinct model types)."""
|
||
summary = parse_roster_file(TEST_FILE)
|
||
for u in summary.units:
|
||
if u.breakdown:
|
||
print(f"✓ {u.name} breakdown:")
|
||
for m in u.breakdown:
|
||
weapon_str = f", weapons: {m.weapons}" if m.weapons else ""
|
||
print(f" {m.name} ×{m.count}{weapon_str}")
|
||
|
||
|
||
def test_to_dict():
|
||
"""Verify serialization to dict."""
|
||
summary = parse_roster_file(TEST_FILE)
|
||
d = summary.to_dict()
|
||
assert "units" in d
|
||
assert "total_pts" in d
|
||
assert isinstance(d["units"], list)
|
||
# Round-trip through JSON
|
||
json_str = json.dumps(d)
|
||
restored = json.loads(json_str)
|
||
assert restored["total_pts"] == summary.total_pts
|
||
print(f"✓ Serialization: {len(restored['units'])} units, {restored['total_pts']}pts")
|
||
|
||
|
||
def test_find_unit_case_insensitive():
|
||
"""Verify case-insensitive unit lookup."""
|
||
summary = parse_roster_file(TEST_FILE)
|
||
# Try different cases
|
||
for name in ["gaunt's ghosts", "GAUNT'S GHOSTS", "Gaunt's Ghosts"]:
|
||
u = summary.find_unit(name)
|
||
if u:
|
||
print(f"✓ Found '{name}' → {u.pts}pts")
|
||
return
|
||
# If Gaunt's Ghosts isn't in this roster, just verify find_unit returns None for nonsense
|
||
assert summary.find_unit("Nonexistent Unit") is None
|
||
print("✓ find_unit returns None for nonexistent units")
|
||
|
||
|
||
def test_string_input():
|
||
"""Verify parse_roster accepts a JSON string."""
|
||
with open(TEST_FILE) as f:
|
||
json_str = f.read()
|
||
summary = parse_roster(json_str)
|
||
assert summary.units
|
||
print(f"✓ String input: {summary.unit_count} units parsed")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
tests = [
|
||
test_parse_file,
|
||
test_units_extracted,
|
||
test_model_count,
|
||
test_crusade_points,
|
||
test_model_breakdown,
|
||
test_to_dict,
|
||
test_find_unit_case_insensitive,
|
||
test_string_input,
|
||
]
|
||
passed = 0
|
||
failed = 0
|
||
for test in tests:
|
||
try:
|
||
test()
|
||
passed += 1
|
||
except Exception as e:
|
||
print(f"✗ {test.__name__}: {e}")
|
||
failed += 1
|
||
print(f"\n{'='*40}")
|
||
print(f"Results: {passed} passed, {failed} failed")
|
||
sys.exit(1 if failed else 0) |