feat(s54): --report/--json CLI mode in run.py over value_report

This commit is contained in:
Kjell Tore Guttormsen 2026-07-24 01:35:35 +02:00
commit 19000d89f6
2 changed files with 193 additions and 0 deletions

View file

@ -9,6 +9,7 @@ content so the dry-run reaches its offline return.
from __future__ import annotations
import json
from pathlib import Path
import pytest
@ -266,3 +267,126 @@ def test_verdict_dir_ingested_at_main_level_offline(tmp_path, capsys) -> None:
)
assert rc == 0
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
# --- S5.4: --report / --json read-only value-report mode ------------------------------------------
def _report_ledger(tmp_path: Path) -> Path:
"""A saved ledger with >=2 projects and one cross-dimension overlap (``c-a`` under both
``energi`` and ``asfalt`` in FV42 -> counted once, flagged), for the value-report arms.
portfolio_total = 1234567 (overlap once) + 500000 = 1734567 øre."""
led = SavingsLedger()
led.add_realized(
LedgerEntry(
project_id="FV42-GSV-E1",
dimension="energi",
candidate_identity="c-a",
amount_ore=1234567,
verdict_id="v1",
provenance="p1",
)
)
led.add_realized(
LedgerEntry(
project_id="FV42-GSV-E1",
dimension="asfalt",
candidate_identity="c-a",
amount_ore=1234567,
verdict_id="v2",
provenance="p2", # cross-dimension overlap on (FV42-GSV-E1, c-a)
)
)
led.add_realized(
LedgerEntry(
project_id="RV13-RAS-TP",
dimension="energi",
candidate_identity="c-b",
amount_ore=500000,
verdict_id="v3",
provenance="p3",
)
)
p = tmp_path / "ledger.json"
led.save(str(p))
return p
def test_report_prints_table_rc0(tmp_path, capsys) -> None:
"""SC3: ``--report --ledger <f>`` -> rc 0; stdout carries a per-project row + the portfolio-total
NOK string. Dispatched FIRST, so no PROJECT_ID/--docs-dir is needed (no single-project refusal)."""
rc = run.main(["--report", "--ledger", str(_report_ledger(tmp_path))])
out = capsys.readouterr().out
assert rc == 0
assert "FV42-GSV-E1" in out # a per-project row
assert "17\xa0345,67\xa0kr" in out # portfolio total 1734567 øre
def test_report_json_rc0_parses_rollup(tmp_path, capsys) -> None:
"""SC4: ``--report ... --json`` -> rc 0 and ``json.loads(stdout)`` yields the roll-up
(int portfolio total, per_project dict, overlaps as JSON lists, provenance list)."""
rc = run.main(["--report", "--ledger", str(_report_ledger(tmp_path)), "--json"])
out = capsys.readouterr().out
assert rc == 0
payload = json.loads(out)
assert payload["portfolio_total_ore"] == 1734567
assert payload["per_project"]["FV42-GSV-E1"] == 1234567
assert isinstance(payload["overlaps"], list)
assert ["FV42-GSV-E1", "c-a"] in payload["overlaps"] # tuple serialized as a JSON array
assert isinstance(payload["provenance"], list)
assert len(payload["provenance"]) == 3 # one ProvenanceLine per ledger entry
def test_report_missing_ledger_file_rc1(capsys) -> None:
"""SC5: ``--report --ledger /nonexistent`` -> rc 1, stderr non-empty, NO table on stdout (a load
failure must never masquerade as a real zero-savings result)."""
rc = run.main(["--report", "--ledger", "/nonexistent-ledger.json"])
cap = capsys.readouterr()
assert rc == 1
assert cap.err.strip()
assert cap.out == ""
def test_report_malformed_ledger_rc1(tmp_path, capsys) -> None:
"""SC5: a malformed-row ledger file -> rc 1 (``ValidationError`` surfaced as a refusal)."""
bad = tmp_path / "bad.json"
bad.write_text('[{"project_id": "P1"}]', encoding="utf-8") # missing required fields
rc = run.main(["--report", "--ledger", str(bad)])
assert rc == 1
assert "refused" in capsys.readouterr().err.lower()
def test_report_without_ledger_rc1_no_traceback(capsys) -> None:
"""Major-#1 guard: ``--report`` with no ``--ledger`` -> rc 1 with a 'requires --ledger' message
and no traceback (guards ``SavingsLedger.load(None)`` -> ``Path(None)`` TypeError)."""
rc = run.main(["--report"])
err = capsys.readouterr().err
assert rc == 1
assert "requires --ledger" in err
assert "Traceback" not in err
def test_report_with_portfolio_refuses(tmp_path, capsys) -> None:
"""Major-#3 partition: ``--report`` + ``--portfolio`` -> rc 1 (mode-exclusive)."""
rc = run.main(["--report", "--portfolio", "--ledger", str(_report_ledger(tmp_path))])
assert rc == 1
assert "refused" in capsys.readouterr().err.lower()
def test_report_with_goals_refuses(tmp_path, capsys) -> None:
"""Major-#3 / P2-2 allowlist: ``--report`` + ``--goals`` (a config flag) -> rc 1. The allowlist
rejects config flags too, not just the two mode flags else ``--goals`` would be silently
dropped, whereas bare ``--goals`` is refused (adding ``--report`` must not suppress a refusal)."""
goals = tmp_path / "goals.json"
goals.write_text('{"portfolio": {"absolute_ore": 1, "mode": "hard"}}', encoding="utf-8")
rc = run.main(["--report", "--goals", str(goals), "--ledger", str(_report_ledger(tmp_path))])
assert rc == 1
assert "refused" in capsys.readouterr().err.lower()
def test_json_without_report_refuses(tmp_path, capsys) -> None:
"""Major-#3 partition: ``--json`` without ``--report`` -> rc 1 (a stray --json is never silently
ignored)."""
rc = run.main(["--json", "--ledger", str(_report_ledger(tmp_path))])
assert rc == 1
assert "refused" in capsys.readouterr().err.lower()