"""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 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 `` + ``--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 ``/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 `` → 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()