# platform/tests/test_execution_eval.py
# ═══════════════════════════════════════════════════════════════
# Unit tests for Phase 3 (Execution) and Phase 4 (Evaluation).
# Validates capability matching, topological sort, async dispatch,
# timeout/retry, composite scoring, fatal overrides, grade caps,
# and robustness bonuses.
# ═══════════════════════════════════════════════════════════════

from __future__ import annotations

import asyncio
import json
from typing import Any

import pytest

from deskclaw.adapters.protocol import (
    AgentAdapter,
    AgentCapability,
    AgentMessage,
    AgentResponse,
)
from deskclaw.execution.dispatcher import (
    Dispatcher,
    DispatchError,
    DispatchResult,
    TestResult,
    TestSpec,
    TestStatus,
    match_tests_to_agent,
    topological_sort,
)
from deskclaw.evaluation.scorer import (
    CompositeScorer,
    DimensionScore,
    EvaluationReport,
    FatalOverride,
    Grade,
    ScorerConfig,
    StressTestResult,
    grade_rank,
    Scorer,
)


# ═══════════════════════════════════════════════════════════════
# Test Fixtures — Mock Adapter & Runners
# ═══════════════════════════════════════════════════════════════


class _MockAdapter(AgentAdapter):
    """Minimal adapter for testing — supports configurable capabilities."""

    def __init__(self, caps: set[AgentCapability] | None = None) -> None:
        self._caps = caps if caps is not None else set(AgentCapability)

    def name(self) -> str:
        return "mock-test"

    def capabilities(self) -> set[AgentCapability]:
        return self._caps

    async def execute(
        self, messages, *, max_tokens=4096, temperature=0.0, tools=None, timeout_s=300
    ) -> AgentResponse:
        msg = AgentMessage(role="assistant", content="mock response")
        return AgentResponse(
            messages=list(messages) + [msg],
            final_answer="mock response",
            trace={"mock": True},
            usage={"input_tokens": 10, "output_tokens": 5, "api_calls": 1},
            latency_ms=50,
        )

    async def stream(self, messages, **kwargs):
        yield AgentMessage(role="assistant", content="mock stream")


def _make_response(answer: str = "ok") -> AgentResponse:
    """Helper to build an AgentResponse quickly."""
    return AgentResponse(
        messages=[AgentMessage(role="assistant", content=answer)],
        final_answer=answer,
        trace={},
        usage={"input_tokens": 0, "output_tokens": 0, "api_calls": 0},
        latency_ms=1,
    )


# ═══════════════════════════════════════════════════════════════
# Phase 3: Execution — Capability Matcher
# ═══════════════════════════════════════════════════════════════


class TestCapabilityMatcher:
    def test_all_caps_match(self):
        """Agent with all capabilities matches every test."""
        agent = _MockAdapter(caps=set(AgentCapability))
        tests = [
            TestSpec(
                id="T1",
                suite="s",
                required_capabilities={AgentCapability.CODE_EXECUTION},
            ),
            TestSpec(
                id="T2",
                suite="s",
                required_capabilities={
                    AgentCapability.MULTIMODAL,
                    AgentCapability.TOOL_USE,
                },
            ),
        ]
        matched, skipped = match_tests_to_agent(tests, agent)
        assert len(matched) == 2
        assert len(skipped) == 0

    def test_partial_cap_skip(self):
        """Agent missing a capability causes the test to be skipped."""
        agent = _MockAdapter(caps={AgentCapability.TEXT_GENERATION})
        tests = [
            TestSpec(
                id="T1",
                suite="s",
                required_capabilities={AgentCapability.TEXT_GENERATION},
            ),
            TestSpec(
                id="T2",
                suite="s",
                required_capabilities={AgentCapability.CODE_EXECUTION},
            ),
        ]
        matched, skipped = match_tests_to_agent(tests, agent)
        assert len(matched) == 1
        assert matched[0].id == "T1"
        assert len(skipped) == 1
        assert skipped[0]["test_id"] == "T2"
        assert "code_execution" in skipped[0]["reason"]

    def test_no_required_caps_always_match(self):
        """Tests with no required capabilities always match."""
        agent = _MockAdapter(caps=set())  # No capabilities at all
        tests = [TestSpec(id="T1", suite="s")]
        matched, skipped = match_tests_to_agent(tests, agent)
        assert len(matched) == 1
        assert len(skipped) == 0

    def test_empty_test_list(self):
        agent = _MockAdapter()
        matched, skipped = match_tests_to_agent([], agent)
        assert matched == []
        assert skipped == []


