"""Tests for ReportContext and build_report_context (P5.3 T1).""" from __future__ import annotations from dataclasses import FrozenInstanceError from pathlib import Path import pytest from pf1e_simulator.client import ClientStats from pf1e_simulator.loaders import load_combatant from pf1e_simulator.map import load_map from pf1e_simulator.nl_policy import NLPolicyStats from pf1e_simulator.objectives import DangerMetrics from pf1e_simulator.report import ReportContext, build_report_context from pf1e_simulator.runner import EncounterSpec, Side, run_encounter from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry ROOT = Path(__file__).resolve().parents[1] MAP = ROOT / "data" / "maps" / "sample_arena.yaml" def _c(path: str): return load_combatant(ROOT / "data" / path) def _spec() -> EncounterSpec: """Fast 1v1 (goblin vs orc) on the sample arena.""" return EncounterSpec( map=load_map(MAP), round_cap=20, sides=( Side(name="players", combatants=(_c("monsters/goblin.json"),), zone="A"), Side(name="monsters", combatants=(_c("monsters/orc.json"),), zone="B"), ), ) def test_build_report_context_minimal() -> None: """Minimal context: only EncounterReport, no RunOutcome.""" registry = load_spell_registry(SPELLS_DIR) spec = _spec() report = run_encounter(spec, runs=10, seed=1, spell_registry=registry) ctx = build_report_context( encounter=report, spec_name="goblin-vs-orc", ) assert isinstance(ctx, ReportContext) assert ctx.encounter is report assert ctx.spec_name == "goblin-vs-orc" assert ctx.danger is None assert ctx.nl_stats is None assert ctx.client_stats is None assert ctx.matchup is None assert ctx.danger_map is None assert ctx.frontier_value is None assert ctx.sensitivity is None def test_build_report_context_with_outcomes() -> None: """Context with RunOutcome -> DangerMetrics computed for the party.""" registry = load_spell_registry(SPELLS_DIR) spec = _spec() report, outcomes = run_encounter( spec, runs=100, seed=1, spell_registry=registry, collect_runs=True ) ctx = build_report_context( encounter=report, spec_name="goblin-vs-orc", outcomes=outcomes, party="players", ) assert ctx.danger is not None assert isinstance(ctx.danger, DangerMetrics) assert ctx.danger.runs == 100 # p_survives, p_tpk, p_any_death are probabilities in [0, 1] assert 0.0 <= ctx.danger.p_survives <= 1.0 assert 0.0 <= ctx.danger.p_tpk <= 1.0 assert 0.0 <= ctx.danger.p_any_death <= 1.0 assert ctx.danger.expected_hp_lost >= 0.0 assert ctx.danger.expected_rounds > 0.0 assert ctx.danger.worst_decile_party_hp >= 0.0 def test_build_report_context_with_all_optional_fields() -> None: """Context with all optional fields populated.""" registry = load_spell_registry(SPELLS_DIR) spec = _spec() report, outcomes = run_encounter( spec, runs=100, seed=1, spell_registry=registry, collect_runs=True ) nl_stats = NLPolicyStats(calls=5, repairs_attempted=1, repairs_succeeded=1, fallbacks=0) client_stats = ClientStats( calls=5, prompt_tokens=1000, completion_tokens=200, cost_usd=0.001, cache_hits=0, cache_misses=5, ) ctx = build_report_context( encounter=report, spec_name="goblin-vs-orc", outcomes=outcomes, party="players", nl_stats=nl_stats, client_stats=client_stats, ) assert ctx.nl_stats is nl_stats assert ctx.client_stats is client_stats assert ctx.nl_stats.calls == 5 # type: ignore[reportOptionalMemberAccess] assert ctx.nl_stats.repairs_attempted == 1 # type: ignore[reportOptionalMemberAccess] assert ctx.nl_stats.repairs_succeeded == 1 # type: ignore[reportOptionalMemberAccess] assert ctx.nl_stats.fallbacks == 0 # type: ignore[reportOptionalMemberAccess] assert ctx.client_stats.prompt_tokens == 1000 # type: ignore[reportOptionalMemberAccess] assert ctx.client_stats.cost_usd == 0.001 # type: ignore[reportOptionalMemberAccess] def test_report_context_immutable() -> None: """ReportContext is a frozen dataclass (immutable).""" registry = load_spell_registry(SPELLS_DIR) spec = _spec() report = run_encounter(spec, runs=5, seed=1, spell_registry=registry) ctx = build_report_context( encounter=report, spec_name="test", ) # Frozen dataclass -> assignment raises FrozenInstanceError with pytest.raises(FrozenInstanceError): ctx.encounter = None # type: ignore[misc]