110 lines
4.5 KiB
Python
110 lines
4.5 KiB
Python
"""Tests for the backend profiles (D2) — now wired (Fase 2), no longer skeletons."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from agent_framework import BaseChatClient
|
|
|
|
from portfolio_optimiser.backends import (
|
|
AzureFoundryBackend,
|
|
ChatBackend,
|
|
LocalBackend,
|
|
Profile,
|
|
get_backend,
|
|
resolve_model,
|
|
)
|
|
|
|
# A complete, non-placeholder map for the PORTFOLIO_MODEL_MAP override (S4.1). Both blocks are
|
|
# required — resolve_model does no structural validation, so an azure-only map would break local.
|
|
_VALID_MAP = {
|
|
"local": {"default": "qwen3:4b", "proposer": "qwen3:4b", "checker": "qwen3:4b"},
|
|
"azure": {"default": "gpt-4o-mini", "proposer": "gpt-4o-mini", "checker": "gpt-4o-mini"},
|
|
}
|
|
|
|
|
|
def _write_map(tmp_path: Path, data: dict) -> Path:
|
|
p = tmp_path / "model_map.json"
|
|
p.write_text(json.dumps(data), encoding="utf-8")
|
|
return p
|
|
|
|
|
|
def test_get_backend_by_string() -> None:
|
|
assert isinstance(get_backend("azure"), AzureFoundryBackend)
|
|
assert isinstance(get_backend("local"), LocalBackend)
|
|
|
|
|
|
def test_get_backend_by_enum() -> None:
|
|
assert get_backend(Profile.AZURE).profile is Profile.AZURE
|
|
assert get_backend(Profile.LOCAL).profile is Profile.LOCAL
|
|
|
|
|
|
def test_backends_satisfy_seam() -> None:
|
|
# Structural conformance to the ChatBackend Protocol (the D2 seam).
|
|
assert isinstance(get_backend("azure"), ChatBackend)
|
|
assert isinstance(get_backend("local"), ChatBackend)
|
|
|
|
|
|
def test_unknown_profile_fails_fast() -> None:
|
|
with pytest.raises(ValueError):
|
|
get_backend("on-prem")
|
|
|
|
|
|
def test_local_backend_returns_client_no_network(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Construction is offline (no network); the default base_url is loopback.
|
|
monkeypatch.delenv("PORTFOLIO_LOCAL_BASE_URL", raising=False)
|
|
client = get_backend("local").create_chat_client(model="qwen3:4b")
|
|
assert isinstance(client, BaseChatClient)
|
|
|
|
|
|
def test_azure_backend_fails_fast_without_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Fail-fast (no silent default endpoint) — the operator must supply the Foundry endpoint.
|
|
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
|
|
with pytest.raises(ValueError):
|
|
get_backend("azure").create_chat_client(model="dummy-deployment")
|
|
|
|
|
|
def test_azure_backend_constructs_with_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# S4.1: FoundryChatClient REQUIRES an explicit credential (verified: pinned agent-framework-
|
|
# foundry 1.8.2 raises ValueError without it). The backend now supplies a lazy
|
|
# AzureCliCredential — construction succeeds OFFLINE (no token acquired until first use).
|
|
# Detach point: drop credential= in create_chat_client → construction raises → RED.
|
|
monkeypatch.setenv(
|
|
"PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT",
|
|
"https://x.services.ai.azure.com/api/projects/p",
|
|
)
|
|
client = get_backend("azure").create_chat_client(model="gpt-4o-mini")
|
|
assert isinstance(client, BaseChatClient)
|
|
|
|
|
|
def test_model_map_resolves_role_to_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
|
|
assert resolve_model("local", "proposer") == "qwen3:4b"
|
|
# Unknown role falls back to the profile default (still a non-empty id).
|
|
assert resolve_model(Profile.LOCAL, "no-such-role")
|
|
|
|
|
|
def test_resolve_model_azure_placeholder_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# SC4: the bundled azure block ships REPLACE-WITH-* placeholders → never build a client from
|
|
# one. Detach point: remove the _PLACEHOLDER_PREFIX guard in resolve_model → this goes GREEN
|
|
# (returns the placeholder) → RED here.
|
|
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
|
|
with pytest.raises(ValueError, match="placeholder"):
|
|
resolve_model("azure", "proposer")
|
|
|
|
|
|
def test_resolve_model_override_redirects_azure(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# SC5: PORTFOLIO_MODEL_MAP points at an out-of-tree valid map → resolves the real id (control:
|
|
# the raise fires only on the placeholder, not always). The committed model_map.json is never
|
|
# touched.
|
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
|
|
assert resolve_model("azure", "proposer") == "gpt-4o-mini"
|
|
assert resolve_model("local", "proposer") == "qwen3:4b"
|
|
|
|
|
|
def test_resolve_model_override_missing_file_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", "/nonexistent/model_map.json")
|
|
with pytest.raises(FileNotFoundError):
|
|
resolve_model("azure", "proposer")
|