"""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="
", 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_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")