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