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