Files
C.E.L_Slide_test2/tests/test_imp47b_payload_apply.py
kyeongmin 1186ad8ae2 feat(#76): IMP-47B reject-as-AI-adaptation activation (u1~u13 backend + tests)
- u1~u9: AI fallback infrastructure (router/prompts/schema/validator) + Step 12 hook
- u10: e2e reject chain (writes final.html with AI-repaired slot, full coverage)
- u11: frontend wiring deferred to follow-up commit (split from IMP-41 hunks)
- u12: coverage_invariant guard
- u13: cache save gate (visual_check PASS + user_approved/auto_cache) — Codex #22 verified

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 00:19:10 +09:00

224 lines
8.2 KiB
Python

"""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:<kind>`` 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"