101 lines
3.7 KiB
Python
101 lines
3.7 KiB
Python
"""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
|