|
|
|
@@ -16,25 +16,22 @@ Stage 2 binding contract (u6):
|
|
|
|
|
planner emits a frame-scoped CSS rule via
|
|
|
|
|
`apply_frame_internal_fit_candidate_css`.
|
|
|
|
|
|
|
|
|
|
T19d (#98) 정정 — layout_adjust 는 silent topology mutation 금지로
|
|
|
|
|
터미널 차단됨 (_attempt_salvage_chain 의 T19d 분기). 구 u6 의
|
|
|
|
|
"layout_adjust 실행" 계약(옛 2/3/4/7/8번 테스트)은 차단 계약으로 대체.
|
|
|
|
|
|
|
|
|
|
Test surfaces (8 tests):
|
|
|
|
|
1. `_SALVAGE_FAIL_BY_ACTION` map registers the two new actions with
|
|
|
|
|
the failure_type names the failure_router (u2) cascade rows expect.
|
|
|
|
|
2. `layout_adjust` PASS — out_path promoted with the swapped render,
|
|
|
|
|
step records new_layout_preset, cascade exits.
|
|
|
|
|
3. `layout_adjust` infeasible (no sibling for `single` preset) — step
|
|
|
|
|
records failure_reason without invoking render_slide; cascade
|
|
|
|
|
advances to frame_internal_fit_candidate.
|
|
|
|
|
4. `layout_adjust` rerender FAIL — cascade advances to
|
|
|
|
|
frame_internal_fit_candidate (which then PASSes via patched
|
|
|
|
|
envelope).
|
|
|
|
|
2. layout_adjust 차단 터미널 — step 없음, render 없음, out_path 보존.
|
|
|
|
|
3. 차단은 feasibility 판정보다 먼저 (plan_layout_adjust 미호출).
|
|
|
|
|
4. 차단 시 frame_internal_fit_candidate 로 전진하지 않음.
|
|
|
|
|
5. `frame_internal_fit_candidate` PASS via patched envelope — out_path
|
|
|
|
|
promoted with CSS-overlay candidate.
|
|
|
|
|
promoted with CSS-overlay candidate (direct entry 경로는 존속).
|
|
|
|
|
6. `frame_internal_fit_candidate` no-envelope — step records
|
|
|
|
|
envelope_present=False; cascade exits via frame_reselect terminal.
|
|
|
|
|
7. Full 5-step cascade all fail — loop cap (range(len(map))=5)
|
|
|
|
|
respected; exactly 5 steps recorded; out_path preserved.
|
|
|
|
|
8. `layout_adjust` uses no CSS-overlay path (css_override field is
|
|
|
|
|
absent from the layout_adjust step; new_layout_preset is present).
|
|
|
|
|
7. CSS-overlay 3단 전부 실패 → layout_adjust 차단 터미널 (steps=3).
|
|
|
|
|
8. 차단 터미널 trace 필드 observability (terminal_action / rationale).
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
@@ -146,21 +143,23 @@ def test_salvage_fail_map_registers_imp88_actions():
|
|
|
|
|
assert "image_fit" not in _SALVAGE_FAIL_BY_ACTION
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 2. layout_adjust PASS branch ────────────────────────────────────
|
|
|
|
|
# ── 2. layout_adjust — T19d(#98) 차단 터미널 ────────────────────────
|
|
|
|
|
# T19d 이후 layout_adjust 는 실행되지 않는다: "retry/salvage path 가 explicit
|
|
|
|
|
# layout override 없이 final.html 의 zone topology 를 바꾸면 안 된다" (silent
|
|
|
|
|
# topology mutation 금지). 아래 테스트들은 구 IMP-88 u6 의 실행 계약 대신
|
|
|
|
|
# 차단 계약을 잠근다.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_layout_adjust_pass_promotes_final_html(project_tmp, monkeypatch):
|
|
|
|
|
"""initial_failure_type=font_step_insufficient routes to layout_adjust;
|
|
|
|
|
plan_layout_adjust swaps horizontal-2 → vertical-2 (rows ↔ cols, swap
|
|
|
|
|
priority 0). render_slide is invoked with the NEW preset; overflow
|
|
|
|
|
passes → out_path promoted; step records new_layout_preset; cascade
|
|
|
|
|
exits with salvage_passed=True. No CSS overlay path was used."""
|
|
|
|
|
def test_layout_adjust_blocked_terminal_preserves_final_html(project_tmp, monkeypatch):
|
|
|
|
|
"""initial_failure_type=font_step_insufficient 는 layout_adjust 로 라우팅
|
|
|
|
|
되지만, T19d 차단으로 step 기록 없이 즉시 터미널 종료한다. render 미호출,
|
|
|
|
|
out_path 원본 보존, salvage_passed=False."""
|
|
|
|
|
out_path = project_tmp / "final.html"
|
|
|
|
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
|
|
|
|
counter = _patch_render(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_pz_pipeline, "run_overflow_check",
|
|
|
|
|
lambda p: {"passed": True, "fail_reasons": []},
|
|
|
|
|
lambda p: pytest.fail("layout_adjust 차단 시 overflow_check 호출 금지"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
trace = _attempt_salvage_chain(
|
|
|
|
@@ -173,100 +172,65 @@ def test_layout_adjust_pass_promotes_final_html(project_tmp, monkeypatch):
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert trace["salvage_attempted"] is True
|
|
|
|
|
assert trace["salvage_passed"] is True
|
|
|
|
|
assert len(trace["salvage_steps"]) == 1
|
|
|
|
|
step0 = trace["salvage_steps"][0]
|
|
|
|
|
assert step0["action"] == "layout_adjust"
|
|
|
|
|
assert step0["passed"] is True
|
|
|
|
|
assert step0["new_layout_preset"] == "vertical-2"
|
|
|
|
|
assert step0["plan"]["feasible"] is True
|
|
|
|
|
# layout_adjust uses the distinct render path — NOT the CSS-overlay path.
|
|
|
|
|
assert "css_override" not in step0
|
|
|
|
|
# render_slide was invoked exactly once with the NEW preset.
|
|
|
|
|
assert counter["n"] == 1
|
|
|
|
|
promoted = out_path.read_text(encoding="utf-8")
|
|
|
|
|
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
|
|
|
|
assert "data-rendered-preset='vertical-2'" in promoted
|
|
|
|
|
assert trace["salvage_passed"] is False
|
|
|
|
|
assert trace["salvage_steps"] == []
|
|
|
|
|
assert trace["salvage_terminal_action"] == (
|
|
|
|
|
"layout_adjust_blocked_no_silent_layout_mutation"
|
|
|
|
|
)
|
|
|
|
|
assert "kept original layout" in trace["salvage_terminal_rationale"]
|
|
|
|
|
assert counter["n"] == 0
|
|
|
|
|
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 3. layout_adjust infeasible (no sibling) ────────────────────────
|
|
|
|
|
# ── 3. layout_adjust 차단은 feasibility 판정보다 먼저 ────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_layout_adjust_infeasible_no_sibling_cascade_advances(project_tmp, monkeypatch):
|
|
|
|
|
"""`single` preset has no render-ready unit_count=1 sibling (catalog
|
|
|
|
|
design — single and grid-2x2 have no swap target). layout_adjust
|
|
|
|
|
returns feasible=False; render_slide is NOT invoked; cascade
|
|
|
|
|
advances to frame_internal_fit_candidate. Patched get_contract
|
|
|
|
|
returns no envelope → that branch also infeasible → cascade exits
|
|
|
|
|
at the frame_reselect terminal action."""
|
|
|
|
|
def test_layout_adjust_blocked_before_feasibility_check(project_tmp, monkeypatch):
|
|
|
|
|
"""T19d 차단은 plan_layout_adjust 호출 이전에 발생한다 — swap sibling 이
|
|
|
|
|
존재하는 horizontal-2 (feasible 했을 케이스)에서도 step 기록 없이 동일
|
|
|
|
|
터미널. plan 계산/렌더 어느 쪽도 실행되지 않음을 잠근다."""
|
|
|
|
|
out_path = project_tmp / "final.html"
|
|
|
|
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
|
|
|
|
counter = _patch_render(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_pz_pipeline, "run_overflow_check",
|
|
|
|
|
lambda p: pytest.fail(
|
|
|
|
|
"run_overflow_check must not run when no candidate is emitted"
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_pz_mapper, "get_contract", lambda _tid: None,
|
|
|
|
|
_pz_pipeline, "plan_layout_adjust",
|
|
|
|
|
lambda **kw: pytest.fail("T19d 차단 시 plan_layout_adjust 호출 금지"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
trace = _attempt_salvage_chain(
|
|
|
|
|
run_dir=project_tmp, out_path=out_path,
|
|
|
|
|
slide_title="imp88-u6", slide_footer=None,
|
|
|
|
|
zones_data=[{"position": "primary", "template_id": "t-only",
|
|
|
|
|
"content_weight": {"score": 1.0}}],
|
|
|
|
|
layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS,
|
|
|
|
|
cascade_inputs={**_ci_image(), "zone_position": "primary"},
|
|
|
|
|
zones_data=_horizontal_zones(),
|
|
|
|
|
layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
|
|
|
|
|
cascade_inputs=_ci_image(),
|
|
|
|
|
initial_failure_type="font_step_insufficient", gap_px=14,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert trace["salvage_attempted"] is True
|
|
|
|
|
assert trace["salvage_passed"] is False
|
|
|
|
|
actions = [s["action"] for s in trace["salvage_steps"]]
|
|
|
|
|
assert actions == ["layout_adjust", "frame_internal_fit_candidate"]
|
|
|
|
|
s0, s1 = trace["salvage_steps"]
|
|
|
|
|
assert s0["plan"]["feasible"] is False
|
|
|
|
|
assert "no render-ready" in (s0["plan"]["failure_reason"] or "")
|
|
|
|
|
assert s0["new_layout_preset"] is None
|
|
|
|
|
assert s1["plan"]["feasible"] is False
|
|
|
|
|
assert s1["plan"]["envelope_present"] is False
|
|
|
|
|
# No candidate ever rendered (layout_adjust infeasible → no render call;
|
|
|
|
|
# frame_internal_fit_candidate infeasible → no render call).
|
|
|
|
|
assert trace["salvage_steps"] == []
|
|
|
|
|
assert trace["salvage_terminal_action"] == (
|
|
|
|
|
"layout_adjust_blocked_no_silent_layout_mutation"
|
|
|
|
|
)
|
|
|
|
|
assert counter["n"] == 0
|
|
|
|
|
# frame_reselect is the next routing target after
|
|
|
|
|
# frame_internal_fit_candidate_insufficient — not in salvage map → terminal.
|
|
|
|
|
assert trace.get("salvage_terminal_action") == "frame_reselect"
|
|
|
|
|
# Original final.html unchanged.
|
|
|
|
|
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 4. layout_adjust rerender FAIL → frame_internal_fit_candidate PASS ──
|
|
|
|
|
# ── 4. layout_adjust 차단 시 frame_internal_fit 로 전진하지 않음 ─────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_layout_adjust_fail_cascade_to_frame_internal_fit_pass(project_tmp, monkeypatch):
|
|
|
|
|
"""layout_adjust feasible but post-swap overflow persists →
|
|
|
|
|
failure_type=layout_adjust_insufficient → routes to
|
|
|
|
|
frame_internal_fit_candidate. Patched contract provides an envelope
|
|
|
|
|
variant that absorbs the excess; that branch PASSes → out_path
|
|
|
|
|
promoted with the CSS-overlay candidate."""
|
|
|
|
|
def test_layout_adjust_blocked_no_cascade_advance(project_tmp, monkeypatch):
|
|
|
|
|
"""T19d 차단은 cascade 전진(layout_adjust_insufficient → frame_internal_
|
|
|
|
|
fit_candidate)이 아니라 즉시 터미널이다. feasible envelope contract 가
|
|
|
|
|
준비돼 있어도 frame_internal_fit_candidate 는 실행되지 않는다 — 구
|
|
|
|
|
IMP-88 u6 의 '4. rerender FAIL → cascade advance' 계약을 대체."""
|
|
|
|
|
out_path = project_tmp / "final.html"
|
|
|
|
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
|
|
|
|
counter = _patch_render(monkeypatch)
|
|
|
|
|
|
|
|
|
|
# First overflow check (after layout_adjust render) FAILS,
|
|
|
|
|
# second (after frame_internal_fit CSS overlay) PASSes.
|
|
|
|
|
overflow_results = iter([
|
|
|
|
|
{"passed": False, "fail_reasons": ["zone overflow persists"]},
|
|
|
|
|
{"passed": True, "fail_reasons": []},
|
|
|
|
|
])
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_pz_pipeline, "run_overflow_check",
|
|
|
|
|
lambda p: next(overflow_results),
|
|
|
|
|
lambda p: pytest.fail("차단 터미널에서 overflow_check 호출 금지"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Stub contract with a feasible internal_envelope variant covering 40px excess.
|
|
|
|
|
# envelope 이 준비된 contract 라도 도달 자체가 없어야 한다.
|
|
|
|
|
stub_contract = {
|
|
|
|
|
"internal_envelope": {
|
|
|
|
|
"variants": [
|
|
|
|
@@ -289,20 +253,13 @@ def test_layout_adjust_fail_cascade_to_frame_internal_fit_pass(project_tmp, monk
|
|
|
|
|
initial_failure_type="font_step_insufficient", gap_px=14,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert trace["salvage_passed"] is True
|
|
|
|
|
assert len(trace["salvage_steps"]) == 2
|
|
|
|
|
s0, s1 = trace["salvage_steps"]
|
|
|
|
|
assert s0["action"] == "layout_adjust"
|
|
|
|
|
assert s0["passed"] is False
|
|
|
|
|
assert s0["plan"]["feasible"] is True
|
|
|
|
|
assert s1["action"] == "frame_internal_fit_candidate"
|
|
|
|
|
assert s1["passed"] is True
|
|
|
|
|
assert s1["plan"]["selected_variant"] == "internal_grid_row"
|
|
|
|
|
assert s1["css_override"]
|
|
|
|
|
assert 'data-template-id="t-top"' in s1["css_override"]
|
|
|
|
|
# Two render_slide calls (one per dispatched branch).
|
|
|
|
|
assert counter["n"] == 2
|
|
|
|
|
assert "ORIGINAL_BEFORE_SALVAGE" not in out_path.read_text(encoding="utf-8")
|
|
|
|
|
assert trace["salvage_passed"] is False
|
|
|
|
|
assert trace["salvage_steps"] == []
|
|
|
|
|
assert trace["salvage_terminal_action"] == (
|
|
|
|
|
"layout_adjust_blocked_no_silent_layout_mutation"
|
|
|
|
|
)
|
|
|
|
|
assert counter["n"] == 0
|
|
|
|
|
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 5. frame_internal_fit_candidate PASS (direct entry) ─────────────
|
|
|
|
@@ -393,15 +350,15 @@ def test_frame_internal_fit_candidate_no_envelope_cascade_terminal(project_tmp,
|
|
|
|
|
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 7. Loop cap respected — 5 stages all fail ───────────────────────
|
|
|
|
|
# ── 7. CSS-overlay 3단 실패 후 layout_adjust 차단 터미널 ─────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_full_5_step_cascade_all_fail_loop_cap_respected(project_tmp, monkeypatch):
|
|
|
|
|
"""Start at donor_slack_insufficient and force every cascade stage to
|
|
|
|
|
fail: cross_zone (no fit_analysis), glue (excess > envelope), font_step
|
|
|
|
|
(no headroom), layout_adjust (single → no sibling), frame_internal_fit
|
|
|
|
|
(no contract). Loop iterates exactly len(_SALVAGE_FAIL_BY_ACTION)=5
|
|
|
|
|
times → salvage_steps has 5 entries in the exact cascade order."""
|
|
|
|
|
def test_cascade_stops_at_font_step_policy_block(project_tmp, monkeypatch):
|
|
|
|
|
"""donor_slack_insufficient 에서 시작: cross_zone(fail) → glue(fail) →
|
|
|
|
|
font_step 은 T28.5 no_font_shrink 정책 차단 — blocked step 을 기록하고
|
|
|
|
|
터미널 종료한다. layout_adjust / frame_internal_fit_candidate 는 도달
|
|
|
|
|
불가 (구 '5단 loop cap' 계약을 대체 — 실행 가능 cascade 는 2단 + 정책
|
|
|
|
|
차단 2종)."""
|
|
|
|
|
out_path = project_tmp / "final.html"
|
|
|
|
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
|
|
|
|
counter = _patch_render(monkeypatch)
|
|
|
|
@@ -416,7 +373,6 @@ def test_full_5_step_cascade_all_fail_loop_cap_respected(project_tmp, monkeypatc
|
|
|
|
|
trace = _attempt_salvage_chain(
|
|
|
|
|
run_dir=project_tmp, out_path=out_path,
|
|
|
|
|
slide_title="imp88-u6", slide_footer=None,
|
|
|
|
|
# single preset → layout_adjust will be infeasible (no sibling)
|
|
|
|
|
zones_data=[{"position": "primary", "template_id": "t-only",
|
|
|
|
|
"content_weight": {"score": 1.0}}],
|
|
|
|
|
layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS,
|
|
|
|
@@ -432,38 +388,37 @@ def test_full_5_step_cascade_all_fail_loop_cap_respected(project_tmp, monkeypatc
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert trace["salvage_passed"] is False
|
|
|
|
|
assert len(trace["salvage_steps"]) == 5
|
|
|
|
|
assert len(trace["salvage_steps"]) == 3
|
|
|
|
|
actions = [s["action"] for s in trace["salvage_steps"]]
|
|
|
|
|
assert actions == [
|
|
|
|
|
"cross_zone_redistribute",
|
|
|
|
|
"glue_compression",
|
|
|
|
|
"font_step_compression",
|
|
|
|
|
"layout_adjust",
|
|
|
|
|
"frame_internal_fit_candidate",
|
|
|
|
|
]
|
|
|
|
|
# All 5 are infeasible — no candidate rendering anywhere.
|
|
|
|
|
# 어떤 branch 도 candidate 렌더 없음.
|
|
|
|
|
assert counter["n"] == 0
|
|
|
|
|
# Loop exhausted at cap (no mid-cascade terminal_action since each
|
|
|
|
|
# next_action stayed in _SALVAGE_FAIL_BY_ACTION through 5 stages).
|
|
|
|
|
assert "salvage_terminal_action" not in trace
|
|
|
|
|
# font_step 은 T28.5 정책 차단 step (blocked_by_policy) + 터미널.
|
|
|
|
|
assert trace["salvage_steps"][2]["blocked_by_policy"] == "no_font_shrink"
|
|
|
|
|
assert trace["salvage_terminal_action"] == (
|
|
|
|
|
"font_step_compression_blocked_no_font_shrink"
|
|
|
|
|
)
|
|
|
|
|
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 8. layout_adjust uses the distinct render path (no CSS overlay) ──
|
|
|
|
|
# ── 8. 차단 터미널 trace 필드 observability ──────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_layout_adjust_step_has_no_css_overlay_field(project_tmp, monkeypatch):
|
|
|
|
|
"""layout_adjust's render path is qualitatively different from the
|
|
|
|
|
CSS-overlay planners (glue / font_step / cross_zone / frame_internal_fit):
|
|
|
|
|
it calls render_slide with the NEW preset + remapped zones_data + new
|
|
|
|
|
layout_css. The step dict therefore omits `css_override` and surfaces
|
|
|
|
|
`new_layout_preset` instead — observability for downstream classifiers."""
|
|
|
|
|
def test_layout_adjust_block_trace_fields(project_tmp, monkeypatch):
|
|
|
|
|
"""T19d 차단 터미널의 관측 계약: salvage_terminal_action 은 고정 문자열,
|
|
|
|
|
salvage_terminal_rationale 은 '왜 layout 을 유지했는지'를 설명하며,
|
|
|
|
|
salvage_steps 에 layout_adjust entry 가 존재하지 않는다 (구 '8. distinct
|
|
|
|
|
render path' 관측 계약을 대체)."""
|
|
|
|
|
out_path = project_tmp / "final.html"
|
|
|
|
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
|
|
|
|
_patch_render(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
_pz_pipeline, "run_overflow_check",
|
|
|
|
|
lambda p: {"passed": True, "fail_reasons": []},
|
|
|
|
|
lambda p: pytest.fail("차단 터미널에서 overflow_check 호출 금지"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
trace = _attempt_salvage_chain(
|
|
|
|
@@ -475,9 +430,9 @@ def test_layout_adjust_step_has_no_css_overlay_field(project_tmp, monkeypatch):
|
|
|
|
|
initial_failure_type="font_step_insufficient", gap_px=14,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
step0 = trace["salvage_steps"][0]
|
|
|
|
|
assert step0["action"] == "layout_adjust"
|
|
|
|
|
# Distinct render path observability contract:
|
|
|
|
|
assert "css_override" not in step0
|
|
|
|
|
assert "new_layout_preset" in step0
|
|
|
|
|
assert "candidate_path" in step0
|
|
|
|
|
assert trace["salvage_terminal_action"] == (
|
|
|
|
|
"layout_adjust_blocked_no_silent_layout_mutation"
|
|
|
|
|
)
|
|
|
|
|
rationale = trace["salvage_terminal_rationale"]
|
|
|
|
|
assert "zone positions" in rationale and "kept original layout" in rationale
|
|
|
|
|
assert all(s["action"] != "layout_adjust" for s in trace["salvage_steps"])
|
|
|
|
|