174 lines
7.1 KiB
Python
174 lines
7.1 KiB
Python
"""S5.4 — verdirapport unit tests: roll-up correctness (SC1), exact NOK (SC2), determinism (SC6).
|
|
|
|
In-process build over a hand-constructed ``SavingsLedger``; local ``_entry(...)`` factory (copied
|
|
from ``tests/test_ledger.py:22-37``). The roll-up must call the ledger accessors
|
|
(``per_project_total`` / ``portfolio_total`` / ``overlaps``), never re-sum ``.entries`` (double-count
|
|
risk on cross-dimension overlaps).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger, stamp
|
|
from portfolio_optimiser.value_report import (
|
|
ProvenanceLine,
|
|
build_value_report,
|
|
dump_report_json,
|
|
format_report_text,
|
|
)
|
|
|
|
_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 _mixed_ledger() -> SavingsLedger:
|
|
""">=2 projects, >=2 dimensions, one cross-dimension overlap (the same candidate ``c-a`` realized
|
|
under both ``energi`` and ``asfalt`` in P1 -> counted ONCE by the deduped total, FLAGGED)."""
|
|
led = SavingsLedger()
|
|
led.add_realized(_entry("P1", "c-a", 1000, dimension="energi"))
|
|
led.add_realized(_entry("P1", "c-a", 1000, dimension="asfalt")) # overlap: same candidate id
|
|
led.add_realized(_entry("P1", "c-b", 2500, dimension="energi"))
|
|
led.add_realized(_entry("P2", "c-c", 4000, dimension="energi"))
|
|
return led
|
|
|
|
|
|
def test_build_value_report_matches_ledger_accessors() -> None:
|
|
"""SC1: per-project + portfolio totals equal the ledger accessors; ``overlaps`` equals
|
|
``ledger.overlaps()`` (the cross-dimension overlap counted ONCE, flagged)."""
|
|
led = _mixed_ledger()
|
|
report = build_value_report(led)
|
|
|
|
for pid in {e.project_id for e in led.entries}:
|
|
assert report.per_project[pid] == led.per_project_total(pid)
|
|
assert report.portfolio_total_ore == led.portfolio_total()
|
|
assert report.overlaps == led.overlaps()
|
|
# overlap counted once: P1 = c-a (1000, once across 2 dims) + c-b (2500) = 3500
|
|
assert report.per_project["P1"] == 3500
|
|
assert report.per_project["P2"] == 4000
|
|
assert report.overlaps == [("P1", "c-a")]
|
|
|
|
|
|
def test_provenance_line_per_entry_verbatim_and_deterministic() -> None:
|
|
"""SC1 (Revision #9): ``.provenance`` carries one ``ProvenanceLine`` per ledger entry with
|
|
verbatim ``verdict_id`` + ``provenance`` string, deterministically ordered."""
|
|
led = _mixed_ledger()
|
|
report = build_value_report(led)
|
|
|
|
assert len(report.provenance) == len(led.entries)
|
|
for line in report.provenance:
|
|
assert isinstance(line, ProvenanceLine)
|
|
|
|
by_key = {(m.project_id, m.dimension, m.candidate_identity): m for m in report.provenance}
|
|
for e in led.entries:
|
|
line = by_key[(e.project_id, e.dimension, e.candidate_identity)]
|
|
assert line.verdict_id == e.verdict_id # verbatim, never re-minted
|
|
assert line.provenance == e.provenance # entry's own string, no verdict-store join
|
|
assert line.amount_ore == e.amount_ore
|
|
|
|
# deterministically ordered: same entries in a different insertion order -> identical provenance
|
|
shuffled = SavingsLedger(entries=list(reversed(led.entries)))
|
|
assert build_value_report(shuffled).provenance == report.provenance
|
|
|
|
|
|
# --- Step 2: text + JSON formatters (exact NOK bytes SC2, byte-determinism SC6) -------------------
|
|
|
|
|
|
def test_format_report_text_exact_nok_bytes() -> None:
|
|
"""SC2: a portfolio total of ``1234567`` øre renders the EXACT Norwegian NOK bytes
|
|
``"12\xa0345,67\xa0kr"`` (non-breaking-space thousands + before ``kr``, mirroring
|
|
``test_costsim.py:95``), and the empty ledger's ``0`` total renders ``"0,00\xa0kr"``."""
|
|
led = SavingsLedger(entries=[_entry("P1", "c-a", 1234567)])
|
|
text = format_report_text(build_value_report(led))
|
|
assert "12\xa0345,67\xa0kr" in text
|
|
|
|
empty = format_report_text(build_value_report(SavingsLedger()))
|
|
assert "0,00\xa0kr" in empty # valid rc-0 zero-savings output, not an error
|
|
|
|
|
|
def _sc6_entries() -> list[LedgerEntry]:
|
|
"""A determinism fixture that INCLUDES a full-``_storage_key``-duplicate pair (``c-dup`` under
|
|
``(P1, energi)`` twice, differing only in ``verdict_id``/``provenance``) — constructible ONLY by
|
|
direct ``SavingsLedger(entries=[...])`` (``add_realized`` would dedup the second). This exercises
|
|
the TOTAL provenance sort from Step 1: a triple-only sort key would TIE the pair and let
|
|
insertion order flip the bytes. Also carries a genuine cross-dimension overlap (``c-ov`` under
|
|
both ``energi`` and ``asfalt``) so ``overlaps`` is non-empty."""
|
|
return [
|
|
LedgerEntry(
|
|
project_id="P1",
|
|
dimension="energi",
|
|
candidate_identity="c-dup",
|
|
amount_ore=1000,
|
|
verdict_id="v-a",
|
|
provenance="prov-a",
|
|
),
|
|
LedgerEntry(
|
|
project_id="P1",
|
|
dimension="energi",
|
|
candidate_identity="c-dup",
|
|
amount_ore=1000,
|
|
verdict_id="v-b",
|
|
provenance="prov-b", # full-key dup of the above
|
|
),
|
|
LedgerEntry(
|
|
project_id="P1",
|
|
dimension="energi",
|
|
candidate_identity="c-ov",
|
|
amount_ore=3000,
|
|
verdict_id="v-c",
|
|
provenance="prov-c",
|
|
),
|
|
LedgerEntry(
|
|
project_id="P1",
|
|
dimension="asfalt",
|
|
candidate_identity="c-ov",
|
|
amount_ore=3000,
|
|
verdict_id="v-d",
|
|
provenance="prov-d", # cross-dimension overlap
|
|
),
|
|
LedgerEntry(
|
|
project_id="P2",
|
|
dimension="energi",
|
|
candidate_identity="c-c",
|
|
amount_ore=4000,
|
|
verdict_id="v-e",
|
|
provenance="prov-e",
|
|
),
|
|
]
|
|
|
|
|
|
def test_output_is_byte_deterministic_regardless_of_order() -> None:
|
|
"""SC6: the same entries inserted in a different order -> byte-identical ``format_report_text``
|
|
AND ``dump_report_json`` output. The full-key duplicate pair exercises the total provenance
|
|
sort; ``overlaps`` tuples serialize to JSON lists (asserted on the parse-back)."""
|
|
entries = _sc6_entries()
|
|
a = build_value_report(SavingsLedger(entries=list(entries)))
|
|
b = build_value_report(SavingsLedger(entries=list(reversed(entries))))
|
|
|
|
assert format_report_text(a).encode() == format_report_text(b).encode()
|
|
assert dump_report_json(a).encode() == dump_report_json(b).encode()
|
|
|
|
payload = json.loads(dump_report_json(a))
|
|
assert payload["portfolio_total_ore"] == 8000 # c-dup 1000 + c-ov 3000 (once) + P2 4000
|
|
assert payload["per_project"] == {"P1": 4000, "P2": 4000}
|
|
assert isinstance(payload["overlaps"], list) and payload["overlaps"] # non-empty
|
|
for ov in payload["overlaps"]:
|
|
assert isinstance(ov, list) # tuple serialized as a JSON array
|
|
assert ["P1", "c-ov"] in payload["overlaps"]
|