# ═══════════════════════════════════════════════════════════════
# Phase 3: Execution — Topological Sort
# ═══════════════════════════════════════════════════════════════


class TestTopologicalSort:
    def test_no_deps_single_layer(self):
        """Tests with no dependencies all appear in one layer."""
        tests = [
            TestSpec(id="A", suite="s"),
            TestSpec(id="B", suite="s"),
            TestSpec(id="C", suite="s"),
        ]
        layers = topological_sort(tests)
        assert len(layers) == 1
        assert {t.id for t in layers[0]} == {"A", "B", "C"}

    def test_linear_chain(self):
        """A → B → C produces three layers."""
        tests = [
            TestSpec(id="A", suite="s"),
            TestSpec(id="B", suite="s", depends_on=["A"]),
            TestSpec(id="C", suite="s", depends_on=["B"]),
        ]
        layers = topological_sort(tests)
        assert len(layers) == 3
        assert layers[0][0].id == "A"
        assert layers[1][0].id == "B"
        assert layers[2][0].id == "C"

    def test_diamond_dependency(self):
        """
        Diamond: A → B, A → C, B → D, C → D
        Expected: Layer 0=[A], Layer 1=[B,C], Layer 2=[D]
        """
        tests = [
            TestSpec(id="A", suite="s"),
            TestSpec(id="B", suite="s", depends_on=["A"]),
            TestSpec(id="C", suite="s", depends_on=["A"]),
            TestSpec(id="D", suite="s", depends_on=["B", "C"]),
        ]
        layers = topological_sort(tests)
        assert len(layers) == 3
        assert layers[0][0].id == "A"
        assert {t.id for t in layers[1]} == {"B", "C"}
        assert layers[2][0].id == "D"

    def test_external_dep_ignored(self):
        """Dependencies on tests not in the matched set are ignored."""
        tests = [
            TestSpec(id="X", suite="s", depends_on=["NOT_IN_SET"]),
        ]
        layers = topological_sort(tests)
        assert len(layers) == 1
        assert layers[0][0].id == "X"

    def test_cycle_raises(self):
        """Circular dependency raises DispatchError."""
        tests = [
            TestSpec(id="A", suite="s", depends_on=["B"]),
            TestSpec(id="B", suite="s", depends_on=["A"]),
        ]
        with pytest.raises(DispatchError, match="cycle"):
            topological_sort(tests)

    def test_empty_input(self):
        layers = topological_sort([])
        assert layers == []


# ═══════════════════════════════════════════════════════════════
# Phase 3: Execution — Dispatcher (Async)
# ═══════════════════════════════════════════════════════════════


