feat(fase1): typed savings ledger with deterministic provenance (F1)
This commit is contained in:
parent
d2029964cc
commit
13905193f7
2 changed files with 197 additions and 0 deletions
101
tests/test_ledger.py
Normal file
101
tests/test_ledger.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue