Files
pf1e-simulator/tests/test_combatant_loader.py
T
ctan a8c7e37f8e fix(loaders): resource pools validated consistently before grant merge
_merge_resource_grants silently dropped non-int pool values when features
were present (a "5" pool vanished, leaving only the feat grant) while
Pydantic coerced the same value when features were absent. _validated_resources
now runs the JSON resources through TypeAdapter(dict[str, int]) -- the same
lax coercion as Combatant.resources -- so both paths coerce-or-fail
identically, and grants are added to the coerced ints.
2026-09-20 17:13:09 +02:00

289 lines
12 KiB
Python

"""Unit tests for the combatant loader feat resolution and feature/spell handling."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
from pf1e_simulator.loaders.foundry import Json
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_blank_entry_raises(self, tmp_path: Path) -> None:
with pytest.raises(CombatantLoadError, match="non-empty feat name strings"):
_resolve_features(["Power Attack", "", " ", "Dodge"], 1, tmp_path)
def test_non_string_entry_raises(self, tmp_path: Path) -> None:
raw: list[Json] = ["Iron Will", 42, None, True]
with pytest.raises(CombatantLoadError, match="non-empty feat name strings"):
_resolve_features(raw, 1, tmp_path)
def test_valid_strings_resolve(self, tmp_path: Path) -> None:
result = _resolve_features(["Power Attack", "Dodge"], 1, tmp_path)
assert result == [POWER_ATTACK, DODGE]
def test_empty_list(self, tmp_path: Path) -> None:
assert _resolve_features([], 1, tmp_path) == []
def test_all_unknown(self, tmp_path: Path) -> None:
result = _resolve_features(["Feat A", "Feat B"], 1, tmp_path)
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_toughness_numeric_string_level(self, tmp_path: Path) -> None:
data = _minimal_payload(level="4", features=["Toughness"])
c = load_combatant(_write(tmp_path, data))
assert c.level == 4
assert c.features[0].effects == toughness(4).effects
def test_toughness_lossless_float_level(self, tmp_path: Path) -> None:
data = _minimal_payload(level=4.0, features=["Toughness"])
c = load_combatant(_write(tmp_path, data))
assert c.level == 4
assert c.features[0].effects == toughness(4).effects
def test_non_numeric_level_raises(self, tmp_path: Path) -> None:
data = _minimal_payload(level="abc", features=["Toughness"])
with pytest.raises(CombatantLoadError, match="level must be an integer"):
load_combatant(_write(tmp_path, data))
def test_fractional_level_raises(self, tmp_path: Path) -> None:
data = _minimal_payload(level=4.5, features=["Toughness"])
with pytest.raises(CombatantLoadError, match="level must be an integer"):
load_combatant(_write(tmp_path, data))
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 == []
def test_string_features_raises(self, tmp_path: Path) -> None:
data = _minimal_payload(features="Toughness")
with pytest.raises(CombatantLoadError, match="features must be a list"):
load_combatant(_write(tmp_path, data))
def test_dict_features_raises(self, tmp_path: Path) -> None:
data = _minimal_payload(features={"a": "Toughness"})
with pytest.raises(CombatantLoadError, match="features must be a list"):
load_combatant(_write(tmp_path, data))
def test_null_features_raises(self, tmp_path: Path) -> None:
data = _minimal_payload(features=None)
with pytest.raises(CombatantLoadError, match="features must be a list"):
load_combatant(_write(tmp_path, data))
def test_non_string_entry_raises(self, tmp_path: Path) -> None:
data = _minimal_payload(features=["Toughness", 42])
with pytest.raises(CombatantLoadError, match="non-empty feat name strings"):
load_combatant(_write(tmp_path, data))
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 TestResourcePools:
def test_string_pool_coerced_without_features(self, tmp_path: Path) -> None:
data = _minimal_payload(resources={"channel_energy": "5"})
c = load_combatant(_write(tmp_path, data))
assert c.resources == {"channel_energy": 5}
def test_string_pool_coerced_with_granting_feature(self, tmp_path: Path) -> None:
data = _minimal_payload(
resources={"channel_energy": "5"},
features=["Exalted of the Society (cleric, pathfinder society)"],
)
c = load_combatant(_write(tmp_path, data))
assert c.resources == {"channel_energy": 6}
def test_non_numeric_pool_value_raises_with_feature(self, tmp_path: Path) -> None:
data = _minimal_payload(
resources={"channel_energy": "lots"}, features=["Iron Will"]
)
with pytest.raises(CombatantLoadError, match="resources must be an object"):
load_combatant(_write(tmp_path, data))
def test_non_numeric_pool_value_raises_without_feature(self, tmp_path: Path) -> None:
data = _minimal_payload(resources={"channel_energy": "lots"})
with pytest.raises(CombatantLoadError, match="resources must be an object"):
load_combatant(_write(tmp_path, data))
def test_null_resources_raises(self, tmp_path: Path) -> None:
data = _minimal_payload(resources=None)
with pytest.raises(CombatantLoadError, match="resources must be an object"):
load_combatant(_write(tmp_path, data))
def test_grant_added_to_int_pool(self, tmp_path: Path) -> None:
data = _minimal_payload(
resources={"channel_energy": 2},
features=["Exalted of the Society (cleric, pathfinder society)"],
)
c = load_combatant(_write(tmp_path, data))
assert c.resources == {"channel_energy": 3}
class TestSpellSlots:
def test_numeric_string_keys_converted(self, tmp_path: Path) -> None:
data = _minimal_payload(spell_slots={"1": 4, "2": 3})
c = load_combatant(_write(tmp_path, data))
assert c.spell_slots == {1: 4, 2: 3}
def test_non_numeric_key_raises_load_error(self, tmp_path: Path) -> None:
data = _minimal_payload(spell_slots={"one": 4})
with pytest.raises(CombatantLoadError, match="spell_slots keys must be integer"):
load_combatant(_write(tmp_path, data))
def test_float_key_raises_load_error(self, tmp_path: Path) -> None:
data = _minimal_payload(spell_slots={"1.5": 4})
with pytest.raises(CombatantLoadError, match="spell_slots keys must be integer"):
load_combatant(_write(tmp_path, data))
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"