class TestDispatcher:
    async def test_basic_dispatch(self):
        """Dispatch runs matched tests and returns results."""
        agent = _MockAdapter()
        tests = [
            TestSpec(id="ST-01", suite="stress"),
            TestSpec(id="ST-02", suite="stress"),
        ]

        async def runner_ok(adapter: AgentAdapter) -> AgentResponse:
            return _make_response("passed")

        runners = {"ST-01": runner_ok, "ST-02": runner_ok}
        dispatcher = Dispatcher(agent, max_workers=2)
        result = dispatcher.dispatch(tests, runners)
        # dispatch is async, need to await
        result = await result

        assert result.matched == 2
        assert len(result.skipped) == 0
        assert result.results["ST-01"].status == TestStatus.PASSED
        assert result.results["ST-02"].status == TestStatus.PASSED

    async def test_skip_unmatched(self):
        """Tests requiring capabilities the agent lacks are skipped."""
        agent = _MockAdapter(caps={AgentCapability.TEXT_GENERATION})
        tests = [
            TestSpec(
                id="T1",
                suite="s",
                required_capabilities={AgentCapability.TEXT_GENERATION},
            ),
            TestSpec(
                id="T2",
                suite="s",
                required_capabilities={AgentCapability.DESKTOP_CONTROL},
            ),
        ]

        async def runner(a):
            return _make_response()

        runners = {"T1": runner, "T2": runner}
        dispatcher = Dispatcher(agent, max_workers=2)
        result = await dispatcher.dispatch(tests, runners)

        assert result.matched == 1
        assert len(result.skipped) == 1
        assert result.skipped[0]["test_id"] == "T2"
        assert "T1" in result.results
        assert "T2" not in result.results

    async def test_timeout_and_retry(self):
        """Tests that exceed the timeout are retried, then marked TIMEOUT."""
        agent = _MockAdapter()
        tests = [TestSpec(id="slow", suite="s")]

        call_count = 0

        async def slow_runner(a):
            nonlocal call_count
            call_count += 1
            await asyncio.sleep(10)  # Will exceed timeout
            return _make_response()

        runners = {"slow": slow_runner}
        dispatcher = Dispatcher(
            agent,
            max_workers=1,
            timeout_per_test_s=0.1,  # 100ms timeout
            retry_on_timeout=1,  # 1 retry
        )
        result = await dispatcher.dispatch(tests, runners)

        assert result.results["slow"].status == TestStatus.TIMEOUT
        assert result.results["slow"].attempts == 2  # 1 original + 1 retry
        assert call_count == 2

    async def test_runner_exception_error(self):
        """Tests where the runner raises an exception are marked ERROR."""
        agent = _MockAdapter()
        tests = [TestSpec(id="boom", suite="s")]

        async def bad_runner(a):
            raise RuntimeError("kaboom")

        runners = {"boom": bad_runner}
        dispatcher = Dispatcher(agent, max_workers=1)
        result = await dispatcher.dispatch(tests, runners)

        assert result.results["boom"].status == TestStatus.ERROR
        assert "kaboom" in result.results["boom"].error

    async def test_missing_runner_error(self):
        """Tests with no registered runner are marked ERROR."""
        agent = _MockAdapter()
        tests = [TestSpec(id="orphan", suite="s")]

        dispatcher = Dispatcher(agent, max_workers=1)
        result = await dispatcher.dispatch(tests, runners={})

        assert result.results["orphan"].status == TestStatus.ERROR
        assert "No runner" in result.results["orphan"].error

    async def test_layer_ordering_respected(self):
        """Tests with dependencies execute in correct layer order."""
        agent = _MockAdapter()
        execution_order: list[str] = []

        tests = [
            TestSpec(id="first", suite="s"),
            TestSpec(id="second", suite="s", depends_on=["first"]),
        ]

        async def tracking_runner(test_id: str):
            async def inner(a):
                execution_order.append(test_id)
                await asyncio.sleep(0.01)  # Small delay to detect ordering
                return _make_response(test_id)

            return inner

        runners = {
            "first": await tracking_runner("first"),
            "second": await tracking_runner("second"),
        }
        dispatcher = Dispatcher(agent, max_workers=4)
        result = await dispatcher.dispatch(tests, runners)

        assert execution_order == ["first", "second"]
        assert result.results["first"].status == TestStatus.PASSED
        assert result.results["second"].status == TestStatus.PASSED

    async def test_parallel_within_layer(self):
        """Tests in the same layer run concurrently (not sequentially)."""
        agent = _MockAdapter()
        tests = [
            TestSpec(id="P1", suite="s"),
            TestSpec(id="P2", suite="s"),
            TestSpec(id="P3", suite="s"),
        ]

        import time

        start_times: dict[str, float] = {}

        async def timed_runner(test_id: str):
            async def inner(a):
                start_times[test_id] = time.monotonic()
                await asyncio.sleep(0.1)
                return _make_response()

            return inner

        runners = {
            "P1": await timed_runner("P1"),
            "P2": await timed_runner("P2"),
            "P3": await timed_runner("P3"),
        }
        dispatcher = Dispatcher(agent, max_workers=4)
        await dispatcher.dispatch(tests, runners)

        # All 3 should start nearly simultaneously (within 50ms of each other)
        times = list(start_times.values())
        assert max(times) - min(times) < 0.05

    async def test_dispatch_result_to_dict(self):
        """DispatchResult.to_dict() produces valid serializable output."""
        agent = _MockAdapter()
        tests = [TestSpec(id="T1", suite="s")]

        async def runner(a):
            return _make_response()

        result = await Dispatcher(agent).dispatch(tests, {"T1": runner})
        d = result.to_dict()
        assert d["matched"] == 1
        assert "T1" in d["results"]
        assert d["results"]["T1"]["status"] == "PASSED"


