feat(s51): fail-fast self-contained routing config loader

Gate: pytest tests/test_hitl.py -k routing_config → 6 passed.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 19:35:31 +02:00
commit e9179271e6
2 changed files with 121 additions and 0 deletions

View file

@ -15,6 +15,9 @@ from __future__ import annotations
import json
from pathlib import Path
import pytest
from pydantic import ValidationError
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.outbox import write_outbox
from portfolio_optimiser.provenance import Citation, ProvenanceStamp
@ -138,3 +141,75 @@ def test_hitl_registered_maf_free() -> None:
from tests.test_okf import _MAF_FREE_MODULES
assert "hitl.py" in _MAF_FREE_MODULES
# --- load_routing_config() fail-fast ---------------------------------------------------------------
def _write_config(tmp_path: Path, data: dict) -> Path:
p = tmp_path / "routing-config.json"
p.write_text(json.dumps(data), encoding="utf-8")
return p
_WELLFORMED_CONFIG = {
"_note": "ignored underscore key",
"entries": [
{
"id": "energi",
"allowed_measure_types": ["scope_reduction"],
"allowed_code_prefixes": ["05"],
"expert": "Ola Energi",
},
{"id": "vei", "allowed_code_prefixes": ["03"], "expert": "Kari Vei"},
],
}
def test_routing_config_loads_wellformed(tmp_path: Path) -> None:
"""A well-formed config loads: entries + experts present, ``_``-prefixed top-level keys stripped."""
config = hitl.load_routing_config(_write_config(tmp_path, _WELLFORMED_CONFIG))
assert [e.id for e in config.entries] == ["energi", "vei"]
assert {e.expert for e in config.entries} == {"Ola Energi", "Kari Vei"}
assert config.entries[1].allowed_measure_types == frozenset() # empty default = "any measure"
def test_routing_config_missing_expert_raises(tmp_path: Path) -> None:
"""An entry missing the required ``expert`` field fails validation."""
bad = {"entries": [{"id": "energi", "allowed_code_prefixes": ["05"]}]}
with pytest.raises(ValidationError):
hitl.load_routing_config(_write_config(tmp_path, bad))
def test_routing_config_duplicate_id_raises(tmp_path: Path) -> None:
"""Two entries with the same ``id`` raise (ValueError via the after-validator)."""
dup = {
"entries": [
{"id": "energi", "expert": "A"},
{"id": "energi", "expert": "B"},
]
}
with pytest.raises(ValueError):
hitl.load_routing_config(_write_config(tmp_path, dup))
def test_routing_config_wrong_types_raise(tmp_path: Path) -> None:
"""A non-list ``entries`` (wrong shape) raises."""
with pytest.raises(ValidationError):
hitl.load_routing_config(_write_config(tmp_path, {"entries": {"id": "x", "expert": "y"}}))
def test_routing_config_missing_file_raises(tmp_path: Path) -> None:
"""A missing config file fails fast with ``FileNotFoundError`` (mirrors ``load_pricing``)."""
with pytest.raises(FileNotFoundError):
hitl.load_routing_config(tmp_path / "no-such-routing-config.json")
def test_routing_config_valid_variant_loads(tmp_path: Path) -> None:
"""Control: a minimal valid config (single entry, no code/measure constraints) loads — proving
the raises above fire only on the bad case, not always."""
config = hitl.load_routing_config(
_write_config(tmp_path, {"entries": [{"id": "generalist", "expert": "Per"}]})
)
assert config.entries[0].expert == "Per"
assert config.entries[0].allowed_code_prefixes == frozenset()