a8c7e37f8e
_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.
289 lines
12 KiB
Python
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"
|