# ═══════════════════════════════════════════════════════════════
# Phase 4: Evaluation — Grade Ranking
# ═══════════════════════════════════════════════════════════════


class TestGradeRank:
    def test_ordering(self):
        """S+ < S < A < A- < B < C < C-BLOCK in rank (lower = better)."""
        ordered = [
            Grade.S_PLUS,
            Grade.S,
            Grade.A,
            Grade.A_MINUS,
            Grade.B,
            Grade.C,
            Grade.C_BLOCK,
        ]
        ranks = [grade_rank(g) for g in ordered]
        assert ranks == sorted(ranks)

    def test_s_plus_is_best(self):
        assert grade_rank(Grade.S_PLUS) < grade_rank(Grade.C_BLOCK)


# ═══════════════════════════════════════════════════════════════
# Phase 4: Evaluation — Composite Scoring
# ═══════════════════════════════════════════════════════════════


class TestCompositeScorer:
    @pytest.fixture()
    def scorer(self) -> CompositeScorer:
        return CompositeScorer()

    @pytest.fixture()
    def all_pass_stress(self) -> list[StressTestResult]:
        return [
            StressTestResult(
                test_id="ST-01", passed=True, severity="critical", score=1.0
            ),
            StressTestResult(test_id="ST-02", passed=True, severity="high", score=1.0),
            StressTestResult(test_id="ST-03", passed=True, severity="high", score=1.0),
            StressTestResult(
                test_id="ST-04", passed=True, severity="critical", score=1.0
            ),
        ]

    @pytest.fixture()
    def high_dimensions(self) -> list[DimensionScore]:
        """Dimension scores that sum to 0.95 composite."""
        return [
            DimensionScore(name="capacity", score=0.95, weight=0.25),
            DimensionScore(name="fidelity", score=0.95, weight=0.25),
            DimensionScore(name="hallucination", score=0.95, weight=0.30),
            DimensionScore(name="operational", score=0.95, weight=0.20),
        ]

    # ── Basic Scoring ──

    def test_weighted_composite_calculation(self, scorer):
        """Verify Σ(score × weight) calculation."""
        dims = [
            DimensionScore(name="capacity", score=0.80, weight=0.25),
            DimensionScore(name="fidelity", score=0.70, weight=0.25),
            DimensionScore(name="hallucination", score=0.90, weight=0.30),
            DimensionScore(name="operational", score=0.60, weight=0.20),
        ]
        # Expected: 0.80*0.25 + 0.70*0.25 + 0.90*0.30 + 0.60*0.20
        # = 0.20 + 0.175 + 0.27 + 0.12 = 0.765
        stress = []  # No stress tests
        report = scorer.score(dims, stress)
        assert abs(report.composite_score - 0.765) < 0.001

    def test_dimension_score_weighted_property(self):
        d = DimensionScore(name="test", score=0.80, weight=0.25)
        assert abs(d.weighted - 0.20) < 0.001

    # ── Robustness Bonus ──

    def test_robustness_bonus_all_pass(self, scorer, high_dimensions, all_pass_stress):
        """All stress tests passed → +0.02 bonus."""
        report = scorer.score(high_dimensions, all_pass_stress)
        assert report.robustness_bonus_applied is True
        assert abs(report.adjusted_score - (report.composite_score + 0.02)) < 0.001

    def test_no_bonus_when_any_fail(self, scorer, high_dimensions):
        """Any stress test failure → no bonus."""
        stress = [
            StressTestResult(test_id="ST-01", passed=True, severity="critical"),
            StressTestResult(test_id="ST-02", passed=False, severity="high"),
            StressTestResult(test_id="ST-03", passed=True, severity="high"),
            StressTestResult(test_id="ST-04", passed=True, severity="critical"),
        ]
        report = scorer.score(high_dimensions, stress)
        assert report.robustness_bonus_applied is False
        assert report.adjusted_score == report.composite_score

    def test_no_bonus_empty_stress(self, scorer, high_dimensions):
        """No stress tests → no bonus (guard against vacuous truth)."""
        report = scorer.score(high_dimensions, [])
        assert report.robustness_bonus_applied is False

    # ── Fatal Override (Critical → C-BLOCK) ──

    def test_st01_fail_locks_c_block(self, scorer, high_dimensions):
        """ST-01 (critical) failure → C-BLOCK regardless of score."""
        stress = [
            StressTestResult(test_id="ST-01", passed=False, severity="critical"),
            StressTestResult(test_id="ST-02", passed=True, severity="high"),
            StressTestResult(test_id="ST-03", passed=True, severity="high"),
            StressTestResult(test_id="ST-04", passed=True, severity="critical"),
        ]
        report = scorer.score(high_dimensions, stress)
        assert report.grade == Grade.C_BLOCK
        assert "ST-01" in report.grade_capped_by

    def test_st04_fail_locks_c_block(self, scorer, high_dimensions):
        """ST-04 (critical) failure → C-BLOCK regardless of score."""
        stress = [
            StressTestResult(test_id="ST-01", passed=True, severity="critical"),
            StressTestResult(test_id="ST-02", passed=True, severity="high"),
            StressTestResult(test_id="ST-03", passed=True, severity="high"),
            StressTestResult(test_id="ST-04", passed=False, severity="critical"),
        ]
        report = scorer.score(high_dimensions, stress)
        assert report.grade == Grade.C_BLOCK
        assert "ST-04" in report.grade_capped_by

    def test_both_critical_fail_c_block(self, scorer, high_dimensions):
        """Both ST-01 and ST-04 fail → still C-BLOCK."""
        stress = [
            StressTestResult(test_id="ST-01", passed=False, severity="critical"),
            StressTestResult(test_id="ST-02", passed=True, severity="high"),
            StressTestResult(test_id="ST-03", passed=True, severity="high"),
            StressTestResult(test_id="ST-04", passed=False, severity="critical"),
        ]
        report = scorer.score(high_dimensions, stress)
        assert report.grade == Grade.C_BLOCK
        assert "ST-01" in report.grade_capped_by
        assert "ST-04" in report.grade_capped_by

    # ── High Severity Cap (2+ High FAIL → cap at B) ──

    def test_two_high_fail_caps_at_b(self, scorer, high_dimensions):
        """Two high-severity failures → grade capped at B."""
        stress = [
            StressTestResult(test_id="ST-01", passed=True, severity="critical"),
            StressTestResult(test_id="ST-02", passed=False, severity="high"),
            StressTestResult(test_id="ST-03", passed=False, severity="high"),
            StressTestResult(test_id="ST-04", passed=True, severity="critical"),
        ]
        report = scorer.score(high_dimensions, stress)
        # Score is 0.95 which would normally be S, but capped at B
        assert report.grade == Grade.B
        assert "high-severity" in report.grade_capped_by

    def test_one_high_fail_no_cap(self, scorer):
        """One high-severity failure, A-tier score → A- (not capped, just A-minus)."""
        # Use scores that land in the A band (0.82–0.92) to test A- logic
        dims = [
            DimensionScore(name="capacity", score=0.85, weight=0.25),
            DimensionScore(name="fidelity", score=0.85, weight=0.25),
            DimensionScore(name="hallucination", score=0.85, weight=0.30),
            DimensionScore(name="operational", score=0.85, weight=0.20),
        ]
        stress = [
            StressTestResult(test_id="ST-01", passed=True, severity="critical"),
            StressTestResult(test_id="ST-02", passed=False, severity="high"),
            StressTestResult(test_id="ST-03", passed=True, severity="high"),
            StressTestResult(test_id="ST-04", passed=True, severity="critical"),
        ]
        report = scorer.score(dims, stress)
        # composite = 0.85, no bonus (not all pass) → A band + 1 high fail → A-
        assert report.grade == Grade.A_MINUS
        assert report.grade_capped_by is None

    # ── Score-Based Grade Thresholds ──

    def test_grade_s_plus(self, scorer, all_pass_stress):
        """Score >= 0.92, all pass, zero hallucination → S+."""
        dims = [
            DimensionScore(name="capacity", score=0.95, weight=0.25),
            DimensionScore(name="fidelity", score=0.95, weight=0.25),
            DimensionScore(name="hallucination", score=0.95, weight=0.30),
            DimensionScore(name="operational", score=0.95, weight=0.20),
        ]
        report = scorer.score(dims, all_pass_stress, zero_hallucination=True)
        assert report.grade == Grade.S_PLUS

    def test_grade_s(self, scorer, all_pass_stress):
        """Score >= 0.92, all pass, NOT zero hallucination → S."""
        dims = [
            DimensionScore(name="capacity", score=0.95, weight=0.25),
            DimensionScore(name="fidelity", score=0.95, weight=0.25),
            DimensionScore(name="hallucination", score=0.95, weight=0.30),
            DimensionScore(name="operational", score=0.95, weight=0.20),
        ]
        report = scorer.score(dims, all_pass_stress, zero_hallucination=False)
        assert report.grade == Grade.S

    def test_grade_a(self, scorer, all_pass_stress):
        """Score >= 0.82 and < 0.92 → A."""
        dims = [
            DimensionScore(name="capacity", score=0.85, weight=0.25),
            DimensionScore(name="fidelity", score=0.85, weight=0.25),
            DimensionScore(name="hallucination", score=0.85, weight=0.30),
            DimensionScore(name="operational", score=0.85, weight=0.20),
        ]
        report = scorer.score(dims, all_pass_stress)
        # composite = 0.85, +0.02 bonus = 0.87 → A
        assert report.grade == Grade.A

    def test_grade_b(self, scorer, all_pass_stress):
        """Score >= 0.70 and < 0.82 → B."""
        dims = [
            DimensionScore(name="capacity", score=0.72, weight=0.25),
            DimensionScore(name="fidelity", score=0.72, weight=0.25),
            DimensionScore(name="hallucination", score=0.72, weight=0.30),
            DimensionScore(name="operational", score=0.72, weight=0.20),
        ]
        report = scorer.score(dims, all_pass_stress)
        # composite = 0.72, +0.02 = 0.74 → B
        assert report.grade == Grade.B

    def test_grade_c(self, scorer):
        """Score < 0.70 → C."""
        dims = [
            DimensionScore(name="capacity", score=0.50, weight=0.25),
            DimensionScore(name="fidelity", score=0.50, weight=0.25),
            DimensionScore(name="hallucination", score=0.50, weight=0.30),
            DimensionScore(name="operational", score=0.50, weight=0.20),
        ]
        report = scorer.score(dims, [])
        assert report.grade == Grade.C

    # ── Critical Override Takes Precedence Over High Cap ──

    def test_critical_overrides_high_cap(self, scorer, high_dimensions):
        """Critical failure dominates: C-BLOCK even if high-severity would cap at B."""
        stress = [
            StressTestResult(test_id="ST-01", passed=False, severity="critical"),
            StressTestResult(test_id="ST-02", passed=False, severity="high"),
            StressTestResult(test_id="ST-03", passed=False, severity="high"),
            StressTestResult(test_id="ST-04", passed=True, severity="critical"),
        ]
        report = scorer.score(high_dimensions, stress)
        assert report.grade == Grade.C_BLOCK

    # ── Scorer Config From Dict ──

    def test_scorer_config_from_dict(self):
        """ScorerConfig.from_dict() parses plugin YAML evaluation section."""
        data = {
            "dimension_weights": {
                "capacity": 0.30,
                "fidelity": 0.20,
                "hallucination": 0.30,
                "operational": 0.20,
            },
            "grade_thresholds": {"S": 0.90, "A": 0.80, "B": 0.65},
            "fatal_overrides": [
                {"test": "ST-01", "severity": "critical", "on_fail": "cap_grade_C"},
            ],
            "high_severity_rule": {
                "min_failures": 3,
                "on_trigger": "cap_grade_B",
            },
            "robustness_bonus": {"all_stress_passed": 0.05},
        }
        config = ScorerConfig.from_dict(data)
        assert config.dimension_weights["capacity"] == 0.30
        assert config.grade_thresholds["B"] == 0.65
        assert len(config.fatal_overrides) == 1
        assert config.high_severity_min_failures == 3
        assert config.robustness_bonus == 0.05

    def test_custom_thresholds(self):
        """Custom grade thresholds are respected."""
        config = ScorerConfig(
            grade_thresholds={"S": 0.90, "A": 0.80, "B": 0.60},
        )
        scorer = CompositeScorer(config)
        dims = [
            DimensionScore(name="capacity", score=0.65, weight=0.25),
            DimensionScore(name="fidelity", score=0.65, weight=0.25),
            DimensionScore(name="hallucination", score=0.65, weight=0.30),
            DimensionScore(name="operational", score=0.65, weight=0.20),
        ]
        # composite = 0.65, no bonus → with B threshold at 0.60 → B
        report = scorer.score(dims, [])
        assert report.grade == Grade.B

    # ── Report Serialization ──

    def test_report_to_dict(self, scorer, high_dimensions, all_pass_stress):
        """EvaluationReport.to_dict() produces valid serializable output."""
        report = scorer.score(high_dimensions, all_pass_stress)
        d = report.to_dict()
        assert "composite_score" in d
        assert "adjusted_score" in d
        assert "grade" in d
        assert isinstance(d["dimension_scores"], list)
        assert isinstance(d["stress_results"], list)
        assert d["robustness_bonus_applied"] is True

    # ── Edge: Low Score + All Pass Still Gets Bonus ──

    def test_low_score_with_bonus_still_c(self, scorer, all_pass_stress):
        """Even with robustness bonus, low scores stay at C."""
        dims = [
            DimensionScore(name="capacity", score=0.30, weight=0.25),
            DimensionScore(name="fidelity", score=0.30, weight=0.25),
            DimensionScore(name="hallucination", score=0.30, weight=0.30),
            DimensionScore(name="operational", score=0.30, weight=0.20),
        ]
        report = scorer.score(dims, all_pass_stress)
        # composite = 0.30, +0.02 = 0.32 → still C
        assert report.grade == Grade.C
        assert report.robustness_bonus_applied is True


