diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 1f3420b..d48519d 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -410,6 +410,20 @@ class Combatant(_FrozenModel): raise ValueError(msg) return value + @field_validator("id") + @classmethod + def _validate_id(cls, value: str) -> str: + # The transcript line format is "round N : "; an id + # containing ": " would be mis-split by cli._parse_transcript_line, + # and leading/trailing whitespace breaks the "id: " join. + if not value or value != value.strip(): + msg = f"combatant id must be non-empty without surrounding whitespace, got {value!r}" + raise ValueError(msg) + if ": " in value: + msg = f"combatant id must not contain ': ' (breaks transcript parsing), got {value!r}" + raise ValueError(msg) + return value + @property def is_lawful(self) -> bool: """Law axis: LG/LN/LE.""" diff --git a/tests/test_models.py b/tests/test_models.py index e25d5aa..e6dcb25 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -189,6 +189,22 @@ class TestCombatant: with pytest.raises(ValidationError): Combatant.model_validate(_combatant_dict(hit_dice="1d8")) + @pytest.mark.parametrize("bad_id", ["a: b", " goblin", "goblin ", ""]) + def test_rejects_transcript_hostile_id(self, bad_id: str) -> None: + # Given an id that would misparse the "round N id: message" transcript + # (contains ": ") or is empty / padded with whitespace + # When/Then validation rejects it + with pytest.raises(ValidationError): + Combatant.model_validate(_combatant_dict(id=bad_id)) + + def test_accepts_id_with_colon_without_space(self) -> None: + # Given an id using ":" as a separator but never ": " (weapon-style + # ids are safe for the transcript parser) + # When validated + combatant = Combatant.model_validate(_combatant_dict(id="goblin:claw")) + # Then it is accepted + assert combatant.id == "goblin:claw" + def test_frozen(self) -> None: # Given a validated combatant combatant = Combatant.model_validate(_combatant_dict())