Files
C.E.L_Slide_test2/tests/test_phase_z2_section_assignment_override.py
kyeongmin d596fabde0 feat(IMP-06): zone-section assignment override CLI + plan helper (trace-only)
Refs #6

Backend / CLI / composition path only — frontend bridge remains #38.

- Add `--override-section-assignment ZONE_ID=section_id[,section_id]` to the
  Phase Z entry parser. Parse-time hard errors for malformed payloads, empty
  zone id, empty section list, duplicate zone id, and duplicate section across
  zones (a section may belong to at most one zone).
- Add `_build_position_assignment_plan` helper (pure function, resolved
  `positions` injected). Builds a per-position assignment plan with the
  Codex-locked template_id ladder: (1) `--override-frame` exact unit_id wins,
  (2) exact existing auto unit reuse, (3) single-section direct-executable V4
  selector via `lookup_v4_match_with_fallback(..., raw_content=section.raw_content)`,
  (4) ad-hoc multi-section override without exact auto + without explicit
  override-frame yields `skipped_reason='ad_hoc_merged_no_template'`.
- Lock the collision policy: explicit override wins per position, sections
  appear in at most one position, overlapping auto units are skipped whole
  (no split, no cascade, no replan), uncovered sections from the previous
  same-position auto unit are recorded in `uncovered_section_ids`.
- Additive trace fields on each plan entry: `previous_source_section_ids`,
  `skipped_collided_auto_units`, `uncovered_section_ids`, `v4_selector_trace`,
  `section_assignment_override`. Top-level `comp_debug["section_assignment_plan"]`
  + `comp_debug["section_assignment_summary"]` so Step 9 / debug artifacts can
  derive from a single source of truth.
- Wire `run_phase_z2_mvp1(override_section_assignments=...)` after final layout
  preset resolution: validate ZONE_IDs against active layout positions and
  validate section_ids against aligned sections (fail-fast). The plan is
  attached to `comp_debug` for downstream artifacts. Actual `zones_data` /
  unit-list rewiring is deferred to a follow-up commit so this change stays
  regression-safe; trace artifacts already surface override intent and
  collision impact.
- Add 9 helper unit tests with fully synthetic MOCK_ ids (no real catalog
  / no v4_full32_result.yaml): non-conflicting auto retention, collision
  whole-skip + uncovered tracing, template ladder steps 1/2/4, unit_id
  naming convention, previous_source_section_ids position history,
  empty-position case, summary aggregation invariants.

No AI, no `calculate_fit`, no full planner rerun, no frontend, no sample
hardcoding, no `restructure`/`reject` silent promotion. `plan_composition()`
signature is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:51:20 +09:00

286 lines
11 KiB
Python