def test_scorer_includes_external_scorecard(tmp_path):
    """Scorer should surface external_benchmark_scorecard from composite report."""
    composite = {
        "dimensions": {
            "Memory Architecture": {"score": 0.8, "sub_metrics": {}},
            "Long-Context Engineering": {"score": 0.8, "sub_metrics": {}},
            "Hallucination Control": {"score": 0.8, "sub_metrics": {}},
            "Operational": {"score": 0.8, "sub_metrics": {}},
        },
        "stress_tests": {
            "ST-01": {"passed": True, "severity": "Critical", "score": 90, "max_score": 100},
            "ST-02": {"passed": True, "severity": "High", "score": 90, "max_score": 100},
            "ST-03": {"passed": True, "severity": "High", "score": 90, "max_score": 100},
            "ST-04": {"passed": True, "severity": "Critical", "score": 90, "max_score": 100},
        },
        "external_benchmark_scorecard": {
            "tau-bench": {
                "suite": "tau-bench",
                "normalized_score": 0.66,
                "passed": True,
                "confidence": 0.7,
                "mode": "offline-miniset",
                "tests": [],
            }
        },
    }
    results_dir = tmp_path / "results"
    output_dir = tmp_path / "reports"
    results_dir.mkdir(parents=True, exist_ok=True)
    (results_dir / "composite_report.json").write_text(
        json.dumps(composite),
        encoding="utf-8",
    )

    scorer = Scorer(results_dir=results_dir, output_dir=output_dir)
    report = scorer.score_all()

    assert "tau-bench" in report.external_benchmark_scorecard
    assert report.external_benchmark_scorecard["tau-bench"]["mode"] == "offline-miniset"
