"""IMP-47B u5 — PARTIAL_OVERRIDES apply tests. Scope (this slice): Helper ``_apply_ai_repair_proposals_to_zones`` (src/phase_z2_pipeline.py) merges ``proposal.payload.slots`` into ``zones_data[k]["slot_payload"]`` for PARTIAL_OVERRIDES proposals only, and loud-fails out-of-scope proposal kinds (builder_options_patch, slot_mapping_proposal) with an explicit ``apply_status`` marker. The IMP-33 u5 validator inside ``route_ai_fallback`` already enforces declared-slot completeness — the apply helper is therefore a structural merge over the validator's contract, not a per-slot guard re-implementation. u6 (step12_ai_repair.json audit), u7 (coverage invariant), and u8 (slide_status surfacing) are out of scope for this unit. """ from __future__ import annotations from src.phase_z2_pipeline import _apply_ai_repair_proposals_to_zones def _record( *, unit_index: int, proposal: dict | None, source_section_ids: list[str] | None = None, ) -> dict: """Synthetic gather_step12_ai_repair_proposals record.""" return { "unit_index": unit_index, "source_section_ids": source_section_ids or [f"MOCK_S{unit_index}"], "frame_template_id": "MOCK_T", "label": "reject", "route_hint": "ai_adaptation_required", "provisional": True, "ai_called": proposal is not None, "skip_reason": None, "proposal": proposal, "error": None, "cache_key": "MOCK_F::abc" if proposal is not None else None, "fingerprints": {"contract_sha": "x", "partial_sha": "y", "catalog_sha": ""} if proposal is not None else None, } def _zone(*, position: str, slot_payload: dict | None = None) -> dict: """Synthetic zones_data entry — only fields the apply helper touches.""" return { "position": position, "template_id": "MOCK_T", "slot_payload": slot_payload if slot_payload is not None else {}, } # ─── Case 1 : PARTIAL_OVERRIDES → merged + applied marker ────────── def test_partial_overrides_merges_slots_into_zone_slot_payload(): """The validator already guarantees declared-slot completeness, so apply is a structural ``dict.update``. Pre-existing meta keys (``_truncated_count``) survive; declared slot values are replaced by the AI proposal values.""" proposal = { "proposal_kind": "partial_overrides", "payload": { "slots": { "title": "AI title", "bullets": ["AI bullet 1", "AI bullet 2"], } }, "rationale": "MOCK", } records = [_record(unit_index=0, proposal=proposal)] zones = [ _zone( position="top", slot_payload={ "title": "deterministic title", "bullets": ["det bullet"], "_truncated_count": 0, }, ) ] _apply_ai_repair_proposals_to_zones(records, ["top"], zones) assert records[0]["apply_status"] == "applied:partial_overrides" assert zones[0]["slot_payload"]["title"] == "AI title" assert zones[0]["slot_payload"]["bullets"] == ["AI bullet 1", "AI bullet 2"] # meta keys not in proposal must survive the merge assert zones[0]["slot_payload"]["_truncated_count"] == 0 # ─── Case 2 : BUILDER_OPTIONS_PATCH → loud-fail unsupported_kind ─── def test_builder_options_patch_is_unsupported_for_reject_route(): """Builder-options application is out-of-scope for IMP-47B reject route (see Stage 2 plan). u5 must mark, not apply — the zone slot_payload stays byte-identical and the record carries the ``unsupported_kind_for_reject_route:`` marker so u8 can surface human_review downstream.""" proposal = { "proposal_kind": "builder_options_patch", "payload": {"font_size_px": 14}, "rationale": "MOCK", } records = [_record(unit_index=0, proposal=proposal)] original_slot_payload = {"title": "deterministic"} zones = [_zone(position="top", slot_payload=dict(original_slot_payload))] _apply_ai_repair_proposals_to_zones(records, ["top"], zones) assert ( records[0]["apply_status"] == "unsupported_kind_for_reject_route:builder_options_patch" ) assert zones[0]["slot_payload"] == original_slot_payload # ─── Case 3 : SLOT_MAPPING_PROPOSAL → loud-fail unsupported_kind ─── def test_slot_mapping_proposal_is_unsupported_for_reject_route(): """Slot-mapping (restructuring) application is also out-of-scope — builder-options + slot-mapping share the same marker path.""" proposal = { "proposal_kind": "slot_mapping_proposal", "payload": {"slots": {"title": "x"}}, "rationale": "MOCK", } records = [_record(unit_index=0, proposal=proposal)] zones = [_zone(position="top", slot_payload={"title": "deterministic"})] _apply_ai_repair_proposals_to_zones(records, ["top"], zones) assert ( records[0]["apply_status"] == "unsupported_kind_for_reject_route:slot_mapping_proposal" ) assert zones[0]["slot_payload"] == {"title": "deterministic"} # ─── Case 4 : no proposal (router short-circuit / not_provisional) ── def test_record_without_proposal_marked_no_proposal_and_zone_untouched(): """Flag-off short-circuit and non-AI-route units carry ``proposal=None``. apply_status must distinguish "no proposal to apply" from real apply outcomes so u8 can categorise the per-unit status without re-reading skip_reason.""" records = [_record(unit_index=0, proposal=None)] zones = [_zone(position="top", slot_payload={"title": "deterministic"})] _apply_ai_repair_proposals_to_zones(records, ["top"], zones) assert records[0]["apply_status"] == "no_proposal" assert zones[0]["slot_payload"] == {"title": "deterministic"} # ─── Case 5 : proposal exists but no matching zone (B4 mismatch) ──── def test_proposal_for_unit_without_zone_match_marked_no_zone_match(): """When a unit is dropped from zones_data (B4 mismatch or FitError in the Step 12 render loop) but still gathered an AI proposal, apply must surface the mismatch via ``no_zone_match`` rather than silently dropping the proposal or writing into a wrong zone.""" proposal = { "proposal_kind": "partial_overrides", "payload": {"slots": {"title": "AI title"}}, "rationale": "MOCK", } records = [_record(unit_index=0, proposal=proposal)] # unit_positions[0]="top" but zones_data has only the bottom zone # → no match for the dropped unit's position. zones = [_zone(position="bottom", slot_payload={"title": "other zone"})] _apply_ai_repair_proposals_to_zones(records, ["top"], zones) assert records[0]["apply_status"] == "no_zone_match" # untouched zone — apply must not bleed into a different position assert zones[0]["slot_payload"] == {"title": "other zone"} # ─── Case 6 : mixed records — independent per-record classification ── def test_mixed_records_classified_independently(): """All five apply_status branches coexist in one batch — confirms the helper does not short-circuit on the first non-applied record.""" records = [ _record(unit_index=0, proposal={ "proposal_kind": "partial_overrides", "payload": {"slots": {"title": "AI"}}, "rationale": "", }), _record(unit_index=1, proposal={ "proposal_kind": "builder_options_patch", "payload": {"font_size_px": 14}, "rationale": "", }), _record(unit_index=2, proposal=None), ] zones = [ _zone(position="top", slot_payload={"title": "det"}), _zone(position="middle", slot_payload={"title": "det"}), _zone(position="bottom", slot_payload={"title": "det"}), ] _apply_ai_repair_proposals_to_zones( records, ["top", "middle", "bottom"], zones, ) assert [r["apply_status"] for r in records] == [ "applied:partial_overrides", "unsupported_kind_for_reject_route:builder_options_patch", "no_proposal", ] assert zones[0]["slot_payload"]["title"] == "AI" assert zones[1]["slot_payload"]["title"] == "det" assert zones[2]["slot_payload"]["title"] == "det"