"""Typed cost-IR tests (method-spec §7.1). Schema invariants are enforced at IR construction, so a malformed proposal can never exist as a value (§3 Step 2). Loading the IR projection from a bundle is FAIL-FAST: a missing file raises (required input — contrast the tolerant inbox, §5). Pure config/file-layer — no model client, no API key, no network. """ from __future__ import annotations from pathlib import Path from typing import Any import pytest from pydantic import ValidationError from portfolio_optimiser_claude.ir import SavingsProposal, load_validator_input BUNDLE = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro" VALID: dict[str, Any] = { "project_id": "P1", "measure": "LED retrofit", "affected_items": [{"code": "EL", "quantity": 1000, "unit_cost": 1.0}], "claimed_saving_nok": 200, "assumptions": {"EL": [0.8, 1.2]}, } def build(**overrides: Any) -> SavingsProposal: return SavingsProposal(**{**VALID, **overrides}) class TestSchemaInvariants: """§7.1: invariants hold at construction — a schema error, never a validator call.""" def test_valid_proposal_constructs(self) -> None: proposal = build() assert proposal.project_id == "P1" assert proposal.assumptions["EL"] == (0.8, 1.2) def test_empty_affected_items_rejected(self) -> None: with pytest.raises(ValidationError): build(affected_items=[]) def test_negative_quantity_rejected(self) -> None: with pytest.raises(ValidationError): build(affected_items=[{"code": "EL", "quantity": -1, "unit_cost": 1.0}]) @pytest.mark.parametrize("bad", [0, -0.5]) def test_nonpositive_unit_cost_rejected(self, bad: float) -> None: with pytest.raises(ValidationError): build(affected_items=[{"code": "EL", "quantity": 1000, "unit_cost": bad}]) @pytest.mark.parametrize("bad", [0, -100]) def test_nonpositive_claimed_saving_rejected(self, bad: float) -> None: with pytest.raises(ValidationError): build(claimed_saving_nok=bad) def test_claim_above_affected_total_is_schema_error(self) -> None: # The claimed saving MUST NOT exceed the affected items' own total # (Σ quantity·unit_cost = 1000) — a schema error, not a validator rejection. with pytest.raises(ValidationError): build(claimed_saving_nok=1001) def test_claim_equal_to_affected_total_allowed(self) -> None: assert build(claimed_saving_nok=1000).claimed_saving_nok == 1000 def test_assumptions_default_to_empty(self) -> None: # Empty assumptions = degenerate band, no spread (§7.1). proposal = build(assumptions={}) assert proposal.assumptions == {} class TestFailFastLoader: """§7.1: the IR projection is required input — a missing file raises.""" def test_loads_shared_bundle_projection_unchanged(self) -> None: proposal = load_validator_input(BUNDLE) assert proposal.project_id == "BYGG-KONTOR-NORD" assert [item.code for item in proposal.affected_items] == ["ENERGI-TOTAL-EL"] assert proposal.assumptions["ENERGI-TOTAL-EL"] == (0.70, 1.40) def test_missing_projection_raises(self, tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): load_validator_input(tmp_path)