Files

216 lines
7.5 KiB
Python

"""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()