9adc87c9e1
Feat resolution read level with an isinstance(int) check and fell back to 1, while Pydantic accepts numeric strings and lossless floats: a level-4 sheet authored as "4" got Toughness for level 1. _parse_level now runs the value through TypeAdapter(int) (same lax coercion as the model) and raises CombatantLoadError for values the model would reject.
249 lines
9.7 KiB
Python
249 lines
9.7 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 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"
|