구조 결함 2개 수정으로 IMP-35 popup 레버를 실동작화:
1. 트리거 모순 해소: 기존 T28.5d 트리거는 eligible_candidate_count==0 요구
— 후보가 '존재만' 해도 미발화인데 그 후보의 자동 소비는 T19d 로 금지
→ 영구 미발화 (mdx04 재현). salvage 정책 차단 터미널
(T19d layout / T28.5 font) 소진 시 overflow zone 을 대상으로 하는
두 번째 트리거 추가 (salvage_exhausted_overflow_pressure).
2. 높이 실감소: slide_base 는 partial 을 그대로 렌더 + <details> 추가일 뿐
(preview_text 는 렌더 경로 미소비 — popup 이 높이를 못 줄였음).
_trim_slot_payload_for_overflow 로 본문 라인을 예산만큼 트리밍 —
재귀 수집 (list-of-str + {text,indent} line-object, f13b 3중 중첩 포함),
라인 경계만, 슬롯당 최소 1라인, label/title 무접촉.
측정 피드백 루프 (최대 3회): 후보 렌더의 잔여 excess 로 추가 트림.
트리밍 전문은 popup body(raw_content 원문)에 보존 — 텍스트 무손실.
실전 검증:
- mdx04: RENDERED_WITH_VISUAL_REGRESSION → **PASS** (trimmed=6, coverage missing 0,
promoted). presentation_ready=False 는 density tight — #15 게이트 정직 판정
- mdx01/02/03/05: 오발화 0, 상태 불변
- SHA baseline 재캡처 (mdx04 final.html 정당 변경), 전체 게이트 1027 passed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+194
-34
@@ -25,6 +25,7 @@ MVP-1.5b spec :
|
||||
- mvp1.5b_test* : 본 모듈, 원래 설계 라인 합류
|
||||
"""
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -6112,15 +6113,31 @@ def _unit_for_t28_5_source_ids(
|
||||
return None
|
||||
|
||||
|
||||
# issue #16 — salvage 정책 차단 터미널 집합. 이 터미널에 도달했다는 것은
|
||||
# 자동 fit 레버(cross_zone/glue)가 소진되고 나머지(font/layout)는 정책
|
||||
# 잠금이라는 뜻 — popup escalation 이 유일한 무손실 자동 레버.
|
||||
_SALVAGE_POLICY_BLOCKED_TERMINALS = frozenset({
|
||||
"layout_adjust_blocked_no_silent_layout_mutation",
|
||||
"font_step_compression_blocked_no_font_shrink",
|
||||
})
|
||||
|
||||
|
||||
def _build_t28_5d_popup_targets(
|
||||
*,
|
||||
units: list[CompositionUnit],
|
||||
presentation_reselection: dict,
|
||||
retry_trace: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""Select T28.5d popup targets from the T28.5c terminal trace.
|
||||
|
||||
This is a deterministic bridge from T28.5c's abort/no-recovery signal
|
||||
onto the existing popup/details chain. It does not mutate units or HTML.
|
||||
|
||||
issue #16 — 두 번째 트리거 추가: salvage cascade 가 정책 차단 터미널로
|
||||
소진됐는데 overflow 가 남은 zone (기존 트리거의
|
||||
``eligible_candidate_count == 0`` 조건은 이 분기에 미적용 — 그 후보들은
|
||||
사용자 수동 경로일 뿐 자동 소비가 T19d 로 금지돼 있어, 후보가 있다는
|
||||
이유로 무손실 popup 레버를 막으면 mdx04 처럼 영구 미발화가 됨).
|
||||
"""
|
||||
layout_reselection = presentation_reselection.get("layout_reselection") or {}
|
||||
terminal = (
|
||||
@@ -6129,36 +6146,119 @@ def _build_t28_5d_popup_targets(
|
||||
== "escalate_to_t28_5d_or_presentation_not_ready"
|
||||
and layout_reselection.get("allowed") is False
|
||||
)
|
||||
salvage_exhausted = bool(
|
||||
retry_trace
|
||||
and retry_trace.get("salvage_attempted")
|
||||
and not retry_trace.get("salvage_passed")
|
||||
and retry_trace.get("salvage_terminal_action")
|
||||
in _SALVAGE_POLICY_BLOCKED_TERMINALS
|
||||
)
|
||||
targets: list[dict] = []
|
||||
if terminal:
|
||||
for zone in presentation_reselection.get("zones") or []:
|
||||
overflow_px = int(zone.get("overflow_px") or 0)
|
||||
overflow_x_px = int(zone.get("overflow_x_px") or 0)
|
||||
if overflow_px <= 0 and overflow_x_px <= 0:
|
||||
continue
|
||||
if int(zone.get("eligible_candidate_count") or 0) != 0:
|
||||
continue
|
||||
source_ids = list(zone.get("source_section_ids") or [])
|
||||
unit = _unit_for_t28_5_source_ids(units, source_ids)
|
||||
if unit is None:
|
||||
continue
|
||||
targets.append({
|
||||
"position": zone.get("position"),
|
||||
"source_section_ids": source_ids,
|
||||
"current_template_id": zone.get("current_template_id"),
|
||||
"overflow_px": overflow_px,
|
||||
"overflow_x_px": overflow_x_px,
|
||||
"manual_geometry_applied": bool(zone.get("manual_geometry_applied")),
|
||||
})
|
||||
seen_positions: set = set()
|
||||
for zone in presentation_reselection.get("zones") or []:
|
||||
overflow_px = int(zone.get("overflow_px") or 0)
|
||||
overflow_x_px = int(zone.get("overflow_x_px") or 0)
|
||||
if overflow_px <= 0 and overflow_x_px <= 0:
|
||||
continue
|
||||
trigger_reason = None
|
||||
if terminal and int(zone.get("eligible_candidate_count") or 0) == 0:
|
||||
trigger_reason = "t28_5c_terminal_no_candidates"
|
||||
elif salvage_exhausted:
|
||||
trigger_reason = "salvage_exhausted_overflow_pressure"
|
||||
if trigger_reason is None:
|
||||
continue
|
||||
source_ids = list(zone.get("source_section_ids") or [])
|
||||
unit = _unit_for_t28_5_source_ids(units, source_ids)
|
||||
if unit is None or zone.get("position") in seen_positions:
|
||||
continue
|
||||
seen_positions.add(zone.get("position"))
|
||||
targets.append({
|
||||
"position": zone.get("position"),
|
||||
"source_section_ids": source_ids,
|
||||
"current_template_id": zone.get("current_template_id"),
|
||||
"overflow_px": overflow_px,
|
||||
"overflow_x_px": overflow_x_px,
|
||||
"manual_geometry_applied": bool(zone.get("manual_geometry_applied")),
|
||||
"trigger_reason": trigger_reason,
|
||||
})
|
||||
return {
|
||||
"triggered": bool(targets),
|
||||
"policy": "t28_5d_second_popup_pass_reuse_existing_chain",
|
||||
"terminal_next_step": presentation_reselection.get("next_step"),
|
||||
"salvage_exhausted_trigger": salvage_exhausted,
|
||||
"target_count": len(targets),
|
||||
"targets": targets,
|
||||
}
|
||||
|
||||
|
||||
# issue #16 — popup escalation 시 zone 본문을 예산 내로 트리밍하는 헬퍼.
|
||||
# 기존 구조 결함: slide_base 는 partial_html 을 그대로 렌더하고 <details>
|
||||
# 를 덧붙이기만 함 (preview_text 는 렌더 경로 어디에서도 미소비) — popup
|
||||
# 이 높이를 전혀 줄이지 못해 escalation 이 항상 실패했음. 트리밍된 라인의
|
||||
# 전문은 popup body(raw_content 원문)에 보존 — 자세히보기 원칙, 텍스트
|
||||
# 무손실 (rendered_text_coverage 는 details 내부 텍스트도 집계).
|
||||
_POPUP_TRIM_LINE_PX = 18 # 13px font × 1.4lh 근사 (보수적)
|
||||
|
||||
|
||||
def _trim_slot_payload_for_overflow(
|
||||
slot_payload: dict, overflow_px: int,
|
||||
) -> tuple[dict, int]:
|
||||
"""list-값 슬롯에서 overflow 해소에 필요한 만큼 라인을 제거 (긴 슬롯부터
|
||||
round-robin, 슬롯당 최소 1라인 유지, label/title/_slot_count 무접촉).
|
||||
|
||||
두 형태를 지원:
|
||||
- top-level list-of-str 슬롯 (예: ``pill_1_body``)
|
||||
- list-of-dict 컬럼 내부의 list-of-str 값 (예: ``pillars[i].lines`` —
|
||||
f13b three_parallel_requirements 계열)
|
||||
|
||||
Returns (새 payload, 제거 라인 수). 라인 경계에서만 자름 — 라인 내부
|
||||
truncate 금지 (IMP-35 preview 철학과 동일).
|
||||
"""
|
||||
if overflow_px <= 0 or not isinstance(slot_payload, dict):
|
||||
return dict(slot_payload or {}), 0
|
||||
need = int(overflow_px // _POPUP_TRIM_LINE_PX) + 1
|
||||
new_payload = copy.deepcopy(slot_payload)
|
||||
|
||||
def _is_lines(v) -> bool:
|
||||
# 라인 리스트 두 형태: list-of-str (verbatim builder 계열) 또는
|
||||
# list-of-{text,...} line-object (f13b pillars.sections.text_lines 계열)
|
||||
if not isinstance(v, list) or not v:
|
||||
return False
|
||||
if all(isinstance(x, str) for x in v):
|
||||
return True
|
||||
return all(
|
||||
isinstance(x, dict) and isinstance(x.get("text"), str) for x in v
|
||||
)
|
||||
|
||||
# trimmable pool: (container_dict, key) 참조를 재귀 수집 (깊이 제한 5)
|
||||
pools: list[tuple[dict, str]] = []
|
||||
|
||||
def _collect(node, depth: int = 0) -> None:
|
||||
if depth > 5:
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if _is_lines(v):
|
||||
pools.append((node, k))
|
||||
elif isinstance(v, (dict, list)):
|
||||
_collect(v, depth + 1)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
if isinstance(item, (dict, list)):
|
||||
_collect(item, depth + 1)
|
||||
|
||||
_collect(new_payload)
|
||||
trimmed = 0
|
||||
while trimmed < need:
|
||||
candidates = [(c, k) for c, k in pools if len(c[k]) > 1]
|
||||
if not candidates:
|
||||
break
|
||||
container, key = max(candidates, key=lambda p: len(p[0][p[1]]))
|
||||
container[key] = container[key][:-1]
|
||||
trimmed += 1
|
||||
return new_payload, trimmed
|
||||
|
||||
|
||||
def _t28_5d_popup_escalation_plan(target: dict) -> dict:
|
||||
return {
|
||||
"action": "details_popup_escalation",
|
||||
@@ -6195,16 +6295,23 @@ def _attempt_t28_5d_popup_second_pass(
|
||||
postprocess_html=None,
|
||||
render_func=render_slide,
|
||||
overflow_check_func=run_overflow_check,
|
||||
retry_trace: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""T28.5d: re-render final.html with existing popup/details payloads.
|
||||
|
||||
The pass is intentionally after T28.5c because its trigger depends on
|
||||
presentation_fit + reselection trace. It is not marker-only: a candidate is
|
||||
rendered, measured, and promoted only if the visual check passes.
|
||||
|
||||
issue #16 — 두 가지 확장:
|
||||
1. salvage 정책 차단 소진 시에도 트리거 (retry_trace 기반)
|
||||
2. 대상 zone 의 본문 라인을 overflow 예산만큼 트리밍 — popup 이 실제로
|
||||
높이를 줄이도록 (전문은 popup body 에 보존, 무손실)
|
||||
"""
|
||||
target_plan = _build_t28_5d_popup_targets(
|
||||
units=units,
|
||||
presentation_reselection=presentation_reselection,
|
||||
retry_trace=retry_trace,
|
||||
)
|
||||
result = {
|
||||
"triggered": target_plan["triggered"],
|
||||
@@ -6244,30 +6351,81 @@ def _attempt_t28_5d_popup_second_pass(
|
||||
)
|
||||
zone.update(popup_payload)
|
||||
zone["t28_5d_popup_escalated"] = True
|
||||
# issue #16 — 본문 라인 트리밍으로 높이 실감소 (전문은 popup body).
|
||||
trimmed_payload, trimmed_lines = _trim_slot_payload_for_overflow(
|
||||
zone.get("slot_payload") or {},
|
||||
int(target.get("overflow_px") or 0),
|
||||
)
|
||||
if trimmed_lines:
|
||||
zone["slot_payload"] = trimmed_payload
|
||||
zone["popup_trimmed_lines"] = trimmed_lines
|
||||
applied_targets.append({
|
||||
**target,
|
||||
"popup_plan": plan,
|
||||
"trimmed_lines": trimmed_lines,
|
||||
})
|
||||
|
||||
if not applied_targets:
|
||||
result["failure_reason"] = "target_units_or_zones_not_found"
|
||||
return result
|
||||
|
||||
candidate_html = render_func(
|
||||
slide_title,
|
||||
slide_footer,
|
||||
zones_candidate,
|
||||
layout_preset,
|
||||
layout_css,
|
||||
gap_px=gap_px,
|
||||
)
|
||||
if postprocess_html is not None:
|
||||
candidate_html = postprocess_html(candidate_html)
|
||||
|
||||
# issue #16 — 측정 피드백 트림 루프 (최대 3회). 라인당 px 추정치(18)가
|
||||
# frame 마다 다를 수 있어, 후보 렌더의 측정 잔여 excess 로 추가 트림.
|
||||
candidate_path = run_dir / "t28_5d_popup_candidate.html"
|
||||
candidate_path.write_text(candidate_html, encoding="utf-8")
|
||||
candidate_overflow = overflow_check_func(candidate_path)
|
||||
passed = bool(candidate_overflow.get("passed", False))
|
||||
target_positions = {t.get("position") for t in applied_targets}
|
||||
candidate_html = ""
|
||||
candidate_overflow: dict = {}
|
||||
passed = False
|
||||
for _trim_round in range(3):
|
||||
candidate_html = render_func(
|
||||
slide_title,
|
||||
slide_footer,
|
||||
zones_candidate,
|
||||
layout_preset,
|
||||
layout_css,
|
||||
gap_px=gap_px,
|
||||
)
|
||||
if postprocess_html is not None:
|
||||
candidate_html = postprocess_html(candidate_html)
|
||||
candidate_path.write_text(candidate_html, encoding="utf-8")
|
||||
candidate_overflow = overflow_check_func(candidate_path)
|
||||
passed = bool(candidate_overflow.get("passed", False))
|
||||
if passed:
|
||||
break
|
||||
# 대상 zone 의 측정 잔여 excess 산출 → 추가 트림
|
||||
extra_trimmed = 0
|
||||
for oz in candidate_overflow.get("zones") or []:
|
||||
pos = oz.get("position")
|
||||
if pos not in target_positions:
|
||||
continue
|
||||
remaining = max(
|
||||
int(round(float(oz.get("excess_y") or 0))),
|
||||
max(
|
||||
[int(round(float(c.get("excess_y") or 0)))
|
||||
for c in oz.get("clipped_inner") or []] or [0]
|
||||
),
|
||||
)
|
||||
if remaining <= 0:
|
||||
continue
|
||||
zone = zone_by_position.get(pos)
|
||||
if zone is None:
|
||||
continue
|
||||
more_payload, more = _trim_slot_payload_for_overflow(
|
||||
zone.get("slot_payload") or {}, remaining,
|
||||
)
|
||||
if more:
|
||||
zone["slot_payload"] = more_payload
|
||||
zone["popup_trimmed_lines"] = (
|
||||
int(zone.get("popup_trimmed_lines") or 0) + more
|
||||
)
|
||||
for t in applied_targets:
|
||||
if t.get("position") == pos:
|
||||
t["trimmed_lines"] = (
|
||||
int(t.get("trimmed_lines") or 0) + more
|
||||
)
|
||||
extra_trimmed += more
|
||||
if extra_trimmed == 0:
|
||||
break # 더 줄일 여지 없음 — 정직 실패
|
||||
result.update({
|
||||
"attempted": True,
|
||||
"passed": passed,
|
||||
@@ -11247,6 +11405,8 @@ def run_phase_z2_mvp1(
|
||||
override_slide_css=override_slide_css,
|
||||
mdx_source_text=mdx_source_text,
|
||||
),
|
||||
# issue #16 — salvage 정책 차단 소진 트리거용 (retry_trace 기반)
|
||||
retry_trace=retry_trace,
|
||||
)
|
||||
slide_status["presentation_popup"] = t28_5d_popup_result
|
||||
if t28_5d_popup_result.get("promoted"):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"schema_version": 2,
|
||||
"axis": "IMP-89 89-a u4 — final.html SHA baseline captured via FULL run_phase_z2_mvp1 pipeline (flag OFF / default)",
|
||||
"description": "Frozen SHA-256 of `final.html` bytes (the artifact written to disk at src/phase_z2_pipeline.py:5994-5996) captured by running the full Phase Z pipeline end-to-end for each mdx 01-05 under PHASE_Z_B4_MAPPER_SOURCE=OFF. Under flag OFF the 89-a selector `_select_mapper_template_id(plan, T)` returns `T` verbatim, so the mapper input is byte-identical to the pre-89-a legacy call shape `map_mdx_to_slots(section, unit.frame_template_id)` — the rendered HTML and therefore the final.html SHA match the pre-89-a baseline. The u4 regression test runs the same pipeline shape under flag OFF and asserts SHA equality. Regenerate only when an upstream mapper/render/template delta is deliberately reviewed and accepted.",
|
||||
"captured_at_utc": "2026-07-03T03:39:01Z",
|
||||
"captured_at_utc": "2026-07-07T00:11:02Z",
|
||||
"renderer": {
|
||||
"entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1",
|
||||
"write_site": "src/phase_z2_pipeline.py:5994-5996",
|
||||
@@ -40,9 +40,9 @@
|
||||
"04.mdx": {
|
||||
"mdx_file": "04.mdx",
|
||||
"run_id": "89a_baseline_04",
|
||||
"final_html_size_bytes": 40206,
|
||||
"sha256": "8ba35bc6642b0e651cd46b9216070bd49052b016807611d9771fd630ca7a2613",
|
||||
"pipeline_exit_code": 1
|
||||
"final_html_size_bytes": 42265,
|
||||
"sha256": "9c680c6f99c2e401f72edfdaf65e527e01263223eaaee70b9af75cb273ad3d3b",
|
||||
"pipeline_exit_code": null
|
||||
},
|
||||
"05.mdx": {
|
||||
"mdx_file": "05.mdx",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"schema_version": 1,
|
||||
"axis": "IMP-95 u8 — final.html SHA baseline captured via FULL run_phase_z2_mvp1 pipeline under PHASE_Z_B4_V4_EVIDENCE=OFF and PHASE_Z_B4_MAPPER_SOURCE=OFF (defaults)",
|
||||
"description": "Frozen SHA-256 of `final.html` bytes (production write site src/phase_z2_pipeline.py:5994-5996) for mdx 01/02/04/05 under PHASE_Z_B4_V4_EVIDENCE OFF. Under flag OFF, IMP-95 (u1~u7) is strictly no-op for final.html (planner branch falls through to legacy _select_frame at u3; u4/u5/u6 additive telemetry confined to placement_trace per the trace-only docstring at src/phase_z2_pipeline.py:86). The u8 regression test asserts SHA equality with these frozen values, so any future code change that drifts the flag-OFF render output produces a mismatch and breaks the test. mdx 03 is excluded per Stage 2 u8 scope (mdx 03 정비 LOCK). Regenerate only when an upstream delta is reviewed and accepted as the new pre-IMP-95 reference.",
|
||||
"captured_at_utc": "2026-07-03T00:02:27Z",
|
||||
"captured_at_utc": "2026-07-07T00:09:32Z",
|
||||
"renderer": {
|
||||
"entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1",
|
||||
"write_site": "src/phase_z2_pipeline.py:5994-5996",
|
||||
@@ -32,9 +32,9 @@
|
||||
"04.mdx": {
|
||||
"mdx_file": "04.mdx",
|
||||
"run_id": "imp95_baseline_04",
|
||||
"final_html_size_bytes": 40206,
|
||||
"sha256": "8ba35bc6642b0e651cd46b9216070bd49052b016807611d9771fd630ca7a2613",
|
||||
"pipeline_exit_code": 1
|
||||
"final_html_size_bytes": 42265,
|
||||
"sha256": "9c680c6f99c2e401f72edfdaf65e527e01263223eaaee70b9af75cb273ad3d3b",
|
||||
"pipeline_exit_code": null
|
||||
},
|
||||
"05.mdx": {
|
||||
"mdx_file": "05.mdx",
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""issue #16 — overflow 압력 기반 popup escalation 테스트 (MDX04 height-fit).
|
||||
|
||||
배경 (실측):
|
||||
- salvage cascade 실효 수단은 cross_zone/glue 2단뿐 (font_step=T28.5 차단,
|
||||
layout_adjust=T19d 차단) — 소진 시 popup 이 유일한 무손실 자동 레버
|
||||
- 기존 T28.5d 트리거는 eligible_candidate_count==0 요구 → 후보가 '존재만'
|
||||
해도 영구 미발화 (mdx04: 후보 2개 있으나 자동 소비는 정책 금지)
|
||||
- slide_base 는 partial 을 그대로 렌더 + <details> 추가 — preview_text 는
|
||||
렌더 미소비라 popup 이 높이를 못 줄였음 → 라인 트리밍으로 실감소
|
||||
|
||||
계약:
|
||||
- 트리밍은 라인 경계에서만, 슬롯당 최소 1라인, label/title 무접촉
|
||||
- 트리밍 전문은 popup body(raw_content 원문)에 보존 — 텍스트 무손실
|
||||
- promote 는 candidate visual pass 시에만 (기존 정책 유지)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
_build_t28_5d_popup_targets,
|
||||
_trim_slot_payload_for_overflow,
|
||||
)
|
||||
|
||||
|
||||
def _unit(ids):
|
||||
return SimpleNamespace(source_section_ids=list(ids), raw_content="- 본문", title="t")
|
||||
|
||||
|
||||
def _reselection(zones, *, triggered=False, next_step="frame_or_layout_candidate_available",
|
||||
layout_allowed=True):
|
||||
return {
|
||||
"triggered": triggered,
|
||||
"next_step": next_step,
|
||||
"layout_reselection": {"allowed": layout_allowed},
|
||||
"zones": zones,
|
||||
}
|
||||
|
||||
|
||||
_BLOCKED_RT = {
|
||||
"salvage_attempted": True,
|
||||
"salvage_passed": False,
|
||||
"salvage_terminal_action": "layout_adjust_blocked_no_silent_layout_mutation",
|
||||
}
|
||||
|
||||
|
||||
# ── 트리거 확장 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_salvage_exhausted_triggers_even_with_candidates():
|
||||
"""mdx04 재현: 후보 2개 존재 + salvage 정책 차단 소진 → 트리거."""
|
||||
zones = [{
|
||||
"position": "bottom", "source_section_ids": ["04-2"],
|
||||
"current_template_id": "three_parallel_requirements",
|
||||
"overflow_px": 70, "eligible_candidate_count": 2,
|
||||
}]
|
||||
plan = _build_t28_5d_popup_targets(
|
||||
units=[_unit(["04-2"])],
|
||||
presentation_reselection=_reselection(zones),
|
||||
retry_trace=_BLOCKED_RT,
|
||||
)
|
||||
assert plan["triggered"] is True
|
||||
assert plan["salvage_exhausted_trigger"] is True
|
||||
assert plan["targets"][0]["trigger_reason"] == "salvage_exhausted_overflow_pressure"
|
||||
|
||||
|
||||
def test_font_step_block_terminal_also_triggers():
|
||||
zones = [{"position": "top", "source_section_ids": ["05-1"],
|
||||
"overflow_px": 30, "eligible_candidate_count": 1}]
|
||||
rt = {**_BLOCKED_RT,
|
||||
"salvage_terminal_action": "font_step_compression_blocked_no_font_shrink"}
|
||||
plan = _build_t28_5d_popup_targets(
|
||||
units=[_unit(["05-1"])],
|
||||
presentation_reselection=_reselection(zones), retry_trace=rt,
|
||||
)
|
||||
assert plan["triggered"] is True
|
||||
|
||||
|
||||
def test_no_overflow_zone_not_targeted():
|
||||
zones = [{"position": "top", "source_section_ids": ["01-1"],
|
||||
"overflow_px": 0, "eligible_candidate_count": 0}]
|
||||
plan = _build_t28_5d_popup_targets(
|
||||
units=[_unit(["01-1"])],
|
||||
presentation_reselection=_reselection(zones), retry_trace=_BLOCKED_RT,
|
||||
)
|
||||
assert plan["triggered"] is False
|
||||
|
||||
|
||||
def test_salvage_passed_does_not_trigger_new_branch():
|
||||
zones = [{"position": "bottom", "source_section_ids": ["04-2"],
|
||||
"overflow_px": 70, "eligible_candidate_count": 2}]
|
||||
rt = {"salvage_attempted": True, "salvage_passed": True,
|
||||
"salvage_terminal_action": None}
|
||||
plan = _build_t28_5d_popup_targets(
|
||||
units=[_unit(["04-2"])],
|
||||
presentation_reselection=_reselection(zones), retry_trace=rt,
|
||||
)
|
||||
assert plan["triggered"] is False
|
||||
|
||||
|
||||
def test_legacy_terminal_trigger_preserved():
|
||||
"""기존 트리거(터미널 + 후보 0)는 retry_trace 없이도 동작 유지."""
|
||||
zones = [{"position": "top", "source_section_ids": ["05-1"],
|
||||
"overflow_px": 40, "eligible_candidate_count": 0}]
|
||||
plan = _build_t28_5d_popup_targets(
|
||||
units=[_unit(["05-1"])],
|
||||
presentation_reselection=_reselection(
|
||||
zones, triggered=True,
|
||||
next_step="escalate_to_t28_5d_or_presentation_not_ready",
|
||||
layout_allowed=False,
|
||||
),
|
||||
)
|
||||
assert plan["triggered"] is True
|
||||
assert plan["targets"][0]["trigger_reason"] == "t28_5c_terminal_no_candidates"
|
||||
|
||||
|
||||
# ── 라인 트리밍 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_trim_removes_lines_from_longest_slot_first():
|
||||
payload = {
|
||||
"title": "제목",
|
||||
"pillar_1_body": ["a", "b", "c", "d", "e"],
|
||||
"pillar_2_body": ["x", "y"],
|
||||
"pillar_1_label": "라벨",
|
||||
}
|
||||
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=70)
|
||||
# 70px → ceil(70/18)+... = 70//18+1 = 4 lines
|
||||
assert n == 4
|
||||
assert new["title"] == "제목" # 비-list 무접촉
|
||||
assert new["pillar_1_label"] == "라벨"
|
||||
assert len(new["pillar_1_body"]) + len(new["pillar_2_body"]) == 7 - 4
|
||||
assert len(new["pillar_2_body"]) >= 1 # 슬롯당 최소 1라인
|
||||
# 원본 불변 (새 dict)
|
||||
assert len(payload["pillar_1_body"]) == 5
|
||||
|
||||
|
||||
def test_trim_never_empties_a_slot():
|
||||
payload = {"body": ["only-line"]}
|
||||
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=100)
|
||||
assert n == 0
|
||||
assert new["body"] == ["only-line"]
|
||||
|
||||
|
||||
def test_trim_zero_overflow_noop():
|
||||
payload = {"body": ["a", "b"]}
|
||||
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=0)
|
||||
assert n == 0 and new == payload
|
||||
|
||||
|
||||
def test_trim_stops_when_all_slots_at_minimum():
|
||||
payload = {"a": ["1", "2"], "b": ["1", "2"]}
|
||||
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=500)
|
||||
assert n == 2 # 각 슬롯 1라인까지만
|
||||
assert new["a"] == ["1"] and new["b"] == ["1"]
|
||||
|
||||
|
||||
def test_trim_nested_list_of_dict_columns():
|
||||
"""중첩 1레벨 — pillars[i].lines 트리밍."""
|
||||
payload = {"title": "t", "pillars": [
|
||||
{"label": "A", "lines": ["1", "2", "3", "4"]},
|
||||
{"label": "B", "lines": ["1", "2", "3", "4"]},
|
||||
]}
|
||||
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=70)
|
||||
assert n == 4
|
||||
assert new["pillars"][0]["label"] == "A" # label 무접촉
|
||||
assert len(new["pillars"][0]["lines"]) >= 1
|
||||
total = sum(len(p["lines"]) for p in new["pillars"])
|
||||
assert total == 8 - 4
|
||||
assert len(payload["pillars"][0]["lines"]) == 4 # 원본 불변 (deepcopy)
|
||||
|
||||
|
||||
def test_trim_f13b_real_shape_text_line_objects():
|
||||
"""mdx04 실측 형태 — pillars[i].sections[j].text_lines[k]={'text':...}
|
||||
3중 중첩 + line-object 리스트 (최초 두 구현이 못 찾던 케이스)."""
|
||||
def lines(n):
|
||||
return [{"text": f"내용 {i}", "indent": 0} for i in range(n)]
|
||||
payload = {"title": "2. DX 추진의 실태", "pillars": [
|
||||
{"label": "2.1", "sections": [{"heading": "", "text_lines": lines(9)}]},
|
||||
{"label": "2.2", "sections": [{"heading": "", "text_lines": lines(9)}]},
|
||||
]}
|
||||
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=70)
|
||||
assert n == 4 # 70//18+1
|
||||
total = sum(
|
||||
len(s["text_lines"]) for p in new["pillars"] for s in p["sections"]
|
||||
)
|
||||
assert total == 18 - 4
|
||||
# 원본 불변 + label/heading 무접촉
|
||||
assert len(payload["pillars"][0]["sections"][0]["text_lines"]) == 9
|
||||
assert new["pillars"][0]["label"] == "2.1"
|
||||
Reference in New Issue
Block a user