105 lines
3.7 KiB
Python
105 lines
3.7 KiB
Python
"""Step 11 tests — fail-fast contract loaders (malformed config raises at startup, no LLM).
|
|
|
|
Every malformed contract raises a ValidationError at load — and, critically, no chat-client
|
|
is ever touched (the validation is pure config-layer, ordered before any backend exists).
|
|
Pattern: tests/test_backends.py (raises) + FakeChatClient.call_count spy.
|
|
"""
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
from spikes._harness import FakeChatClient
|
|
|
|
from portfolio_optimiser.contracts import (
|
|
Contracts,
|
|
GoalConfig,
|
|
GoalContract,
|
|
load_contracts,
|
|
load_goal_config,
|
|
)
|
|
|
|
_DS = {"docs_dir": "docs", "top_k": 3}
|
|
_TERM = {"max_rounds": 3, "max_tokens": 10000}
|
|
_FB = {"decision": "approved", "rationale": "feasible within range"}
|
|
|
|
|
|
def test_valid_contracts_load() -> None:
|
|
contracts = load_contracts(_DS, _TERM, _FB)
|
|
assert isinstance(contracts, Contracts)
|
|
assert contracts.model_map.local["default"] # bundled model_map validated
|
|
|
|
|
|
def test_malformed_data_source_raises() -> None:
|
|
with pytest.raises(ValidationError):
|
|
load_contracts({"docs_dir": "docs", "top_k": 0}, _TERM, _FB) # top_k must be > 0
|
|
|
|
|
|
def test_malformed_model_map_raises() -> None:
|
|
bad_map = {
|
|
"local": {"proposer": "qwen3:4b"},
|
|
"azure": {"default": "x"},
|
|
} # local missing 'default'
|
|
with pytest.raises(ValidationError):
|
|
load_contracts(_DS, _TERM, _FB, model_map=bad_map)
|
|
|
|
|
|
def test_malformed_termination_raises() -> None:
|
|
with pytest.raises(ValidationError):
|
|
load_contracts(_DS, {"max_rounds": 0, "max_tokens": 10000}, _FB) # unbounded
|
|
|
|
|
|
def test_malformed_feedback_raises() -> None:
|
|
with pytest.raises(ValidationError):
|
|
load_contracts(_DS, _TERM, {"decision": "maybe", "rationale": "x"}) # not a literal
|
|
|
|
|
|
def test_no_chat_client_call_on_malformed_contract() -> None:
|
|
spy = FakeChatClient()
|
|
with pytest.raises(ValidationError):
|
|
load_contracts({"docs_dir": "docs", "top_k": -1}, _TERM, _FB)
|
|
assert spy.call_count == 0 # contract validation never constructs or calls a client
|
|
|
|
|
|
# --- Step 7: configurable goal contract + standalone fail-fast loader ----------------------------
|
|
|
|
|
|
def test_goal_contract_hard_and_soft_validate() -> None:
|
|
hard = GoalContract(absolute_ore=500000)
|
|
assert hard.mode == "hard" # default mode
|
|
soft = GoalContract(percent=25.0, mode="soft")
|
|
assert soft.mode == "soft"
|
|
assert soft.percent == 25.0
|
|
|
|
|
|
def test_goal_contract_requires_a_target() -> None:
|
|
"""Neither absolute_ore nor percent -> ValidationError (a goal with no target is meaningless)."""
|
|
with pytest.raises(ValidationError):
|
|
GoalContract()
|
|
|
|
|
|
def test_goal_config_roundtrips_portfolio_and_per_project() -> None:
|
|
cfg = GoalConfig(
|
|
portfolio=GoalContract(absolute_ore=1_000_000),
|
|
per_project={"P1": GoalContract(percent=10.0, mode="soft")},
|
|
)
|
|
assert cfg.portfolio is not None and cfg.portfolio.absolute_ore == 1_000_000
|
|
assert cfg.per_project["P1"].mode == "soft"
|
|
# dict round-trip via model_validate preserves both levels
|
|
reparsed = GoalConfig.model_validate(cfg.model_dump())
|
|
assert reparsed.per_project["P1"].percent == 10.0
|
|
|
|
|
|
def test_load_goal_config_fail_fast_on_missing_file(tmp_path) -> None:
|
|
with pytest.raises(FileNotFoundError):
|
|
load_goal_config(str(tmp_path / "nope.json"))
|
|
|
|
|
|
def test_load_goal_config_parses_valid_file(tmp_path) -> None:
|
|
p = tmp_path / "goals.json"
|
|
p.write_text(
|
|
'{"portfolio": {"absolute_ore": 750000, "mode": "hard"},'
|
|
' "per_project": {"P2": {"percent": 5.0}}}',
|
|
encoding="utf-8",
|
|
)
|
|
cfg = load_goal_config(str(p))
|
|
assert cfg.portfolio is not None and cfg.portfolio.absolute_ore == 750000
|
|
assert cfg.per_project["P2"].percent == 5.0
|