216 lines
7.5 KiB
Python
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()
|