"""Behavioral tests for the typed savings ledger (Fase 1, Step 4 — SC3 + SC8-identical). Multiple realized entries (distinct candidates) total correctly per-project and per-portfolio; each entry carries provenance + a ``verdict_id`` link; a malformed entry raises ``ValidationError`` (fail-fast, contrast the tolerant verdict inbox); the same input serialized twice is byte-identical (SC8 determinism). Model tests: ``tests/test_portfolio.py:49`` (aggregate), ``tests/test_contracts.py`` (fail-fast). """ from __future__ import annotations import pytest from pydantic import ValidationError from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger, stamp _TS = "2026-07-06T00:00:00Z" def _entry( project_id: str, candidate_identity: str, amount_ore: int, *, dimension: str = "energi", verdict_id: str = "v1", ) -> LedgerEntry: return LedgerEntry( project_id=project_id, dimension=dimension, candidate_identity=candidate_identity, amount_ore=amount_ore, verdict_id=verdict_id, provenance=stamp(approver="ekspert", experiment="fase1-sim", timestamp=_TS), ) def test_totals_per_project_and_portfolio() -> None: ledger = SavingsLedger() assert ledger.add_realized(_entry("P1", "c-a", 1000)) is True assert ledger.add_realized(_entry("P1", "c-b", 2500)) is True assert ledger.add_realized(_entry("P2", "c-c", 4000)) is True assert ledger.per_project_total("P1") == 3500 assert ledger.per_project_total("P2") == 4000 assert ledger.portfolio_total() == 7500 def test_entry_carries_provenance_and_verdict_link() -> None: e = _entry("P1", "c-a", 1000, verdict_id="verdict-xyz") assert e.verdict_id == "verdict-xyz" assert "godkjent av ekspert" in e.provenance assert _TS in e.provenance # timestamp is baked into the stamp (deterministic, no wall-clock) def test_malformed_entry_raises_validation_error() -> None: """Fail-fast (contrast the tolerant inbox): a negative ``amount_ore`` violates ``ge=0``.""" with pytest.raises(ValidationError): LedgerEntry( project_id="P1", dimension="energi", candidate_identity="c-a", amount_ore=-1, verdict_id="v1", provenance="x", ) def test_load_rejects_malformed_row(tmp_path) -> None: """A malformed on-disk row raises ``ValidationError`` on load (fail-fast, never skipped).""" bad = tmp_path / "ledger.json" bad.write_text('[{"project_id": "P1"}]', encoding="utf-8") # missing required fields with pytest.raises(ValidationError): SavingsLedger.load(str(bad)) def test_load_missing_file_raises(tmp_path) -> None: with pytest.raises(FileNotFoundError): SavingsLedger.load(str(tmp_path / "does-not-exist.json")) def test_save_is_byte_identical_regardless_of_order(tmp_path) -> None: """SC8 determinism: the same entries inserted in different order serialize byte-identically (sorted keys + integer øre => the on-disk form is order-independent).""" a = SavingsLedger() a.add_realized(_entry("P2", "c-c", 4000)) a.add_realized(_entry("P1", "c-a", 1000)) a.add_realized(_entry("P1", "c-b", 2500)) b = SavingsLedger() b.add_realized(_entry("P1", "c-b", 2500)) b.add_realized(_entry("P1", "c-a", 1000)) b.add_realized(_entry("P2", "c-c", 4000)) pa, pb = tmp_path / "a.json", tmp_path / "b.json" a.save(str(pa)) b.save(str(pb)) assert pa.read_text(encoding="utf-8") == pb.read_text(encoding="utf-8") # Round-trips: load() reconstructs an equivalent ledger with the same totals. reloaded = SavingsLedger.load(str(pa)) assert reloaded.portfolio_total() == 7500