fix(IMP-06): Stage 4 blocker-fix — render_records + plan-aware traces

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.
This commit is contained in:
2026-05-14 07:41:12 +09:00
parent 1f15495117
commit 52ccb7fc8b
3 changed files with 472 additions and 55 deletions

View File

@@ -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)",
]

View File

@@ -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:
# empty / collision-skipped
plan_units.append(None)
# Unexpected — auto plan entry without a matching original unit.
plan_unit_by_position[pos] = None
else:
# 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,6 +2585,68 @@ 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) ───
@@ -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-동일. "

View File

@@ -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')}"
)