"""IMP-56 (#90) u5 — Step 12 ``text_overrides`` apply unit tests. Synthetic — exercises ``_apply_text_overrides_to_zones`` directly without running the full Phase Z 22-step pipeline. The helper is decoupled from ``MdxSection`` / ``CompositionUnit`` graphs and only consumes a minimal ``[{position, slot_payload}, ...]`` zone list, so a synthetic fixture is sufficient to lock the contract. Coverage axes (Stage 2 plan u5 + Stage 1 binding contract) : - sanitized batch : malformed text_path / non-string value drops per-row (mirrors ``image_id_stamper.build_image_overrides_style`` u7 tolerance). - stale path : frame swap / layout regression → ``skipped``, not error. - raw_content preservation : helper never touches ``debug_zones`` / unit graph (asserted by zero-mutation on an out-of-band sentinel mapping). - audit shape : ``applied`` / ``skipped`` / ``per_zone`` keys present and counts consistent with the input batch. - empty / ``None`` override input is a no-op (empty audit). Fully synthetic per Codex generalization guardrail (MOCK_ prefix). NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml`` dependency. """ from __future__ import annotations import pytest from src.phase_z2_pipeline import _apply_text_overrides_to_zones # ─── Synthetic fixture helpers ────────────────────────────────────── def _zone(position: str, slot_payload: dict) -> dict: """Minimal zone dict mirroring the Step 12 ``zones_data[i]`` shape.""" return { "position": position, "template_id": "MOCK_T_phase_z2_text_overrides", "slot_payload": slot_payload, "content_weight": 1.0, "min_height_px": 200, } # ─── Case 1 : happy path — list[str] slot mutation ──────────────────── def test_apply_replaces_list_line_in_place(): zones = [ _zone("top", {"slot_title": ["original headline"], "slot_body": ["a", "b", "c"]}), _zone("bottom", {"slot_caption": ["caption A"]}), ] overrides = { "top": {"slot_title.0": "edited headline", "slot_body.1": "edited B"}, } audit = _apply_text_overrides_to_zones(overrides, zones) assert zones[0]["slot_payload"]["slot_title"] == ["edited headline"] assert zones[0]["slot_payload"]["slot_body"] == ["a", "edited B", "c"] # bottom zone untouched (no override entry) assert zones[1]["slot_payload"]["slot_caption"] == ["caption A"] assert audit["applied"] == 2 assert audit["skipped"] == 0 assert audit["per_zone"] == [{"position": "top", "applied": 2, "skipped": 0}] # ─── Case 2 : stale text_path — frame swap / layout regression ──────── def test_stale_text_path_skipped_silently(): """Absent slot_key + out-of-range line_index both count as skipped, not errors.""" zones = [_zone("top", {"slot_title": ["only one line"]})] overrides = { "top": { "slot_title.0": "ok", # applied "slot_title.99": "out of range", # skipped (idx > len) "slot_missing.0": "stale frame", # skipped (slot absent) }, } audit = _apply_text_overrides_to_zones(overrides, zones) assert zones[0]["slot_payload"]["slot_title"] == ["ok"] assert "slot_missing" not in zones[0]["slot_payload"] assert audit["applied"] == 1 assert audit["skipped"] == 2 assert audit["per_zone"][0] == {"position": "top", "applied": 1, "skipped": 2} # ─── Case 3 : malformed input — per-entry tolerance ──────────────────── def test_malformed_entries_dropped_in_validate(): """Non-string value / malformed text_path drop in ``validate_text_overrides``.""" zones = [_zone("top", {"slot_title": ["original", "second"]})] overrides = { "top": { "slot_title.0": "good", # kept "slot_title.bad": "ignored", # dropped (non-int idx) "slot_title.1": 123, # dropped (non-str value) "no_dot": "ignored", # dropped (missing '.') }, "": {"slot_title.0": "empty zone id dropped"}, # zone_id sanitization 123: {"slot_title.0": "non-string zone id dropped"}, } audit = _apply_text_overrides_to_zones(overrides, zones) # only the well-formed entry applied assert zones[0]["slot_payload"]["slot_title"] == ["good", "second"] assert audit["applied"] == 1 assert audit["skipped"] == 0 # ─── Case 4 : raw_content preservation invariant ────────────────────── def test_raw_content_sentinel_untouched(): """Helper must not mutate anything outside ``zone['slot_payload']``. Out-of-band fields (mirror of ``debug_zones[i].source_section_ids`` / MdxSection graph) stay byte-identical — Stage 1 binding contract. """ raw_sentinel = ["MOCK_S1", "MOCK_S2"] zones = [_zone("top", {"slot_title": ["original"]})] zones[0]["source_section_ids_sentinel"] = raw_sentinel # out-of-band zones[0]["raw_content_sentinel"] = "- original bullet\n- second\n" _apply_text_overrides_to_zones({"top": {"slot_title.0": "edited"}}, zones) assert zones[0]["source_section_ids_sentinel"] is raw_sentinel # same object assert zones[0]["source_section_ids_sentinel"] == ["MOCK_S1", "MOCK_S2"] assert zones[0]["raw_content_sentinel"] == "- original bullet\n- second\n" # ─── Case 5 : empty / None batch is no-op ────────────────────────────── @pytest.mark.parametrize("payload", [None, {}, {"top": {}}, {"missing_zone": {"slot.0": "x"}}]) def test_empty_or_irrelevant_batch_is_noop(payload): zones = [_zone("top", {"slot_title": ["unchanged"]})] audit = _apply_text_overrides_to_zones(payload, zones) assert zones[0]["slot_payload"]["slot_title"] == ["unchanged"] assert audit["applied"] == 0 assert audit["skipped"] == 0 # ─── Case 6 : zone without slot_payload skipped (defensive) ─────────── def test_zone_without_slot_payload_skipped(): zones = [{"position": "top"}] # no slot_payload key (defensive contract) audit = _apply_text_overrides_to_zones({"top": {"slot.0": "x"}}, zones) assert audit["per_zone"] == [] assert audit["applied"] == 0 assert audit["skipped"] == 0