- 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>
224 lines
8.2 KiB
Python
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"
|