feat(models): combatant schema, Foundry sheet loader, monster JSON schema
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""Unit tests for the combat data models (contracts for later stages)."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from pf1e_simulator.dice import DiceExpr
|
||||
from pf1e_simulator.models import (
|
||||
AbilityScores,
|
||||
AttackSpec,
|
||||
Combatant,
|
||||
DamageComponent,
|
||||
DamageReduction,
|
||||
)
|
||||
|
||||
|
||||
def _abilities() -> AbilityScores:
|
||||
return AbilityScores(
|
||||
str_score=10,
|
||||
dex_score=11,
|
||||
con_score=12,
|
||||
int_score=13,
|
||||
wis_score=14,
|
||||
cha_score=15,
|
||||
)
|
||||
|
||||
|
||||
def _attack_dict(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"id": "dummy:0",
|
||||
"name": "bite",
|
||||
"kind": "melee",
|
||||
"attack_bonus": 3,
|
||||
"damage": [{"formula": "1d6", "types": ["piercing"]}],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _combatant_dict(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"id": "dummy",
|
||||
"name": "Dummy",
|
||||
"level": 1,
|
||||
"abilities": _abilities(),
|
||||
"hp_max": 10,
|
||||
"ac": {"total": 15, "touch": 12, "flat_footed": 13},
|
||||
"bab": 1,
|
||||
"initiative_mod": 2,
|
||||
"speed_land_ft": 30,
|
||||
"saves": {"fort": 2, "ref": 3, "will": 4},
|
||||
"attacks": [_attack_dict()],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestDamageComponent:
|
||||
def test_parses_string_formula(self) -> None:
|
||||
# Given a raw string formula
|
||||
# When the component is validated
|
||||
component = DamageComponent.model_validate({"formula": "2d6+3", "types": ["fire"]})
|
||||
# Then the formula is parsed into a DiceExpr
|
||||
assert component.formula == DiceExpr(count=2, sides=6, bonus=3)
|
||||
assert component.types == ["fire"]
|
||||
|
||||
def test_accepts_dice_expr_formula(self) -> None:
|
||||
# Given an already-parsed DiceExpr
|
||||
expr = DiceExpr(count=1, sides=4)
|
||||
# When the component is built with it
|
||||
component = DamageComponent(formula=expr, types=["slashing"])
|
||||
# Then it is kept as-is
|
||||
assert component.formula is expr
|
||||
|
||||
def test_rejects_empty_types(self) -> None:
|
||||
# Given a component with no damage type
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
DamageComponent.model_validate({"formula": "1d6", "types": []})
|
||||
|
||||
def test_rejects_unparseable_formula(self) -> None:
|
||||
# Given a formula that is not dice notation
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
DamageComponent.model_validate({"formula": "not dice", "types": ["fire"]})
|
||||
|
||||
|
||||
class TestAttackSpec:
|
||||
def test_defaults(self) -> None:
|
||||
# Given a minimal attack spec
|
||||
# When validated
|
||||
attack = AttackSpec.model_validate(_attack_dict())
|
||||
# Then the optional fields take their contract defaults
|
||||
assert attack.damage_bonus == 0
|
||||
assert attack.crit_range == 20
|
||||
assert attack.crit_mult == 2
|
||||
assert attack.reach_ft == 5
|
||||
assert attack.range_increment_ft is None
|
||||
assert attack.count == 1
|
||||
|
||||
@pytest.mark.parametrize("kind", ["melee", "ranged", "touch"])
|
||||
def test_accepts_all_kinds(self, kind: str) -> None:
|
||||
# Given each allowed kind
|
||||
# When/Then validation succeeds
|
||||
attack = AttackSpec.model_validate(_attack_dict(kind=kind))
|
||||
assert attack.kind == kind
|
||||
|
||||
def test_rejects_unknown_kind(self) -> None:
|
||||
# Given a kind outside the Literal
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
AttackSpec.model_validate(_attack_dict(kind="spell"))
|
||||
|
||||
def test_rejects_empty_damage_list(self) -> None:
|
||||
# Given an attack with no damage component
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
AttackSpec.model_validate(_attack_dict(damage=[]))
|
||||
|
||||
def test_rejects_crit_mult_below_2(self) -> None:
|
||||
# Given crit_mult=1 (a weapon that never multiplies is not PF1e)
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
AttackSpec.model_validate(_attack_dict(crit_mult=1))
|
||||
|
||||
def test_rejects_crit_mult_above_4(self) -> None:
|
||||
# Given crit_mult=5
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
AttackSpec.model_validate(_attack_dict(crit_mult=5))
|
||||
|
||||
@pytest.mark.parametrize("crit_range", [0, 1, 21])
|
||||
def test_rejects_crit_range_out_of_bounds(self, crit_range: int) -> None:
|
||||
# Given a crit range outside 2..20
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
AttackSpec.model_validate(_attack_dict(crit_range=crit_range))
|
||||
|
||||
def test_rejects_zero_count(self) -> None:
|
||||
# Given count=0
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
AttackSpec.model_validate(_attack_dict(count=0))
|
||||
|
||||
|
||||
class TestCombatant:
|
||||
def test_defaults(self) -> None:
|
||||
# Given a minimal combatant
|
||||
# When validated
|
||||
combatant = Combatant.model_validate(_combatant_dict())
|
||||
# Then the optional fields take their contract defaults
|
||||
assert combatant.size == "Medium"
|
||||
assert combatant.speed_fly_ft is None
|
||||
assert combatant.dr is None
|
||||
assert combatant.cr is None
|
||||
assert combatant.xp is None
|
||||
assert combatant.source == ""
|
||||
assert combatant.notes == ""
|
||||
|
||||
def test_rejects_hp_max_zero(self) -> None:
|
||||
# Given hp_max=0
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
Combatant.model_validate(_combatant_dict(hp_max=0))
|
||||
|
||||
def test_rejects_negative_land_speed(self) -> None:
|
||||
# Given a negative land speed
|
||||
# When/Then validation rejects it
|
||||
with pytest.raises(ValidationError):
|
||||
Combatant.model_validate(_combatant_dict(speed_land_ft=-5))
|
||||
|
||||
def test_rejects_unknown_extra_key(self) -> None:
|
||||
# Given an unknown top-level key
|
||||
# When/Then validation rejects it (strict schema)
|
||||
with pytest.raises(ValidationError):
|
||||
Combatant.model_validate(_combatant_dict(hit_dice="1d8"))
|
||||
|
||||
def test_frozen(self) -> None:
|
||||
# Given a validated combatant
|
||||
combatant = Combatant.model_validate(_combatant_dict())
|
||||
# When/Then mutation is rejected
|
||||
with pytest.raises(ValidationError):
|
||||
combatant.name = "Mutilated"
|
||||
|
||||
def test_full_combatant_roundtrip(self) -> None:
|
||||
# Given a fully populated combatant
|
||||
combatant = Combatant.model_validate(
|
||||
_combatant_dict(
|
||||
size="Small",
|
||||
speed_fly_ft=80,
|
||||
dr={"amount": 5, "bypass": ["magic"]},
|
||||
cr="1/3",
|
||||
xp=135,
|
||||
source="Bestiary > Goblin",
|
||||
notes="note",
|
||||
)
|
||||
)
|
||||
# Then every field round-trips
|
||||
assert combatant.size == "Small"
|
||||
assert combatant.speed_fly_ft == 80
|
||||
assert combatant.dr is not None
|
||||
assert combatant.dr.amount == 5
|
||||
assert combatant.dr.bypass == frozenset({"magic"})
|
||||
assert combatant.cr == "1/3"
|
||||
assert combatant.xp == 135
|
||||
assert combatant.source == "Bestiary > Goblin"
|
||||
assert combatant.notes == "note"
|
||||
|
||||
|
||||
class TestDamageReduction:
|
||||
def test_empty_bypass_means_dr_dash(self) -> None:
|
||||
# Given DR with an empty bypass set
|
||||
dr = DamageReduction(amount=10, bypass=frozenset())
|
||||
# Then it represents DR 10/—
|
||||
assert dr.amount == 10
|
||||
assert dr.bypass == frozenset()
|
||||
Reference in New Issue
Block a user