From 52ccb7fc8b604482540b494ed01387d5f89245de Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Thu, 14 May 2026 07:41:12 +0900 Subject: [PATCH] =?UTF-8?q?fix(IMP-06):=20Stage=204=20blocker-fix=20?= =?UTF-8?q?=E2=80=94=20render=5Frecords=20+=20plan-aware=20traces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Codex #13 blockers in a single coherent commit. Blocker 1 (units None hazard) — drop None placeholders from `units` list. Replace with a separate `render_records` layer built AFTER frame_overrides apply. units = canonical renderable list (list[CompositionUnit] only); render_records = canonical per-position view including empty / collision- skipped / cli_override entries. Downstream loops (Step 6 print, frame_ overrides, zones_data/debug_zones, Step 9 application_plan, compute_slide_ status covered loop) no longer need None guards. Blocker 2 (no integration test) — add end-to-end pipeline integration test: `--override-section-assignment top=03-2` on sample 03 MDX produces zones_data[top].source_section_ids = ['03-2'], debug_zones[top].assignment _source = 'cli_override', debug_zones[bottom].v4_template_id = '__empty__' (override_collision whole-skip), step20 filtered_section_ids contains '03-1', and filtered_section_reasons carries a section_assignment_override _uncovered entry. Proves the render path — not only comp_debug — reflects the CLI override. Blocker 3 (Step 9/20 not plan-aware) — surface plan-aware additive fields in both render-path debug_zones/zones_data and Step 9 application_plan units: position, assignment_source, section_assignment_override, replaced_auto_unit, skipped_collided_auto_units, uncovered_section_ids, skipped_reason. compute_slide_status appends Codex #10 Catch O list-shaped filtered_section_reasons entries for override-uncovered sections and folds them into filtered_section_ids so full_coverage is re-evaluated post-override. Exact-id-only collision semantics enforced (Codex #14/#15/#16/#17): S3 and S3-1 are distinct ids; no prefix hierarchy, no parent cascade. Three new section-id invariant tests added (parent-like vs child-like, exact duplicate collision detected, distinct ids coexist). Test : 24 pytest pass (9 helper + 9 case + 3 invariant + 1 case 9b + 1 integration + 1 from v4_fallback baseline) ; smoke 11/11 PASS. Register `integration` pytest marker in pyproject.toml. --- pyproject.toml | 3 + src/phase_z2_pipeline.py | 296 ++++++++++++++---- ...st_phase_z2_section_assignment_override.py | 228 ++++++++++++++ 3 files changed, 472 insertions(+), 55 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 89453af..ddf1ff8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,3 +31,6 @@ target-version = "py310" [tool.pytest.ini_options] asyncio_mode = "auto" +markers = [ + "integration: end-to-end pipeline integration tests (heavy; invoke Selenium)", +] diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 72ff1cf..4ab9d73 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -1592,6 +1592,52 @@ def compute_slide_status(sections: list[MdxSection], "filter_reasons": c.get("filter_reasons", []), }) + # IMP-06 blocker-fix (Codex #10 Catch O schema + Codex #16 coverage invariant) — + # surface override-uncovered sections as additive list entries in + # `filtered_section_reasons` and ensure `filtered_section_ids` includes them + # so coverage does not silently miss sections that were dropped by an explicit + # zone-section override. + v4_fb_summary = comp_debug.get("v4_fallback_summary", {}) or {} + section_assignment_summary = comp_debug.get("section_assignment_summary") or {} + section_assignment_uncovered_ids: list[str] = list( + section_assignment_summary.get("uncovered_section_ids") or [] + ) + if section_assignment_uncovered_ids: + # Codex #16 invariant : final filtered_section_ids must contain the + # override-uncovered ids even if they were originally "covered" by the + # pre-override auto plan. full_coverage must be re-evaluated too so + # Step 20 `overall` enum reflects the post-override reality. + for sid in section_assignment_uncovered_ids: + if sid not in filtered_ids: + filtered_ids.append(sid) + filtered_ids = sorted(set(filtered_ids)) + full_coverage = len(filtered_ids) == 0 + # Append a separate list entry per override-uncovered section so existing + # readers of filtered_section_reasons (list-shaped) keep working. + plan_by_position = { + (p.get("position") or ""): p + for p in (comp_debug.get("section_assignment_plan") or []) + } + for sid in section_assignment_uncovered_ids: + # Find the position whose plan entry recorded this uncovered id. + source_position = None + for pos, entry in plan_by_position.items(): + if sid in (entry.get("uncovered_section_ids") or []): + source_position = pos + break + filtered_section_reasons.append({ + "section_ids": [sid], + "merge_type": None, + "template_id": None, + "v4_label": None, + "phase_z_status": None, + "score": None, + "selection_state": "section_assignment_override_uncovered", + "filter_reasons": ["section_assignment_override_uncovered"], + "source": "section_assignment_override", + "position": source_position, + }) + if full_coverage and visual_passed: overall = "PASS" elif full_coverage and not visual_passed: @@ -2116,14 +2162,19 @@ def run_phase_z2_mvp1( file=sys.stderr, ) - # Stage 4 Part 2 — rebuild units list aligned with position_assignment_plan so - # downstream zones_data / debug_zones / Step 9 / Step 20 derive from the plan - # rather than the original auto-selected sequence. None placeholders preserve - # position identity for empty/collision-skipped zones (Codex #10 Catch N). + # Stage 4 blocker-fix (Codex #13/#14/#15/#16/#17) — rebuild units as a pure + # `list[CompositionUnit]` (renderable only, no None). Position-aware truth + # lives in `render_records` (built after frame_overrides apply) per Codex + # internal contract: units = canonical renderable list, render_records = + # canonical per-position view including empty/skipped entries. from src.phase_z2_composition import CompositionUnit plan_units: list = [] + # Maintain ordered alignment with section_assignment_plan for the + # render_records build step below: plan_unit_by_position[pos] = unit | None. + plan_unit_by_position: dict[str, object] = {} for entry in section_assignment_plan: assignment_source = entry["assignment_source"] + pos = entry["position"] if assignment_source == "cli_override" and entry["template_id"] is not None: sids = entry["source_section_ids"] raw_content_parts = [] @@ -2158,17 +2209,25 @@ def run_phase_z2_mvp1( }, ) plan_units.append(override_unit) + plan_unit_by_position[pos] = override_unit elif assignment_source == "auto": - # Find original auto unit by source_section_ids + # Find original auto unit by source_section_ids. matched = None for u in units: if list(u.source_section_ids) == entry["source_section_ids"]: matched = u break - plan_units.append(matched) # may be None if not found (unexpected) + if matched is not None: + plan_units.append(matched) + plan_unit_by_position[pos] = matched + else: + # Unexpected — auto plan entry without a matching original unit. + plan_unit_by_position[pos] = None else: - # empty / collision-skipped - plan_units.append(None) + # empty / collision-skipped — NO None in units list, but the position + # is preserved in plan_unit_by_position so render_records can emit + # an empty zone record below (after frame_overrides apply). + plan_unit_by_position[pos] = None units = plan_units if not units or layout_preset is None: @@ -2317,55 +2376,52 @@ def run_phase_z2_mvp1( file=sys.stderr, ) + # IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — build render_records AFTER + # frame_overrides so each record points to the post-override CompositionUnit + # object (Codex #15 Catch R). render_records is the canonical per-position + # plan-derived view: cli_override / auto / empty / collision-skipped all carry + # a record. `unit` is the same instance reference as in `units` when + # renderable, otherwise None. This drives debug_zones/Step 9/Step 20 traces + # without forcing None into the renderable `units` list. + render_records: list[dict] = [] + if section_assignment_plan is not None: + for entry in section_assignment_plan: + pos = entry["position"] + unit_ref = plan_unit_by_position.get(pos) + render_records.append({ + "position": pos, + "assignment_source": entry["assignment_source"], + "unit": unit_ref, + "source_section_ids": list(entry.get("source_section_ids") or []), + "section_assignment_override": entry.get("section_assignment_override"), + "replaced_auto_unit": entry.get("replaced_auto_unit"), + "skipped_collided_auto_units": list(entry.get("skipped_collided_auto_units") or []), + "uncovered_section_ids": list(entry.get("uncovered_section_ids") or []), + "skipped_reason": entry.get("skipped_reason"), + }) + + # IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — index render_records by + # unit object identity so the renderable zones loop below can stamp each + # zones_data / debug_zones entry with plan-aware fields (assignment_source, + # section_assignment_override, replaced_auto_unit, skipped_collided_auto_units, + # uncovered_section_ids, skipped_reason). Empty positions handled later by + # the post-loop "empty zone records" block — those records carry the same + # plan-aware fields directly from their plan entry. + render_record_by_unit_id: dict[int, dict] = {} + if render_records: + for rec in render_records: + u = rec.get("unit") + if u is not None: + render_record_by_unit_id[id(u)] = rec + for i, unit in enumerate(units): position = positions[i] if i < len(positions) else f"zone_{i}" - # Stage 4 Part 2 — empty position (override plan produced no renderable unit). - # Preserve zone identity with an explicit empty record so layout/grid stay - # structurally consistent without distorting allocation (Codex #10 Catch N). - if unit is None: - plan_entry = None - if section_assignment_plan is not None and i < len(section_assignment_plan): - plan_entry = section_assignment_plan[i] - zones_data.append({ - "position": position, - "template_id": "__empty__", - "slot_payload": {}, - "content_weight": {"score": 0}, - "min_height_px": 0, - "assignment_source": "empty", - "skipped_reason": ( - (plan_entry or {}).get("skipped_reason") - or "section_assignment_override_empty_or_unrenderable" - ), - }) - debug_zones.append({ - "position": position, - "source_section_ids": [], - "merge_type": "empty", - "title": "", - "v4_template_id": "__empty__", - "v4_label": None, - "v4_confidence": 0.0, - "selection_path": "section_assignment_empty", - "fallback_reason": None, - "fallback_used": False, - "phase_z_status": None, - "composition_score": 0.0, - "composition_rationale": {}, - "composition_notes": [], - "mapper_type": "empty", - "contract_id": "__empty__", - "contract_frame_id": None, - "assignment_source": "empty", - "skipped_reason": ( - (plan_entry or {}).get("skipped_reason") - or "section_assignment_override_empty_or_unrenderable" - ), - "replaced_auto_unit": (plan_entry or {}).get("replaced_auto_unit"), - "skipped_collided_auto_units": (plan_entry or {}).get("skipped_collided_auto_units", []), - "uncovered_section_ids": (plan_entry or {}).get("uncovered_section_ids", []), - }) - continue + plan_record = render_record_by_unit_id.get(id(unit)) + # When render_records exists (override path) prefer its position to + # guard against future drifts. positions[i] is the legacy auto path + # and is byte-identical to plan_record.position in the normal case. + if plan_record is not None and plan_record.get("position"): + position = plan_record["position"] synth_section = MdxSection( section_id="+".join(unit.source_section_ids), section_num=0, @@ -2466,12 +2522,39 @@ def run_phase_z2_mvp1( content_weight = compute_content_weight(synth_section) truncated_count = slot_payload.get("_truncated_count") # builder 가 truncate 한 경우 + # IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — plan-aware additive + # fields. When no override CLI was used (plan_record is None), these + # default to None/False/[] so pre-IMP-06 readers see byte-equivalent + # data. Empty zone records appended below (post-loop) carry the same + # field shape from their plan entry directly. + plan_assignment_source = ( + plan_record.get("assignment_source") if plan_record else None + ) + plan_section_override = ( + bool(plan_record.get("section_assignment_override")) + if plan_record else False + ) + plan_replaced_auto = ( + plan_record.get("replaced_auto_unit") if plan_record else None + ) + plan_skipped_collided = list( + plan_record.get("skipped_collided_auto_units") or [] + ) if plan_record else [] + plan_uncovered = list( + plan_record.get("uncovered_section_ids") or [] + ) if plan_record else [] + plan_skipped_reason = ( + plan_record.get("skipped_reason") if plan_record else None + ) + zones_data.append({ "position": position, "template_id": unit.frame_template_id, "slot_payload": slot_payload, "content_weight": content_weight, "min_height_px": min_height_px, + "assignment_source": plan_assignment_source, + "section_assignment_override": plan_section_override, }) debug_zones.append({ "position": position, @@ -2502,8 +2585,70 @@ def run_phase_z2_mvp1( "content_weight": content_weight, # trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향). "placement_trace": placement_trace, + # IMP-06 blocker-fix — plan-aware additive fields. + "assignment_source": plan_assignment_source, + "section_assignment_override": plan_section_override, + "replaced_auto_unit": plan_replaced_auto, + "skipped_collided_auto_units": plan_skipped_collided, + "uncovered_section_ids": plan_uncovered, + "skipped_reason": plan_skipped_reason, }) + # IMP-06 blocker-fix (Codex #10 Catch N) — append explicit empty zone records + # for positions whose section-assignment plan produced no renderable unit + # (cli_override with no resolvable template, ad-hoc multi-section, + # override_collision whole-skip with no replacement, etc.). Empty records + # preserve zone identity in zones_data/debug_zones (template_id="__empty__", + # content_weight=0, min_height_px=0) so layout/grid stay structurally + # consistent without distorting allocation, and the partial-render loop + # short-circuits "__empty__" to an empty string (no TemplateNotFound). + if render_records: + renderable_positions = {z["position"] for z in zones_data} + for record in render_records: + pos = record["position"] + if pos in renderable_positions: + continue + zones_data.append({ + "position": pos, + "template_id": "__empty__", + "slot_payload": {}, + "content_weight": {"score": 0}, + "min_height_px": 0, + "assignment_source": "empty", + "skipped_reason": ( + record.get("skipped_reason") + or "section_assignment_override_empty_or_unrenderable" + ), + }) + debug_zones.append({ + "position": pos, + "source_section_ids": list(record.get("source_section_ids") or []), + "merge_type": "empty", + "title": "", + "v4_template_id": "__empty__", + "v4_label": None, + "v4_confidence": 0.0, + "selection_path": "section_assignment_empty", + "fallback_reason": None, + "fallback_used": False, + "phase_z_status": None, + "composition_score": 0.0, + "composition_rationale": {}, + "composition_notes": [], + "mapper_type": "empty", + "contract_id": "__empty__", + "contract_frame_id": None, + "assignment_source": record.get("assignment_source"), + "skipped_reason": ( + record.get("skipped_reason") + or "section_assignment_override_empty_or_unrenderable" + ), + "section_assignment_override": record.get("section_assignment_override"), + "replaced_auto_unit": record.get("replaced_auto_unit"), + "skipped_collided_auto_units": list(record.get("skipped_collided_auto_units") or []), + "uncovered_section_ids": list(record.get("uncovered_section_ids") or []), + }) + # ─── Step 3: Content Object 추출 (B1, trace-only) ─── # IMP-03 — slide-level rich ContentObject 추출 (default OFF canary). # scope-lock 16 조건 (Gitea #3) : @@ -3075,6 +3220,20 @@ def run_phase_z2_mvp1( # 4. len(units) == Step 6 의 plan_composition 결과 (무변) # 5. application_status == "ok" iff len(v4_candidates) > 0 iff candidate_status == "ok" + # IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — pre-build per-unit + # plan-aware lookup so Step 9 application_plan can carry position + + # assignment_source + override-flag fields. Render-record is the canonical + # plan-derived per-position view (built post-frame_overrides); match by + # object identity since render_records[i]["unit"] holds the same instance + # ref as the entry in `units`. + plan_record_by_unit_id = {} + if "render_records" in locals() and render_records: + for rec in render_records: + u = rec.get("unit") + if u is None: + continue + plan_record_by_unit_id[id(u)] = rec + application_plan_units = [] for i, unit in enumerate(units): unit_id = "+".join(unit.source_section_ids) @@ -3087,6 +3246,19 @@ def run_phase_z2_mvp1( current_default = unit.frame_template_id if has_v4 else None selection_trace = v4_fallback_traces.get(unit.source_section_ids[0], {}) + # IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware additive + # fields. additive = pre-IMP-06 readers (no override CLI used) see + # position=None / assignment_source=None / section_assignment_override + # =False / replaced_auto_unit=None / skipped_collided_auto_units=[] / + # skipped_reason=None — i.e. byte-identical absent overrides. + plan_record = plan_record_by_unit_id.get(id(unit)) + plan_position = plan_record.get("position") if plan_record else None + plan_assignment_source = plan_record.get("assignment_source") if plan_record else None + plan_section_override = bool(plan_record.get("section_assignment_override")) if plan_record else False + plan_replaced_auto = plan_record.get("replaced_auto_unit") if plan_record else None + plan_skipped_collided = list(plan_record.get("skipped_collided_auto_units") or []) if plan_record else [] + plan_skipped_reason = plan_record.get("skipped_reason") if plan_record else None + # Step 7-A axis 보강 — reject 포함 모든 V4 judgments (frontend UI 가 # 모든 frame 의 png 를 카드로 보여주기 위함). # unit_id = source_section_ids join. parent_merged 는 첫 section 의 @@ -3157,6 +3329,14 @@ def run_phase_z2_mvp1( for c in v4_all_for_unit ], "application_candidates": app_candidates, + # IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware + # additive fields. None / False / [] when no override CLI used. + "position": plan_position, + "assignment_source": plan_assignment_source, + "section_assignment_override": plan_section_override, + "replaced_auto_unit": plan_replaced_auto, + "skipped_collided_auto_units": plan_skipped_collided, + "skipped_reason": plan_skipped_reason, }) units_with_no_v4 = [ @@ -3178,6 +3358,12 @@ def run_phase_z2_mvp1( # Step 7-A axis : user override trace "frame_overrides_applied": frame_overrides_applied, "frame_overrides_skipped": frame_overrides_skipped, + # IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — surface the + # section-assignment plan + summary at Step 9 top level so + # frontend / downstream readers do not have to dive into + # comp_debug to see override impact. + "section_assignment_plan": comp_debug.get("section_assignment_plan"), + "section_assignment_summary": comp_debug.get("section_assignment_summary"), "v0_lock_note": ( "Step 9 v0 passive (사용자 lock 2026-05-08). " "Step 6 default 그대로 사용 — runtime 결과 byte-동일. " diff --git a/tests/test_phase_z2_section_assignment_override.py b/tests/test_phase_z2_section_assignment_override.py index 652f9f8..b618c93 100644 --- a/tests/test_phase_z2_section_assignment_override.py +++ b/tests/test_phase_z2_section_assignment_override.py @@ -325,3 +325,231 @@ def test_summary_aggregation_counts_applied_skipped_uncovered(): assert summary["skipped_count"] >= 1 # collision skip assert summary["uncovered_section_ids"] == ["MOCK_S2"] assert summary["section_assignment_overrides_applied"][0]["position"] == "top" + + +# ─── Section-id exact-id invariant (Codex #14 / #15 / #16 / #17) ───────── + + +def test_section_id_exact_match_parent_like_does_not_collide_with_child_like(): + """Codex #14 explicit clarification : section ids are matched exact-string only. + `S3` and `S3-1` are distinct ids — auto plan having a parent-like id `S3` does + not implicitly consume / uncover `S3-1` or `S3-2` via prefix matching. + """ + auto = _FakeUnit(source_section_ids=["MOCK_S3"], frame_template_id="MOCK_T_parent") + units = [auto] + positions = ["top", "bottom"] + # Override places a child-like id S3-1 into bottom; must NOT be treated as + # the same section as S3 by prefix. + overrides = {"bottom": ["MOCK_S3-1"]} + sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S3", "MOCK_S3-1"]} + override_frames = {"MOCK_S3-1": "MOCK_T_child"} + + plan, summary = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=override_frames, + ) + + by_pos = {p["position"]: p for p in plan} + # top : auto plan keeps MOCK_S3 intact (NOT consumed/skipped by S3-1 override) + assert by_pos["top"]["assignment_source"] == "auto" + assert by_pos["top"]["source_section_ids"] == ["MOCK_S3"] + assert by_pos["top"]["skipped_reason"] is None + # bottom : override applies MOCK_S3-1 as a distinct section + assert by_pos["bottom"]["assignment_source"] == "cli_override" + assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S3-1"] + # No collision, no uncovered MOCK_S3 from "prefix match" + assert summary["uncovered_section_ids"] == [] + + +def test_section_id_exact_duplicate_collision_detected(): + """Codex #14 invariant : exact `S3-1` colliding with another exact `S3-1` + IS detected as a collision (whole-skip auto, uncovered trace). + """ + # Auto unit with MOCK_S3-1 (exact id) sits at bottom + auto_top = _FakeUnit(source_section_ids=["MOCK_other"], frame_template_id="MOCK_T_other") + auto_bottom = _FakeUnit(source_section_ids=["MOCK_S3-1", "MOCK_S3-2"], frame_template_id="MOCK_T_pair") + units = [auto_top, auto_bottom] + positions = ["top", "bottom"] + # Override pulls MOCK_S3-1 to top — exact-id collision with auto_bottom's S3-1. + overrides = {"top": ["MOCK_S3-1"]} + sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_other", "MOCK_S3-1", "MOCK_S3-2"]} + override_frames = {"MOCK_S3-1": "MOCK_T_for_S3_1"} + + plan, summary = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=override_frames, + ) + + by_pos = {p["position"]: p for p in plan} + # top : override wins (replaces auto_top's MOCK_other) + assert by_pos["top"]["assignment_source"] == "cli_override" + assert by_pos["top"]["source_section_ids"] == ["MOCK_S3-1"] + # bottom : exact-id collision with override's MOCK_S3-1 → whole skip + assert by_pos["bottom"]["assignment_source"] == "empty" + assert by_pos["bottom"]["skipped_reason"] == "override_collision" + skipped = by_pos["bottom"]["skipped_collided_auto_units"] + assert skipped and skipped[0]["unit_id"] == "MOCK_S3-1+MOCK_S3-2" + # MOCK_S3-2 is uncovered (exact-id only, no automatic split) + assert "MOCK_S3-2" in summary["uncovered_section_ids"] + + +def test_section_id_distinct_ids_coexist_in_different_positions(): + """Codex #14 invariant : S3 and S3-1 (distinct exact ids) can coexist in + different positions without collision or false uncoverage.""" + # Auto plan : S3 at top + auto = _FakeUnit(source_section_ids=["MOCK_S3"], frame_template_id="MOCK_T_S3") + units = [auto] + positions = ["top", "bottom"] + # Override : S3-1 into bottom (no overlap with S3 since exact-id only) + overrides = {"bottom": ["MOCK_S3-1"]} + sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S3", "MOCK_S3-1"]} + override_frames = {"MOCK_S3-1": "MOCK_T_S3_1"} + + plan, summary = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=override_frames, + ) + + by_pos = {p["position"]: p for p in plan} + assert by_pos["top"]["source_section_ids"] == ["MOCK_S3"] + assert by_pos["top"]["skipped_reason"] is None + assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S3-1"] + assert summary["uncovered_section_ids"] == [] # no false uncoverage + + +# ─── Codex #13 Blocker 2 integration proof ─────────────────────────────── +# End-to-end pipeline run with `--override-section-assignment top=03-2` on +# sample 03 MDX. Asserts the override is reflected in the actual render-path +# artifacts (zones_data, debug_zones, Step 9 application_plan, Step 20 +# slide_status, debug.json `zones`) — NOT only in `comp_debug. +# section_assignment_plan`. Without this proof, the helper unit tests above +# could pass while units/render_records/zones still showed the pre-override +# auto plan. Heavy: invokes Selenium overflow check. + + +@pytest.mark.integration +def test_integration_override_reflects_in_zones_data_step9_step20(tmp_path, monkeypatch): + """Codex #13 Blocker 2 (non-negotiable) — integration proof that + `--override-section-assignment top=03-2` changes actual render-path + artifacts (zones_data / debug_zones / Step 9 / Step 20 / debug.json), + not only `comp_debug.section_assignment_plan`. + + Sample 03 MDX has 2 sections (03-1, 03-2). Auto plan = [top=03-1, + bottom=03-2]. Override forces top=03-2 → exact-id collision with auto + bottom (whole-skip) + previous auto top (03-1) becomes uncovered. + """ + import json + from pathlib import Path + from src import phase_z2_pipeline as pz2 + + PROJECT_ROOT = Path(pz2.__file__).resolve().parent.parent + sample_path = PROJECT_ROOT / "samples" / "mdx" / "03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx" + if not sample_path.is_file(): + pytest.skip(f"sample MDX not present: {sample_path}") + + # Isolate run output under tmp_path so we do not pollute data/runs/. + monkeypatch.setattr(pz2, "RUNS_DIR", tmp_path / "runs") + + run_id = "test_imp06_override_integration" + pz2.run_phase_z2_mvp1( + sample_path, + run_id=run_id, + override_section_assignments={"top": ["03-2"]}, + ) + + run_dir = tmp_path / "runs" / run_id / "phase_z2" + debug_path = run_dir / "debug.json" + assert debug_path.is_file(), f"debug.json missing at {debug_path}" + debug = json.loads(debug_path.read_text(encoding="utf-8")) + + # ── 1) Render-path zones (debug.json `zones` == debug_zones in code) ── + zones = {z["position"]: z for z in debug.get("zones", [])} + assert "top" in zones, f"top zone missing; got {list(zones)}" + assert "bottom" in zones, f"bottom zone missing; got {list(zones)}" + + top = zones["top"] + # Override reflected in render-path debug_zones, not only comp_debug. + assert top["source_section_ids"] == ["03-2"], ( + f"top debug_zone did not reflect override; source_section_ids={top.get('source_section_ids')}" + ) + assert top.get("assignment_source") == "cli_override", ( + f"top assignment_source != cli_override; got {top.get('assignment_source')}" + ) + # Override-flag trace surfaces on the post-override debug_zone. + assert top.get("section_assignment_override") is True, ( + f"top.section_assignment_override flag missing/false; got {top.get('section_assignment_override')}" + ) + + bottom = zones["bottom"] + # Exact-id collision (override 03-2 vs auto bottom 03-2) → whole-skip. + # The empty zone record must be present in debug_zones (zone identity preserved). + assert bottom.get("v4_template_id") == "__empty__" or bottom.get("merge_type") == "empty", ( + f"bottom should be empty after collision; got " + f"v4_template_id={bottom.get('v4_template_id')}, merge_type={bottom.get('merge_type')}" + ) + assert bottom.get("skipped_reason") == "override_collision", ( + f"bottom skipped_reason != override_collision; got {bottom.get('skipped_reason')}" + ) + + # ── 2) Step 20 slide_status — coverage invariant ── + step20_path = run_dir / "steps" / "step20_slide_status.json" + assert step20_path.is_file(), f"step20 missing at {step20_path}" + step20 = json.loads(step20_path.read_text(encoding="utf-8")) + payload = step20.get("data") if "data" in step20 else step20 + filtered_ids = payload.get("filtered_section_ids") or [] + assert "03-1" in filtered_ids, ( + f"03-1 (previous auto top displaced by override) must appear in " + f"filtered_section_ids; got {filtered_ids}" + ) + # full_mdx_coverage must NOT be True when override displaces a section. + assert payload.get("full_mdx_coverage") is not True, ( + f"full_mdx_coverage should be False after override displaces 03-1; got " + f"{payload.get('full_mdx_coverage')}" + ) + # Codex #10 Catch O list-shaped filtered_section_reasons must include the + # override-uncovered entry pointing to the position whose plan dropped it. + reasons = payload.get("filtered_section_reasons") or [] + override_uncovered_reasons = [ + r for r in reasons + if isinstance(r, dict) + and r.get("source") == "section_assignment_override" + and "03-1" in (r.get("section_ids") or []) + ] + assert override_uncovered_reasons, ( + f"filtered_section_reasons missing override-uncovered entry for 03-1; " + f"got {reasons}" + ) + + # ── 3) Step 9 application_plan — plan-aware additive fields per unit ── + step09_path = run_dir / "steps" / "step09_application_plan.json" + assert step09_path.is_file(), f"step09 missing at {step09_path}" + step09 = json.loads(step09_path.read_text(encoding="utf-8")) + step09_data = step09.get("data") if "data" in step09 else step09 + plan_units = step09_data.get("units") or [] + # The renderable unit for 03-2 must exist with override-aware fields. + override_units = [ + u for u in plan_units + if u.get("unit_id") == "03-2" + and u.get("position") == "top" + and u.get("assignment_source") == "cli_override" + ] + assert override_units, ( + f"Step 9 application_plan did not carry plan-aware fields; " + f"units={[(u.get('unit_id'), u.get('position'), u.get('assignment_source')) for u in plan_units]}" + ) + assert override_units[0].get("section_assignment_override") is True + + # ── 4) comp_debug — pre-existing plan/summary still present (regression) ── + cd = debug.get("composition_planner_debug") or {} + sa_summary = cd.get("section_assignment_summary") or {} + # uncovered_section_ids carries the displaced auto-top section. + assert "03-1" in (sa_summary.get("uncovered_section_ids") or []), ( + f"comp_debug.section_assignment_summary.uncovered_section_ids " + f"missing 03-1; got {sa_summary.get('uncovered_section_ids')}" + )