"""Tests for the scenario DSL: loading, defaults, and validation.""" from __future__ import annotations import copy from pathlib import Path import pytest import yaml from fabletest.cli import validate_scenarios from fabletest.model import Scenario, load_corpus from tests.conftest import make_scenario def _scenario_dict(sid: str = "dsl-test-001") -> dict: return { "id": sid, "title": "DSL test", "family": "quorum-manipulation", "description": ( "A synthetic scenario used to exercise the YAML loader and " "scenario defaults in isolation from the engine." ), "precedent": "Synthetic; constructed for DSL unit testing.", "actors": [ {"id": "a1", "faction": "majority"}, {"id": "a2", "faction": "minority"}, ], "resources": {"treasury": 10.0}, "objective": {"type": "proposal_executed", "proposal": "p1"}, "moves": [ {"actor": "a1", "action": "propose", "args": {"kind": "policy"}}, {"actor": "a1", "action": "vote", "args": {"proposal": "p1", "choice": "yes"}}, {"actor": "a1", "action": "tally", "args": {"proposal": "p1"}}, ], "expected": {"attack_succeeds": False, "blocked_by": []}, "empathy": { "worst_off": "a2", "floor": 0.5, "rationale": "The lone minority member bears the cost if quorum games work.", }, } class TestFromDict: def test_round_trip_core_fields(self): scenario = Scenario.from_dict(_scenario_dict()) assert scenario.id == "dsl-test-001" assert scenario.title == "DSL test" assert getattr(scenario.family, "value", scenario.family) == "quorum-manipulation" assert len(scenario.actors) == 2 assert len(scenario.moves) == 3 assert scenario.expected.attack_succeeds is False assert scenario.empathy.floor == pytest.approx(0.5) def test_optional_fields_default(self): data = _scenario_dict() data.pop("tags", None) data.pop("parameters", None) scenario = Scenario.from_dict(data) assert list(scenario.tags or []) == [] assert dict(scenario.parameters or {}) == {} def test_unknown_family_rejected(self): data = _scenario_dict() data["family"] = "underwater-basket-weaving" with pytest.raises((ValueError, KeyError)): Scenario.from_dict(data) def test_helper_builder_produces_valid_scenario(self): scenario = make_scenario() assert scenario.id == "unit-test-scenario" assert scenario.moves class TestCorpusLoading: def _write(self, path: Path, payload) -> None: path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8") def test_load_single_scenario_file(self, tmp_path: Path): self._write(tmp_path / "one.yaml", _scenario_dict("dsl-single-001")) scenarios = load_corpus(tmp_path) assert [s.id for s in scenarios] == ["dsl-single-001"] def test_load_multi_scenario_file(self, tmp_path: Path): payload = { "scenarios": [ _scenario_dict("dsl-multi-001"), _scenario_dict("dsl-multi-002"), ] } self._write(tmp_path / "many.yaml", payload) scenarios = load_corpus(tmp_path) assert sorted(s.id for s in scenarios) == ["dsl-multi-001", "dsl-multi-002"] def test_load_recurses_into_subdirectories(self, tmp_path: Path): sub = tmp_path / "quorum-manipulation" sub.mkdir() self._write(sub / "nested.yaml", _scenario_dict("dsl-nested-001")) scenarios = load_corpus(tmp_path) assert [s.id for s in scenarios] == ["dsl-nested-001"] def test_duplicate_ids_rejected(self, tmp_path: Path): self._write(tmp_path / "a.yaml", _scenario_dict("dsl-dup-001")) self._write(tmp_path / "b.yaml", _scenario_dict("dsl-dup-001")) with pytest.raises(ValueError): load_corpus(tmp_path) class TestStructuralValidation: def test_valid_scenario_has_no_errors(self): outcome = validate_scenarios([Scenario.from_dict(_scenario_dict())]) assert outcome["errors"] == [] def test_missing_precedent_is_an_error(self): data = _scenario_dict() data["precedent"] = " " outcome = validate_scenarios([Scenario.from_dict(data)]) assert any("precedent" in e for e in outcome["errors"]) def test_empathy_floor_out_of_range_is_an_error(self): data = _scenario_dict() data["empathy"]["floor"] = 1.5 outcome = validate_scenarios([Scenario.from_dict(data)]) assert any("empathy.floor" in e for e in outcome["errors"]) def test_bad_id_is_an_error(self): data = _scenario_dict() data["id"] = "Bad_ID!" outcome = validate_scenarios([Scenario.from_dict(data)]) assert any("kebab-case" in e for e in outcome["errors"]) def test_duplicate_ids_reported(self): a = Scenario.from_dict(_scenario_dict("dsl-dup-002")) b = Scenario.from_dict(_scenario_dict("dsl-dup-002")) outcome = validate_scenarios([a, b]) assert any("duplicate" in e for e in outcome["errors"]) def test_expected_success_without_tag_warns(self): data = _scenario_dict() data["expected"]["attack_succeeds"] = True outcome = validate_scenarios([Scenario.from_dict(data)]) assert any("known-vulnerability" in w for w in outcome["warnings"]) data2 = copy.deepcopy(data) data2["tags"] = ["known-vulnerability"] outcome2 = validate_scenarios([Scenario.from_dict(data2)]) assert not any("known-vulnerability" in w for w in outcome2["warnings"])