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.
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user