238 lines
9.1 KiB
Python
238 lines
9.1 KiB
Python
"""IMP-33 u7 — AI fallback router tests.
|
|
|
|
Scope (Stage 2 plan, u7):
|
|
- flag-off gate returns None and does NOT touch the client / prompt
|
|
- route-mismatch gate returns None and does NOT touch the client / prompt
|
|
- cache-hit short-circuits the client and still re-validates against the
|
|
current frame contract (defence-in-depth)
|
|
- cache-miss calls the client and validates the returned proposal
|
|
- validation errors propagate
|
|
- budget / circuit exceptions from u4 propagate
|
|
- router never imports ``save_proposal`` (cache save is caller-driven
|
|
after visual_check + user_approved per u6 IMP-46 gate)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.phase_z2_ai_fallback import AiFallbackProposal, ProposalKind
|
|
from src.phase_z2_ai_fallback import router as router_mod
|
|
from src.phase_z2_ai_fallback.client import (
|
|
AiFallbackBudgetExceeded,
|
|
AiFallbackCircuitOpen,
|
|
AiFallbackClient,
|
|
)
|
|
from src.phase_z2_ai_fallback.router import route_ai_fallback
|
|
from src.phase_z2_ai_fallback.validate import AiFallbackValidationError
|
|
|
|
|
|
_FRAME_CONTRACT = {
|
|
"frame_id": 1171281190,
|
|
"sub_zones": [{"id": "pillar_1", "accepts": ["text_block"]}],
|
|
"payload": {"builder_options": {"item_parser": "pillar_item"}},
|
|
}
|
|
_REGION = {"id": "zone_top.region_a"}
|
|
_V4_AI = {
|
|
"route": "ai_adaptation_required",
|
|
"cardinality": "many",
|
|
"frame_id": 1171281190,
|
|
"rank": 1,
|
|
}
|
|
_V4_NOT_AI = {"route": "light_edit", "cardinality": "many"}
|
|
|
|
|
|
def _make_proposal(
|
|
kind: ProposalKind = ProposalKind.PARTIAL_OVERRIDES,
|
|
payload: dict | None = None,
|
|
) -> AiFallbackProposal:
|
|
return AiFallbackProposal(
|
|
proposal_kind=kind,
|
|
payload=payload if payload is not None else {"slots": {"pillar_1": "a"}},
|
|
)
|
|
|
|
|
|
def _call_kwargs() -> dict:
|
|
return dict(
|
|
cache_key="frame:1171281190:cardinality:many",
|
|
v4_result=_V4_AI,
|
|
frame_contract=_FRAME_CONTRACT,
|
|
frame_visual_html="<div></div>",
|
|
figma_partial_json={},
|
|
internal_region=_REGION,
|
|
mdx_text="# example\n- a\n- b",
|
|
)
|
|
|
|
|
|
def test_router_returns_none_when_flag_off(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", False)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
result = route_ai_fallback(**_call_kwargs(), client=client)
|
|
assert result is None
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_returns_none_when_route_not_ai_adaptation(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
kwargs = _call_kwargs()
|
|
kwargs["v4_result"] = _V4_NOT_AI
|
|
result = route_ai_fallback(**kwargs, client=client)
|
|
assert result is None
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_returns_cached_when_cache_hit(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
cached = _make_proposal()
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: cached)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
result = route_ai_fallback(**_call_kwargs(), client=client)
|
|
assert result is cached
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_validates_cached_proposal(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
bad_cached = AiFallbackProposal(
|
|
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
|
payload={"unknown_key": "x"},
|
|
)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: bad_cached)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
with pytest.raises(AiFallbackValidationError):
|
|
route_ai_fallback(**_call_kwargs(), client=client)
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_forwards_fingerprints_and_misses_on_mismatch(monkeypatch):
|
|
"""Scope: router-level (IMP-46 #62 Axis R u2).
|
|
|
|
When caller supplies ``fingerprints`` and the cache layer returns
|
|
``None`` (simulating a strict-equality mismatch against the stored
|
|
contract/partial/catalog SHA), the router proceeds to call the
|
|
client. The exact ``fingerprints`` dict supplied by the caller must
|
|
appear in the kwargs passed to ``read_proposal``.
|
|
"""
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
captured: dict = {}
|
|
|
|
def _spy_read_proposal(key, *, fingerprints=None):
|
|
captured["key"] = key
|
|
captured["fingerprints"] = fingerprints
|
|
return None
|
|
|
|
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
|
|
proposal = _make_proposal()
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
client.request_proposal.return_value = proposal
|
|
supplied = {"contract_sha": "aaa", "partial_sha": "bbb", "catalog_sha": "ccc"}
|
|
result = route_ai_fallback(
|
|
**_call_kwargs(), client=client, fingerprints=supplied
|
|
)
|
|
assert result is proposal
|
|
assert captured["fingerprints"] == supplied
|
|
client.request_proposal.assert_called_once()
|
|
|
|
|
|
def test_router_forwards_fingerprints_and_hits_on_match(monkeypatch):
|
|
"""Scope: router-level (IMP-46 #62 Axis R u2).
|
|
|
|
When caller supplies ``fingerprints`` and the cache layer returns a
|
|
cached proposal (simulating strict-equality match against stored
|
|
SHA bundle), the router short-circuits without calling the client.
|
|
The forwarded kwarg must equal the caller-supplied dict exactly.
|
|
"""
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
cached = _make_proposal()
|
|
captured: dict = {}
|
|
|
|
def _spy_read_proposal(key, *, fingerprints=None):
|
|
captured["fingerprints"] = fingerprints
|
|
return cached
|
|
|
|
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
supplied = {"contract_sha": "xxx", "partial_sha": "yyy", "catalog_sha": "zzz"}
|
|
result = route_ai_fallback(
|
|
**_call_kwargs(), client=client, fingerprints=supplied
|
|
)
|
|
assert result is cached
|
|
assert captured["fingerprints"] == supplied
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_forwards_fingerprints_none_for_legacy_callers(monkeypatch):
|
|
"""Scope: router-level (IMP-46 #62 Axis R u2).
|
|
|
|
Legacy callers that omit the ``fingerprints`` kwarg must result in
|
|
``read_proposal`` being invoked with ``fingerprints=None`` (cache
|
|
layer skips fingerprint comparison — legacy no-invalidation
|
|
behaviour preserved).
|
|
"""
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
cached = _make_proposal()
|
|
captured: dict = {}
|
|
|
|
def _spy_read_proposal(key, *, fingerprints=None):
|
|
captured["fingerprints"] = fingerprints
|
|
return cached
|
|
|
|
monkeypatch.setattr(router_mod, "read_proposal", _spy_read_proposal)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
result = route_ai_fallback(**_call_kwargs(), client=client)
|
|
assert result is cached
|
|
assert captured["fingerprints"] is None
|
|
client.request_proposal.assert_not_called()
|
|
|
|
|
|
def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
|
proposal = _make_proposal()
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
client.request_proposal.return_value = proposal
|
|
result = route_ai_fallback(**_call_kwargs(), client=client)
|
|
assert result is proposal
|
|
client.request_proposal.assert_called_once()
|
|
sent_prompt = client.request_proposal.call_args.args[0]
|
|
assert set(sent_prompt.keys()) == {"system", "user"}
|
|
|
|
|
|
def test_router_propagates_validation_error(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
|
bad = AiFallbackProposal(
|
|
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
|
payload={"unknown_key": "x"},
|
|
)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
client.request_proposal.return_value = bad
|
|
with pytest.raises(AiFallbackValidationError):
|
|
route_ai_fallback(**_call_kwargs(), client=client)
|
|
|
|
|
|
def test_router_propagates_budget_exceeded(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
client.request_proposal.side_effect = AiFallbackBudgetExceeded("over")
|
|
with pytest.raises(AiFallbackBudgetExceeded):
|
|
route_ai_fallback(**_call_kwargs(), client=client)
|
|
|
|
|
|
def test_router_propagates_circuit_open(monkeypatch):
|
|
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
|
monkeypatch.setattr(router_mod, "read_proposal", lambda key, **_: None)
|
|
client = MagicMock(spec=AiFallbackClient)
|
|
client.request_proposal.side_effect = AiFallbackCircuitOpen("tripped")
|
|
with pytest.raises(AiFallbackCircuitOpen):
|
|
route_ai_fallback(**_call_kwargs(), client=client)
|
|
|
|
|
|
def test_router_does_not_import_save_proposal():
|
|
"""Cache save is caller-driven AFTER visual_check + user_approved (u6 IMP-46
|
|
gate); structurally guaranteed by NOT importing save_proposal in the router."""
|
|
assert not hasattr(router_mod, "save_proposal")
|