"""Structural-block tests (method-spec §3 Step 4). A claim above the optimistic feasible bound (p90) yields a ``Rejection`` that is a DISTINCT type from ``ValidatedProposal`` — carrying the claimed and feasible figures in its reason, and NO percentiles — so it can never be consumed as validated. """ from __future__ import annotations import pytest from portfolio_optimiser_claude.ir import SavingsProposal from portfolio_optimiser_claude.validator import ( Rejection, ValidatedProposal, validate_proposal, ) def proposal_claiming(claimed: float) -> SavingsProposal: # Degenerate band (no assumptions): every Monte Carlo sample equals the fixed # cost, so the feasible bound is exactly 0.30 × 1000 = 300 at every percentile. return SavingsProposal( project_id="P1", measure="test measure", affected_items=[{"code": "EL", "quantity": 1000, "unit_cost": 1.0}], claimed_saving_nok=claimed, ) class TestStructuralBlock: """§3 Step 4: the deterministic validator gates the numbers — blocking.""" def test_claim_above_p90_yields_rejection(self) -> None: outcome = validate_proposal(proposal_claiming(500)) assert isinstance(outcome, Rejection) def test_claim_within_p90_yields_validated(self) -> None: outcome = validate_proposal(proposal_claiming(200)) assert isinstance(outcome, ValidatedProposal) assert outcome.validates is True # Degenerate band: no spread, all percentiles collapse to the fixed bound. assert outcome.p10 == outcome.p50 == outcome.p90 == pytest.approx(300.0) def test_rejection_reason_carries_claimed_and_feasible_figures(self) -> None: outcome = validate_proposal(proposal_claiming(500)) assert isinstance(outcome, Rejection) assert "500" in outcome.reason assert "300" in outcome.reason class TestRejectionIsUnconsumable: """§3 Step 4: a distinct type with no percentiles — never consumable as validated.""" def test_rejection_is_not_a_validated_proposal(self) -> None: outcome = validate_proposal(proposal_claiming(500)) assert not isinstance(outcome, ValidatedProposal) assert not issubclass(Rejection, ValidatedProposal) assert not issubclass(ValidatedProposal, Rejection) def test_rejection_has_no_percentiles(self) -> None: outcome = validate_proposal(proposal_claiming(500)) assert isinstance(outcome, Rejection) for field in ("p10", "p50", "p90", "validates"): assert not hasattr(outcome, field)