Files

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")