SC2 second half: --verdict-dir had no main()-level test (exploration gap). New test drops one valid verdict into a tmp inbox and drives main([pid, --docs-dir, --bundle-dir, --verdict-dir, --live-dry-run]) -> rc 0: the inbox ingestion (load_verdicts_from_dir, run.py:287) runs before the dry-run cut (run.py:335), so the flag's wiring is exercised offline without raising. --bundle-dir's main()-level coverage already exists in test_live_dry_run.py and is referenced, not duplicated. run.py untouched (never re-wired). 11 passed in test_run_cli.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
268 lines
9.3 KiB
Python
268 lines
9.3 KiB
Python
"""S5.3 CLI-parity tests for ``run.main()`` single-project flags (Steps 2/4/5).
|
|
|
|
In-process ``run.main([argv])`` + rc + ``capsys`` substring asserts (never subprocess),
|
|
mirroring ``tests/test_live_dry_run.py``. Every arm is offline — it stops before the first
|
|
model call (``debate.run``), so no socket/network is exercised (brief NFR). The bundle
|
|
fixture ``shared/examples/bygg-energi-mikro`` (project ``BYGG-KONTOR-NORD``) supplies citable
|
|
content so the dry-run reaches its offline return.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from portfolio_optimiser import run
|
|
from portfolio_optimiser.dimension import Dimension
|
|
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger
|
|
from portfolio_optimiser.verdicts import ProposalFeatures, capture_verdict, write_verdict
|
|
|
|
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
|
_PID = "BYGG-KONTOR-NORD"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Hermetic env (verbatim from ``test_live_dry_run.py``): clear the S4.1 out-of-tree overrides
|
|
so these CLI assertions read the BUNDLED map/config, not the operator's Foundry environment."""
|
|
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
|
|
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
|
|
|
|
|
|
def _write_dimension(tmp_path: Path) -> Path:
|
|
"""A valid dimension scope config on disk (loaded fail-fast by ``load_dimension``)."""
|
|
dim = Dimension(
|
|
id="energi",
|
|
label="Energi",
|
|
allowed_measure_types=frozenset({"energy_efficiency"}),
|
|
)
|
|
p = tmp_path / "dim.json"
|
|
p.write_text(dim.model_dump_json(), encoding="utf-8")
|
|
return p
|
|
|
|
|
|
# --- Step 2: --dimension-config / --outbox-dir / --run-id wiring + structured refusal -------------
|
|
|
|
|
|
def test_dimension_config_flag_parses_offline(tmp_path, capsys) -> None:
|
|
"""(a) ``--dimension-config <valid>`` + ``--live-dry-run`` → rc 0 (flag parsed, loader invoked,
|
|
offline — stops before any model call)."""
|
|
rc = run.main(
|
|
[
|
|
_PID,
|
|
"--docs-dir",
|
|
str(BUNDLE_DIR),
|
|
"--bundle-dir",
|
|
str(BUNDLE_DIR),
|
|
"--dimension-config",
|
|
str(_write_dimension(tmp_path)),
|
|
"--live-dry-run",
|
|
]
|
|
)
|
|
assert rc == 0
|
|
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
|
|
|
|
|
|
def test_outbox_dir_with_run_id_writes_runconfig_offline(tmp_path) -> None:
|
|
"""(b) ``--outbox-dir`` + ``--run-id`` + ``--live-dry-run`` → rc 0 AND ``<tmp>/r1-runconfig.json``
|
|
written offline (via ``write_run_config``, before the dry-run return)."""
|
|
outbox = tmp_path / "out"
|
|
outbox.mkdir()
|
|
rc = run.main(
|
|
[
|
|
_PID,
|
|
"--docs-dir",
|
|
str(BUNDLE_DIR),
|
|
"--bundle-dir",
|
|
str(BUNDLE_DIR),
|
|
"--outbox-dir",
|
|
str(outbox),
|
|
"--run-id",
|
|
"r1",
|
|
"--live-dry-run",
|
|
]
|
|
)
|
|
assert rc == 0
|
|
assert (outbox / "r1-runconfig.json").is_file()
|
|
|
|
|
|
def test_outbox_dir_without_run_id_refuses(tmp_path, capsys) -> None:
|
|
"""(c) RED guard: ``--outbox-dir`` WITHOUT ``--run-id`` → rc 1 structured refusal. ``run_project``'s
|
|
step-0 fail-fast (no wall-clock default) surfaces through the CLI refusal wrapper, no traceback."""
|
|
outbox = tmp_path / "out"
|
|
outbox.mkdir()
|
|
rc = run.main(
|
|
[
|
|
_PID,
|
|
"--docs-dir",
|
|
str(BUNDLE_DIR),
|
|
"--outbox-dir",
|
|
str(outbox),
|
|
"--live-dry-run",
|
|
]
|
|
)
|
|
assert rc == 1
|
|
assert "refused" in capsys.readouterr().err.lower()
|
|
|
|
|
|
def test_dimension_config_missing_file_refuses(capsys) -> None:
|
|
"""(d) ``--dimension-config <nonexistent>`` → rc 1 structured refusal. ``load_dimension`` raises
|
|
``FileNotFoundError``, caught by the WIDENED dry-run handler (not just ``ValueError`` — Pass-2 #1)."""
|
|
rc = run.main(
|
|
[
|
|
_PID,
|
|
"--docs-dir",
|
|
str(BUNDLE_DIR),
|
|
"--dimension-config",
|
|
"/nonexistent-dim-config.json",
|
|
"--live-dry-run",
|
|
]
|
|
)
|
|
assert rc == 1
|
|
assert "refused" in capsys.readouterr().err.lower()
|
|
|
|
|
|
# --- Step 4: mode-exclusivity refusals + backward-compat pin -------------------------------------
|
|
|
|
|
|
def _met_portfolio_goal(tmp_path: Path) -> tuple[Path, Path]:
|
|
"""A portfolio-hard goal (1 øre) already met by a 1-øre ledger — so any RED (pre-refusal)
|
|
fall-through into the portfolio dispatch stops OFFLINE at the goal check (no client, no socket)."""
|
|
goals = tmp_path / "goals.json"
|
|
goals.write_text('{"portfolio": {"absolute_ore": 1, "mode": "hard"}}', encoding="utf-8")
|
|
led = SavingsLedger()
|
|
led.add_realized(
|
|
LedgerEntry(
|
|
project_id="FV42-GSV-E1",
|
|
dimension="energi",
|
|
candidate_identity="c1",
|
|
amount_ore=1,
|
|
verdict_id="v1",
|
|
provenance="x",
|
|
)
|
|
)
|
|
ledger = tmp_path / "ledger.json"
|
|
led.save(str(ledger))
|
|
return goals, ledger
|
|
|
|
|
|
def test_goals_without_portfolio_refuses(tmp_path, capsys) -> None:
|
|
"""(a) ``--goals`` without ``--portfolio`` → rc 1 refusal (the flag belongs to portfolio mode).
|
|
``--live-dry-run`` keeps the RED (pre-refusal) fall-through offline."""
|
|
goals = tmp_path / "goals.json"
|
|
goals.write_text('{"portfolio": {"absolute_ore": 1, "mode": "hard"}}', encoding="utf-8")
|
|
rc = run.main(
|
|
[
|
|
_PID,
|
|
"--docs-dir",
|
|
str(BUNDLE_DIR),
|
|
"--bundle-dir",
|
|
str(BUNDLE_DIR),
|
|
"--goals",
|
|
str(goals),
|
|
"--live-dry-run",
|
|
]
|
|
)
|
|
assert rc == 1
|
|
assert "refused" in capsys.readouterr().err.lower()
|
|
|
|
|
|
def test_ledger_without_portfolio_refuses(tmp_path, capsys) -> None:
|
|
"""(a') ``--ledger`` without ``--portfolio`` → rc 1 refusal."""
|
|
ledger = tmp_path / "ledger.json"
|
|
ledger.write_text("[]", encoding="utf-8")
|
|
rc = run.main(
|
|
[
|
|
_PID,
|
|
"--docs-dir",
|
|
str(BUNDLE_DIR),
|
|
"--bundle-dir",
|
|
str(BUNDLE_DIR),
|
|
"--ledger",
|
|
str(ledger),
|
|
"--live-dry-run",
|
|
]
|
|
)
|
|
assert rc == 1
|
|
assert "refused" in capsys.readouterr().err.lower()
|
|
|
|
|
|
def test_portfolio_with_single_project_flag_refuses(tmp_path, capsys) -> None:
|
|
"""(c) ``--portfolio`` combined with a single-project-only flag (``--outbox-dir``) → rc 1 refusal
|
|
naming the offending flag. The met portfolio goal keeps the RED fall-through offline."""
|
|
goals, ledger = _met_portfolio_goal(tmp_path)
|
|
rc = run.main(
|
|
[
|
|
"--portfolio",
|
|
"--goals",
|
|
str(goals),
|
|
"--ledger",
|
|
str(ledger),
|
|
"--outbox-dir",
|
|
str(tmp_path / "ob"),
|
|
]
|
|
)
|
|
assert rc == 1
|
|
err = capsys.readouterr().err.lower()
|
|
assert "refused" in err
|
|
assert "--outbox-dir" in err
|
|
|
|
|
|
def test_single_project_mode_without_docs_dir_refuses(capsys) -> None:
|
|
"""(b) single-project mode with a pid but no ``--docs-dir`` → rc 1 refusal (the Step-3
|
|
compensating guard for the relaxed argparse ``required=``)."""
|
|
rc = run.main([_PID])
|
|
assert rc == 1
|
|
assert "refused" in capsys.readouterr().err.lower()
|
|
|
|
|
|
def test_single_project_mode_without_pid_refuses(capsys) -> None:
|
|
"""(b') single-project mode with ``--docs-dir`` but no PROJECT_ID → rc 1 refusal."""
|
|
rc = run.main(["--docs-dir", str(BUNDLE_DIR)])
|
|
assert rc == 1
|
|
assert "refused" in capsys.readouterr().err.lower()
|
|
|
|
|
|
def test_legacy_single_project_invocation_still_succeeds(capsys) -> None:
|
|
"""Backward-compat pin: the legacy invocation (positional pid + ``--docs-dir`` + ``--bundle-dir``
|
|
+ ``--live-dry-run``) still returns rc 0 — the existing CLI contract survives Step 3's
|
|
``nargs='?'``/``required`` relaxation."""
|
|
rc = run.main(
|
|
[_PID, "--docs-dir", str(BUNDLE_DIR), "--bundle-dir", str(BUNDLE_DIR), "--live-dry-run"]
|
|
)
|
|
assert rc == 0
|
|
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
|
|
|
|
|
|
# --- Step 5: confirm coverage for the already-wired flags (never re-wired; run.py untouched) ------
|
|
|
|
|
|
def test_verdict_dir_ingested_at_main_level_offline(tmp_path, capsys) -> None:
|
|
"""Step 5 (SC2 second half): ``--verdict-dir`` is exercised at ``main()`` level — the previously
|
|
untested already-wired flag. The async inbox is ingested (``load_verdicts_from_dir``,
|
|
``run.py:287``) BEFORE the dry-run cut (``run.py:335``), so a dropped verdict is threaded through
|
|
``main()`` offline without raising. ``--bundle-dir``'s ``main()``-level coverage already exists
|
|
in ``tests/test_live_dry_run.py:32-49`` and is NOT re-tested here (never re-wired)."""
|
|
inbox = tmp_path / "inbox"
|
|
feats = ProposalFeatures(
|
|
affected_codes=frozenset({"ENERGI-TOTAL-EL"}),
|
|
measure_type="energy_efficiency",
|
|
claimed_saving_nok=30000.0,
|
|
description="LED-retrofit",
|
|
)
|
|
write_verdict(str(inbox), capture_verdict(feats, "approved", "expert reviewed (sim)"))
|
|
rc = run.main(
|
|
[
|
|
_PID,
|
|
"--docs-dir",
|
|
str(BUNDLE_DIR),
|
|
"--bundle-dir",
|
|
str(BUNDLE_DIR),
|
|
"--verdict-dir",
|
|
str(inbox),
|
|
"--live-dry-run",
|
|
]
|
|
)
|
|
assert rc == 0
|
|
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
|