From 82148cea1eb1850cdd6ce83d44b37b146f081584 Mon Sep 17 00:00:00 2001 From: Thien An Date: Tue, 18 Aug 2026 15:11:28 +0200 Subject: [PATCH] test(loaders): add unit tests for combatant feat resolution and feature/spell handling Tests for _resolve_feat (known/unknown/Toughness/Weapon Focus), _resolve_features (filtering, empty lists), load_combatant with features+spells integration, and backward-compat aliases. Gate: 401 tests, ruff clean, basedpyright clean. --- tests/test_combatant_loader.py | 182 +++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/test_combatant_loader.py diff --git a/tests/test_combatant_loader.py b/tests/test_combatant_loader.py new file mode 100644 index 0000000..78297f1 --- /dev/null +++ b/tests/test_combatant_loader.py @@ -0,0 +1,182 @@ +"""Unit tests for the combatant loader feat resolution and feature/spell handling.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +from pf1e_simulator.abilities import ( + DODGE, + IRON_WILL, + POWER_ATTACK, + toughness, + weapon_focus, +) +from pf1e_simulator.loaders.combatant import ( + CombatantLoadError, + MonsterLoadError, + _resolve_feat, + _resolve_features, + load_combatant, + load_monster, +) + +# ── _resolve_feat ───────────────────────────────────────────────────────────── + + +class TestResolveFeat: + def test_known_feat_returns_constant(self) -> None: + assert _resolve_feat("Power Attack", 1) is POWER_ATTACK + + def test_known_feat_case_insensitive(self) -> None: + assert _resolve_feat("power attack", 1) is POWER_ATTACK + assert _resolve_feat("IRON WILL", 1) is IRON_WILL + + def test_toughness_uses_level(self) -> None: + feat = _resolve_feat("Toughness", 5) + assert feat.name == "Toughness" + assert feat.category == "feat" + assert feat.effects == toughness(5).effects + + def test_toughness_different_levels(self) -> None: + low = _resolve_feat("Toughness", 1) + high = _resolve_feat("Toughness", 10) + assert low.effects != high.effects + + def test_weapon_focus_extracts_weapon(self) -> None: + feat = _resolve_feat("Weapon Focus (Longsword)", 1) + assert feat.effects == weapon_focus("Longsword").effects + + def test_weapon_focus_case_insensitive(self) -> None: + feat = _resolve_feat("weapon focus (dagger)", 1) + assert feat.effects == weapon_focus("dagger").effects + + def test_unknown_feat_returns_minimal_spec(self) -> None: + feat = _resolve_feat("Lateral Thinking", 1) + assert feat.name == "Lateral Thinking" + assert feat.category == "feat" + assert feat.effects == () + + def test_unknown_feat_preserves_whitespace_stripping(self) -> None: + feat = _resolve_feat(" Some Feat ", 1) + assert feat.name == "Some Feat" + + def test_dodge_and_alertness(self) -> None: + assert _resolve_feat("Dodge", 1) is DODGE + + +# ── _resolve_features ───────────────────────────────────────────────────────── + + +class TestResolveFeatures: + def test_filters_empty_and_whitespace(self) -> None: + result = _resolve_features(["Power Attack", "", " ", "Dodge"], 1) + assert len(result) == 2 + assert result[0] is POWER_ATTACK + assert result[1] is DODGE + + def test_filters_non_strings(self) -> None: + result = _resolve_features(["Iron Will", 42, None, True], 1) # type: ignore[list-item] + assert len(result) == 1 + + def test_empty_list(self) -> None: + assert _resolve_features([], 1) == [] + + def test_all_unknown(self) -> None: + result = _resolve_features(["Feat A", "Feat B"], 1) + assert len(result) == 2 + assert all(f.effects == () for f in result) + + +# ── load_combatant features+spells ──────────────────────────────────────────── + + +def _write(tmp_path: Path, payload: dict[str, object]) -> Path: + target = tmp_path / "combatant.json" + target.write_text(json.dumps(payload), encoding="utf-8") + return target + + +def _minimal_payload(**extra: object) -> dict[str, object]: + base: dict[str, object] = { + "name": "Tester", + "level": 3, + "abilities": { + "str_score": 10, "dex_score": 10, "con_score": 10, + "int_score": 10, "wis_score": 10, "cha_score": 10, + }, + "hp_max": 10, + "ac": {"total": 10, "touch": 10, "flat_footed": 10}, + "bab": 1, + "initiative_mod": 0, + "speed_land_ft": 30, + "saves": {"fort": 0, "ref": 0, "will": 0}, + "attacks": [], + } + base.update(extra) + return base + + +class TestFeatures: + def test_known_feats_resolved(self, tmp_path: Path) -> None: + data = _minimal_payload(features=["Power Attack", "Iron Will"]) + c = load_combatant(_write(tmp_path, data)) + assert len(c.features) == 2 + assert c.features[0] is POWER_ATTACK + assert c.features[1] is IRON_WILL + + def test_unknown_feat_preserved(self, tmp_path: Path) -> None: + data = _minimal_payload(features=["Some Unknown Feat"]) + c = load_combatant(_write(tmp_path, data)) + assert len(c.features) == 1 + assert c.features[0].name == "Some Unknown Feat" + + def test_toughness_receives_level(self, tmp_path: Path) -> None: + data = _minimal_payload(level=7, features=["Toughness"]) + c = load_combatant(_write(tmp_path, data)) + assert c.features[0].effects == toughness(7).effects + + def test_level_default_one(self, tmp_path: Path) -> None: + data = _minimal_payload(level=1, features=["Toughness"]) + c = load_combatant(_write(tmp_path, data)) + assert c.features[0].effects == toughness(1).effects + + def test_no_features_field(self, tmp_path: Path) -> None: + data = _minimal_payload() + c = load_combatant(_write(tmp_path, data)) + assert c.features == [] + + def test_empty_features_list(self, tmp_path: Path) -> None: + data = _minimal_payload(features=[]) + c = load_combatant(_write(tmp_path, data)) + assert c.features == [] + + +class TestSpells: + def test_spells_passed_through(self, tmp_path: Path) -> None: + data = _minimal_payload(spells=["Magic Missile", "Shield"]) + c = load_combatant(_write(tmp_path, data)) + assert c.spells == ["Magic Missile", "Shield"] + + def test_no_spells_field(self, tmp_path: Path) -> None: + data = _minimal_payload() + c = load_combatant(_write(tmp_path, data)) + assert c.spells == [] + + def test_empty_spells_list(self, tmp_path: Path) -> None: + data = _minimal_payload(spells=[]) + c = load_combatant(_write(tmp_path, data)) + assert c.spells == [] + + +class TestBackwardCompatAliases: + def test_monster_load_error_is_combatant_load_error(self) -> None: + assert MonsterLoadError is CombatantLoadError + + def test_load_monster_delegates(self, tmp_path: Path) -> None: + data = _minimal_payload(name="Alias Test") + c = load_monster(_write(tmp_path, data)) + assert c.name == "Alias Test"