"""IMP-06 zone-section assignment override — helper unit tests (synthetic).
Lock per Claude #6 §4 L13 + Codex #2 R3 6 cases + 자체 catch 7-10 :
9 cases covering parse / helper assignment / collision / template ladder.
Fully synthetic per Codex #7 generalization guardrail (MOCK_ prefix).
NO real catalog template_id / frame_id, NO `v4_full32_result.yaml` dependency.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import pytest
from src.phase_z2_pipeline import _build_position_assignment_plan
# ─── Synthetic fixtures ──────────────────────────────────────────
@dataclass
class _FakeUnit:
"""Synthetic CompositionUnit stand-in. Only fields the helper reads."""
source_section_ids: list[str]
template_id: Optional[str] = None
frame_template_id: Optional[str] = None
@dataclass
class _FakeSection:
"""Synthetic MdxSection stand-in. Only fields the helper reads."""
section_id: str
raw_content: str = "- item A\n- item B\n"
# ─── Case 1 : single override + non-conflicting auto retain ─────────────
def test_single_zone_override_retains_non_conflicting_auto():
"""Claude #6 L13 case 4 — single override on `top`, auto units do not overlap.
Expected: top = override unit; bottom = auto unit retained.
"""
units = [
_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto_top"),
_FakeUnit(source_section_ids=["MOCK_S2"], frame_template_id="MOCK_T_auto_bottom"),
]
positions = ["top", "bottom"]
overrides = {"top": ["MOCK_S3"]}
sections_by_id = {"MOCK_S3": _FakeSection("MOCK_S3")}
override_frames = {"MOCK_S3": "MOCK_T_for_S3"} # ladder step 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"]["assignment_source"] == "cli_override"
assert by_pos["top"]["source_section_ids"] == ["MOCK_S3"]
assert by_pos["top"]["template_id"] == "MOCK_T_for_S3"
assert by_pos["bottom"]["assignment_source"] == "auto"
assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S2"]
assert summary["applied_count"] == 1
assert summary["skipped_count"] == 0
# ─── Case 2 : collision — override wins, auto whole-skipped ──────────────
def test_override_collision_whole_skip_no_split_uncovered_traced():
"""Codex #2 R1 example : override section overlaps an auto merged unit.
Expected: override wins; auto [MOCK_S1, MOCK_S2] skipped whole (no split);
MOCK_S2 reported as uncovered.
"""
auto_merged = _FakeUnit(
source_section_ids=["MOCK_S1", "MOCK_S2"],
frame_template_id="MOCK_T_merged",
)
auto_solo = _FakeUnit(source_section_ids=["MOCK_S3"], frame_template_id="MOCK_T_solo")
units = [auto_merged, auto_solo]
positions = ["top", "bottom"]
overrides = {"top": ["MOCK_S1"]}
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2", "MOCK_S3"]}
override_frames = {"MOCK_S1": "MOCK_T_override_S1"}
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; auto_merged that sat at top is replaced.
assert by_pos["top"]["assignment_source"] == "cli_override"
assert by_pos["top"]["source_section_ids"] == ["MOCK_S1"]
# No split : MOCK_S2 (the other half of the auto merged unit) is NOT
# re-extracted into the replaced position; instead it surfaces as uncovered.
assert by_pos["top"]["previous_source_section_ids"] == ["MOCK_S1", "MOCK_S2"]
assert by_pos["top"]["uncovered_section_ids"] == ["MOCK_S2"]
# bottom : non-overlapping auto_solo is retained (no collision).
assert by_pos["bottom"]["assignment_source"] == "auto"
assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S3"]
# Summary aggregates MOCK_S2 as the global uncovered section.
assert summary["uncovered_section_ids"] == ["MOCK_S2"]
# ─── Case 3 : template ladder step 1 (override_frames wins) ─────────────
def test_template_resolution_ladder_step1_override_frame_wins():
"""Codex #4 T1 ladder step 1 : --override-frame exact unit_id wins."""
units = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto")]
positions = ["top"]
overrides = {"top": ["MOCK_S1"]}
sections_by_id = {"MOCK_S1": _FakeSection("MOCK_S1")}
override_frames = {"MOCK_S1": "MOCK_T_explicit_override"}
plan, _ = _build_position_assignment_plan(
units=units, positions=positions,
override_section_assignments=overrides,
sections_by_id=sections_by_id,
override_frames=override_frames,
)
assert plan[0]["template_id"] == "MOCK_T_explicit_override"
assert plan[0]["skipped_reason"] is None
# ─── Case 4 : template ladder step 2 (exact auto unit reuse) ────────────
def test_template_resolution_ladder_step2_exact_auto_reuse():
"""Codex #4 T1 ladder step 2 : no override_frame; exact existing auto unit -> reuse."""
units = [_FakeUnit(source_section_ids=["MOCK_S1", "MOCK_S2"], frame_template_id="MOCK_T_auto_merged")]
positions = ["top"]
overrides = {"top": ["MOCK_S1", "MOCK_S2"]}
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2"]}
plan, _ = _build_position_assignment_plan(
units=units, positions=positions,
override_section_assignments=overrides,
sections_by_id=sections_by_id,
override_frames=None, # no explicit frame override
)
# ladder step 2 hits : exact auto unit [MOCK_S1, MOCK_S2] reuse
assert plan[0]["template_id"] == "MOCK_T_auto_merged"
assert plan[0]["skipped_reason"] is None
# ─── Case 5 : template ladder step 4 (ad-hoc multi-section fail) ─────────
def test_template_resolution_ladder_step4_ad_hoc_multi_section_fail():
"""Codex #4 Additional lock : ad-hoc multi-section override without exact auto +
without explicit --override-frame -> skipped_reason = 'ad_hoc_merged_no_template'.
"""
units = [
_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_a"),
_FakeUnit(source_section_ids=["MOCK_S2"], frame_template_id="MOCK_T_b"),
]
positions = ["top"]
overrides = {"top": ["MOCK_S1", "MOCK_S2"]} # ad-hoc merge, no exact auto
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2"]}
plan, _ = _build_position_assignment_plan(
units=units, positions=positions,
override_section_assignments=overrides,
sections_by_id=sections_by_id,
override_frames=None,
)
assert plan[0]["template_id"] is None
assert plan[0]["skipped_reason"] == "ad_hoc_merged_no_template"
# ─── Case 6 : unit_id naming convention ─────────────────────────────────
def test_unit_id_naming_convention_consistent_for_auto_and_override():
"""Codex T2 + Claude #4 catch 8 : unit_id = '+'.join(source_section_ids)."""
units = [
_FakeUnit(source_section_ids=["MOCK_S1", "MOCK_S2"], frame_template_id="MOCK_T_merged"),
]
positions = ["top", "bottom"]
overrides = {"bottom": ["MOCK_S3"]}
sections_by_id = {"MOCK_S3": _FakeSection("MOCK_S3")}
override_frames = {"MOCK_S3": "MOCK_T_S3"}
plan, _ = _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}
# auto merged unit
assert by_pos["top"]["unit_id"] == "MOCK_S1+MOCK_S2"
# single-section override
assert by_pos["bottom"]["unit_id"] == "MOCK_S3"
# ─── Case 7 : previous_source_section_ids semantics (position history) ──
def test_previous_source_section_ids_records_same_position_auto_history():
"""Codex T3 + Claude #4 catch 9 : previous_source_section_ids = the auto
assignment that occupied the SAME position before override applied.
"""
units = [
_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_a"),
_FakeUnit(source_section_ids=["MOCK_S2"], frame_template_id="MOCK_T_b"),
]
positions = ["top", "bottom"]
overrides = {"top": ["MOCK_S3"]}
sections_by_id = {"MOCK_S3": _FakeSection("MOCK_S3")}
override_frames = {"MOCK_S3": "MOCK_T_S3"}
plan, _ = _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 WAS MOCK_S1 before override
assert by_pos["top"]["previous_source_section_ids"] == ["MOCK_S1"]
# ─── Case 8 : empty position when no auto unit available ───────────────
def test_position_with_no_auto_unit_marked_empty():
"""When `units` has fewer entries than `positions`, extra positions = empty."""
units = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_a")]
positions = ["top", "bottom"] # bottom has no auto unit
sections_by_id = {"MOCK_S1": _FakeSection("MOCK_S1")}
plan, summary = _build_position_assignment_plan(
units=units, positions=positions,
override_section_assignments=None,
sections_by_id=sections_by_id,
override_frames=None,
)
by_pos = {p["position"]: p for p in plan}
assert by_pos["bottom"]["assignment_source"] == "empty"
assert by_pos["bottom"]["skipped_reason"] == "no_auto_unit_available"
assert summary["applied_count"] == 0
# ─── Case 9 : summary aggregation invariants ───────────────────────────
def test_summary_aggregation_counts_applied_skipped_uncovered():
"""Claude #6 L12 single source of truth : summary derives from plan."""
auto_merged = _FakeUnit(
source_section_ids=["MOCK_S1", "MOCK_S2"],
frame_template_id="MOCK_T_merged",
)
units = [auto_merged]
positions = ["top", "bottom"]
# Two overrides : top=MOCK_S1 (causes collision with auto_merged at bottom)
overrides = {"top": ["MOCK_S1"]}
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2"]}
override_frames = {"MOCK_S1": "MOCK_T_override_S1"}
plan, summary = _build_position_assignment_plan(
units=units, positions=positions,
override_section_assignments=overrides,
sections_by_id=sections_by_id,
override_frames=override_frames,
)
# Summary derives from plan : 1 applied, 1 collision skip, 1 uncovered.
assert summary["applied_count"] == 1
assert summary["skipped_count"] >= 1 # collision skip
assert summary["uncovered_section_ids"] == ["MOCK_S2"]
assert summary["section_assignment_overrides_applied"][0]["position"] == "top"