Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2827622858 | ||
|
|
f3bff898fb | ||
|
|
e9b3d2e9c0 | ||
|
|
7a52cebfaa | ||
|
|
7d5639ad72 | ||
|
|
56619a0239 | ||
|
|
a79bd8bc43 | ||
|
|
0fb168befc | ||
|
|
1fb973297f |
@@ -186,6 +186,12 @@ export default function SlideCanvas({
|
|||||||
// 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
|
// 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
|
||||||
const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning;
|
const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning;
|
||||||
|
|
||||||
|
// IMP-14 (Step 13 A-4) — backend slide_base.html 가 embedded vs standalone CSS
|
||||||
|
// contract 를 `?embedded=1` query 로 소유. 기존 query string 보존하면서 flag 만 추가.
|
||||||
|
const embeddedSrc = finalHtmlUrl
|
||||||
|
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로
|
// wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로
|
||||||
// top-left origin scale 후 wrapper 안에 정확히 맞춤.
|
// top-left origin scale 후 wrapper 안에 정확히 맞춤.
|
||||||
const W_SCALED = SLIDE_W * scale;
|
const W_SCALED = SLIDE_W * scale;
|
||||||
@@ -283,37 +289,21 @@ export default function SlideCanvas({
|
|||||||
>
|
>
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
src={finalHtmlUrl}
|
src={embeddedSrc}
|
||||||
title="Phase Z 렌더 결과"
|
title="Phase Z 렌더 결과"
|
||||||
className="w-full h-full border-0 block"
|
className="w-full h-full border-0 block"
|
||||||
scrolling="no"
|
scrolling="no"
|
||||||
sandbox="allow-same-origin"
|
sandbox="allow-same-origin"
|
||||||
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
// final.html 은 standalone 표시용으로 body 에 padding / flex center /
|
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
|
||||||
// min-height: 100vh 가 있어서, iframe 안에서는 슬라이드가 잘림.
|
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
|
||||||
// .slide (1280×720) 만 보이도록 reset CSS 를 contentDocument 에 주입.
|
// reset CSS 를 contentDocument 에 inject 하지 않음. embedded query 가
|
||||||
|
// backend auto-mode detection script 를 trigger 해서 html.embedded
|
||||||
|
// class 를 붙이고 standalone-only body 규칙을 reset.
|
||||||
try {
|
try {
|
||||||
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
const style = doc.createElement("style");
|
|
||||||
style.textContent = `
|
|
||||||
html, body {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
min-height: 0 !important;
|
|
||||||
height: 720px !important;
|
|
||||||
width: 1280px !important;
|
|
||||||
background: transparent !important;
|
|
||||||
display: block !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
}
|
|
||||||
.slide {
|
|
||||||
box-shadow: none !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
doc.head.appendChild(style);
|
|
||||||
|
|
||||||
// 2026-05-14 — slide-level override CSS (catalog/template 무변).
|
// 2026-05-14 — slide-level override CSS (catalog/template 무변).
|
||||||
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
|
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
|
||||||
@@ -612,6 +602,28 @@ export default function SlideCanvas({
|
|||||||
: null;
|
: null;
|
||||||
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
|
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
|
||||||
|
|
||||||
|
// IMP-11 u4: active frame lookup — distinct axis from preview.
|
||||||
|
// preview is shown only when override differs from default; active is
|
||||||
|
// always defined as override-if-present-else-default. Used by u5 to
|
||||||
|
// compare the active frame's catalog min_height_px against zone height.
|
||||||
|
const activeFrameId = overrideFrameId ?? defaultFrameId;
|
||||||
|
const activeCandidate = activeFrameId
|
||||||
|
? region?.frame_candidates?.find((c) => c.id === activeFrameId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// IMP-11 u5: catalog min_height_px violation hint. height is already
|
||||||
|
// a fraction of SLIDE_H (1280x720 logical px coordinate space), so
|
||||||
|
// logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
|
||||||
|
// not re-multiplied (double-apply would shrink the comparison value).
|
||||||
|
// Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
|
||||||
|
const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
|
||||||
|
const minHeightPx = activeCandidate?.minHeightPx ?? null;
|
||||||
|
const belowMinHeight =
|
||||||
|
isPendingLayout &&
|
||||||
|
minHeightPx != null &&
|
||||||
|
zoneHeightPx != null &&
|
||||||
|
zoneHeightPx < minHeightPx;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={zone.id}
|
key={zone.id}
|
||||||
@@ -695,6 +707,18 @@ export default function SlideCanvas({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* IMP-11 u5: red border + 'min H Npx' badge when zone height
|
||||||
|
is below the active frame's catalog min_height_px. Visual
|
||||||
|
hint only, no clamp/resize behavior change. */}
|
||||||
|
{belowMinHeight && minHeightPx != null && (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 pointer-events-none border-2 border-red-500" />
|
||||||
|
<span className="absolute bottom-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-red-500 text-white shadow pointer-events-none">
|
||||||
|
min H {minHeightPx}px
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
|
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
|
||||||
부 라벨 = backend zone position (top, bottom, primary). */}
|
부 라벨 = backend zone position (top, bottom, primary). */}
|
||||||
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
|
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
|
||||||
|
|||||||
@@ -527,6 +527,31 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
{runMeta.status}
|
{runMeta.status}
|
||||||
</span>
|
</span>
|
||||||
|
{runMeta.filtered_section_ids.length > 0 && (
|
||||||
|
<details className="relative">
|
||||||
|
<summary className="text-[10px] font-bold px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded uppercase tracking-wider cursor-pointer list-none">
|
||||||
|
Filtered: {runMeta.filtered_section_ids.length}
|
||||||
|
</summary>
|
||||||
|
<div className="absolute top-full mt-1 left-0 z-50 bg-white border border-slate-200 rounded shadow-lg p-3 w-96 max-h-96 overflow-y-auto">
|
||||||
|
{runMeta.filtered_section_reasons.map((r, i) => (
|
||||||
|
<div key={i} className="mb-2 pb-2 border-b border-slate-100 last:border-0 last:mb-0 last:pb-0 text-[11px]">
|
||||||
|
<div className="font-mono text-slate-700">{r.section_ids.join(", ")}</div>
|
||||||
|
<div className="text-slate-500">selection_state: <span className="font-mono">{r.selection_state}</span></div>
|
||||||
|
{r.merge_type && <div className="text-slate-500">merge_type: <span className="font-mono">{r.merge_type}</span></div>}
|
||||||
|
{r.template_id && <div className="text-slate-500">template_id: <span className="font-mono">{r.template_id}</span></div>}
|
||||||
|
{r.v4_label && <div className="text-slate-500">v4_label: <span className="font-mono">{r.v4_label}</span></div>}
|
||||||
|
{r.phase_z_status && <div className="text-slate-500">phase_z_status: <span className="font-mono">{r.phase_z_status}</span></div>}
|
||||||
|
{r.score !== null && <div className="text-slate-500">score: <span className="font-mono">{r.score}</span></div>}
|
||||||
|
{r.source && <div className="text-slate-500">source: <span className="font-mono">{r.source}</span></div>}
|
||||||
|
{r.position && <div className="text-slate-500">position: <span className="font-mono">{r.position}</span></div>}
|
||||||
|
<ul className="mt-1 list-disc list-inside text-slate-600">
|
||||||
|
{r.filter_reasons.map((reason, j) => <li key={j} className="font-mono">{reason}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -207,6 +207,22 @@ export async function exportSlidePlan(slidePlan: SlidePlan, userSelection: any):
|
|||||||
// step20_slide_status.json → 최종 상태 (PASS / RENDERED_WITH_VISUAL_REGRESSION / ...)
|
// step20_slide_status.json → 최종 상태 (PASS / RENDERED_WITH_VISUAL_REGRESSION / ...)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// IMP-10 D-1 : verbatim mirror of step20_slide_status.json.data.filtered_section_reasons[]
|
||||||
|
// schema (src/phase_z2_pipeline.py:2217-2278). `source` / `position` only present on
|
||||||
|
// the override-uncovered additive variant. Strings rendered verbatim — no enum redefinition.
|
||||||
|
export interface FilteredSectionReason {
|
||||||
|
section_ids: string[];
|
||||||
|
merge_type: string | null;
|
||||||
|
template_id: string | null;
|
||||||
|
v4_label: string | null;
|
||||||
|
phase_z_status: string | null;
|
||||||
|
score: number | null;
|
||||||
|
selection_state: string;
|
||||||
|
filter_reasons: string[];
|
||||||
|
source?: string;
|
||||||
|
position?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunMeta {
|
export interface RunMeta {
|
||||||
run_id: string;
|
run_id: string;
|
||||||
mdx_path: string;
|
mdx_path: string;
|
||||||
@@ -214,6 +230,8 @@ export interface RunMeta {
|
|||||||
status: "PASS" | "RENDERED_WITH_VISUAL_REGRESSION" | "PARTIAL_COVERAGE" | "ABORTED" | string;
|
status: "PASS" | "RENDERED_WITH_VISUAL_REGRESSION" | "PARTIAL_COVERAGE" | "ABORTED" | string;
|
||||||
visual_check_passed: boolean;
|
visual_check_passed: boolean;
|
||||||
full_mdx_coverage: boolean;
|
full_mdx_coverage: boolean;
|
||||||
|
filtered_section_ids: string[]; // step20 filtered_section_ids
|
||||||
|
filtered_section_reasons: FilteredSectionReason[]; // step20 filtered_section_reasons
|
||||||
preview_url: string; // /data/runs/{runId}/preview.png
|
preview_url: string; // /data/runs/{runId}/preview.png
|
||||||
final_html_url: string; // /data/runs/{runId}/final.html
|
final_html_url: string; // /data/runs/{runId}/final.html
|
||||||
layout_candidates: string[]; // step07 layout_candidates list
|
layout_candidates: string[]; // step07 layout_candidates list
|
||||||
@@ -393,6 +411,8 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
|||||||
status: slideStatus.data?.overall ?? "UNKNOWN",
|
status: slideStatus.data?.overall ?? "UNKNOWN",
|
||||||
visual_check_passed: slideStatus.data?.visual_check_passed ?? false,
|
visual_check_passed: slideStatus.data?.visual_check_passed ?? false,
|
||||||
full_mdx_coverage: slideStatus.data?.full_mdx_coverage ?? false,
|
full_mdx_coverage: slideStatus.data?.full_mdx_coverage ?? false,
|
||||||
|
filtered_section_ids: slideStatus.data?.filtered_section_ids ?? [],
|
||||||
|
filtered_section_reasons: slideStatus.data?.filtered_section_reasons ?? [],
|
||||||
preview_url: `${base}/preview.png`,
|
preview_url: `${base}/preview.png`,
|
||||||
final_html_url: `${base}/final.html`,
|
final_html_url: `${base}/final.html`,
|
||||||
layout_candidates: layout.data?.layout_candidates ?? [],
|
layout_candidates: layout.data?.layout_candidates ?? [],
|
||||||
@@ -507,6 +527,10 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
|||||||
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
|
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
|
||||||
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined.
|
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined.
|
||||||
catalogRegistered: c.catalog_registered,
|
catalogRegistered: c.catalog_registered,
|
||||||
|
// backend step09 의 min_height_px (frame_contracts.yaml visual_hints.min_height_px).
|
||||||
|
// logical 1280x720 px 좌표계. contract 미등록 또는 visual_hints 부재 시 undefined.
|
||||||
|
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined (graceful).
|
||||||
|
minHeightPx: c.min_height_px ?? undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const displayStrategy = (
|
const displayStrategy = (
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ export interface FrameCandidate {
|
|||||||
/** backend frame_contracts.yaml 에 catalog 등록 여부. false 면 사용자가 override
|
/** backend frame_contracts.yaml 에 catalog 등록 여부. false 면 사용자가 override
|
||||||
* 시도해도 Step 7-A 가 skip (render path 미연결). UI 회색 + "render path 미적용" 표시. */
|
* 시도해도 Step 7-A 가 skip (render path 미연결). UI 회색 + "render path 미적용" 표시. */
|
||||||
catalogRegistered?: boolean;
|
catalogRegistered?: boolean;
|
||||||
|
/** IMP-11 D-2 — frame contract visual_hints.min_height_px (logical 1280x720 px).
|
||||||
|
* Source = templates/phase_z2/catalog/frame_contracts.yaml visual_hints.min_height_px.
|
||||||
|
* Undefined when contract unregistered or visual_hints absent (frontend tolerates undefined). */
|
||||||
|
minHeightPx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+1480
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
|||||||
|
"""IMP-13 build-time preview.png renderer for figma_to_html_agent/blocks/<frame_id> (u1-u6)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse, hashlib, json, sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_BLOCKS_DIR = REPO_ROOT / "figma_to_html_agent" / "blocks"
|
||||||
|
DEFAULT_MANIFEST = DEFAULT_BLOCKS_DIR / "_preview_manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FrameRow:
|
||||||
|
frame_id: str
|
||||||
|
block_dir: Path
|
||||||
|
index_html_path: Path
|
||||||
|
preview_png_path: Path
|
||||||
|
has_index: bool
|
||||||
|
has_preview: bool
|
||||||
|
|
||||||
|
|
||||||
|
def discover(blocks_dir: Path) -> List[FrameRow]:
|
||||||
|
if not blocks_dir.is_dir():
|
||||||
|
return []
|
||||||
|
rows: List[FrameRow] = []
|
||||||
|
for entry in sorted(blocks_dir.iterdir()):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
idx, png = entry / "index.html", entry / "preview.png"
|
||||||
|
rows.append(FrameRow(entry.name, entry, idx, png, idx.is_file(), png.is_file()))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _build_driver() -> Any:
|
||||||
|
"""Headless Chrome driver. Mirrors the run_overflow_check chromedriver-candidate + headless options pattern.
|
||||||
|
Inline per Stage 2 (no shared module). Per-frame window-size is set by the caller (u3), not here."""
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
options = Options()
|
||||||
|
options.add_argument("--headless=new")
|
||||||
|
options.add_argument("--no-sandbox")
|
||||||
|
options.add_argument("--disable-dev-shm-usage")
|
||||||
|
candidates = [REPO_ROOT / "chromedriver", REPO_ROOT / "chromedriver.exe"]
|
||||||
|
last_err: Exception | None = None
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_file():
|
||||||
|
try:
|
||||||
|
return webdriver.Chrome(service=Service(str(path)), options=options)
|
||||||
|
except Exception as exc: # noqa: BLE001 — propagate via aggregated error
|
||||||
|
last_err = exc
|
||||||
|
try:
|
||||||
|
return webdriver.Chrome(options=options)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise RuntimeError(f"selenium init failed: {last_err or exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def render_one(driver: Any, row: FrameRow) -> tuple[int, int, Path]:
|
||||||
|
"""Render row.index_html_path -> row.preview_png_path via WebElement screenshot. Returns (w, h, path) or raises.
|
||||||
|
Driver is injected (caller owns lifecycle). .slide bbox drives window-size; no hardcoded slide dimensions."""
|
||||||
|
if not row.has_index:
|
||||||
|
raise FileNotFoundError(f"missing index.html: {row.index_html_path}")
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
driver.get(row.index_html_path.resolve().as_uri())
|
||||||
|
driver.set_script_timeout(15)
|
||||||
|
driver.execute_async_script(
|
||||||
|
"const cb=arguments[arguments.length-1];"
|
||||||
|
"(document.fonts&&document.fonts.ready?document.fonts.ready:Promise.resolve()).then(()=>cb(true));"
|
||||||
|
)
|
||||||
|
rect = driver.execute_script(
|
||||||
|
"const el=document.querySelector('.slide');"
|
||||||
|
"if(!el)return null;"
|
||||||
|
"const r=el.getBoundingClientRect();"
|
||||||
|
"return [Math.round(r.width), Math.round(r.height)];"
|
||||||
|
)
|
||||||
|
if not rect:
|
||||||
|
raise RuntimeError(f".slide not found in {row.index_html_path}")
|
||||||
|
w, h = int(rect[0]), int(rect[1])
|
||||||
|
driver.set_window_size(w, h)
|
||||||
|
el = driver.find_element(By.CSS_SELECTOR, ".slide")
|
||||||
|
row.preview_png_path.write_bytes(el.screenshot_as_png)
|
||||||
|
return w, h, row.preview_png_path
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def is_unchanged(row: FrameRow, last_entry: Optional[Dict[str, Any]]) -> bool:
|
||||||
|
"""Stale-detect short-circuit: True iff preview.png mtime >= index.html mtime AND sha256 matches last_entry.
|
||||||
|
Returns False when prior entry is absent, preview.png is missing, preview is older than index, or hash differs."""
|
||||||
|
if last_entry is None or not row.has_index or not row.has_preview:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
idx_mtime = row.index_html_path.stat().st_mtime
|
||||||
|
png_mtime = row.preview_png_path.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if png_mtime < idx_mtime:
|
||||||
|
return False
|
||||||
|
recorded = last_entry.get("index_sha256")
|
||||||
|
if not recorded:
|
||||||
|
return False
|
||||||
|
return _sha256_file(row.index_html_path) == recorded
|
||||||
|
|
||||||
|
|
||||||
|
def categorize(rows: List[FrameRow]) -> Dict[str, List[FrameRow]]:
|
||||||
|
"""Bucket discover() rows so nothing is silently skipped (Stage 2 guardrail).
|
||||||
|
renderable = has_index (eligible for render or skipped_unchanged decision in u6).
|
||||||
|
missing_index_html = no index.html (catalog gap; IMP-04 follow-up).
|
||||||
|
orphan = preview.png exists without index.html (subset of missing_index_html; stale artifact to flag).
|
||||||
|
Buckets are intentionally non-disjoint: orphan is a subset of missing_index_html,
|
||||||
|
matching the Stage 2 evidence counts (renderable=20, missing_index_html=13, orphan=1)."""
|
||||||
|
renderable = [r for r in rows if r.has_index]
|
||||||
|
missing = [r for r in rows if not r.has_index]
|
||||||
|
orphan = [r for r in missing if r.has_preview]
|
||||||
|
return {"renderable": renderable, "missing_index_html": missing, "orphan": orphan}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_manifest(path: Path) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_entry(row: FrameRow, w: int, h: int) -> Dict[str, Any]:
|
||||||
|
return {"status": "rendered", "index_sha256": _sha256_file(row.index_html_path),
|
||||||
|
"index_mtime": row.index_html_path.stat().st_mtime,
|
||||||
|
"preview_mtime": row.preview_png_path.stat().st_mtime,
|
||||||
|
"viewport": {"w": w, "h": h}}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Iterable[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog="generate_frame_previews", description="IMP-13 build-time preview.png renderer.")
|
||||||
|
p.add_argument("--blocks-dir", type=Path, default=DEFAULT_BLOCKS_DIR)
|
||||||
|
p.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
||||||
|
p.add_argument("--dry-run", action="store_true")
|
||||||
|
args = p.parse_args(list(argv) if argv is not None else None)
|
||||||
|
rows = discover(args.blocks_dir)
|
||||||
|
if args.dry_run:
|
||||||
|
wi = sum(1 for r in rows if r.has_index)
|
||||||
|
wp = sum(1 for r in rows if r.has_preview)
|
||||||
|
print(f"discovered: total={len(rows)} with_index_html={wi} with_preview_png={wp}")
|
||||||
|
return 0
|
||||||
|
prev_frames = _load_manifest(args.manifest).get("frames") or {}
|
||||||
|
buckets = categorize(rows)
|
||||||
|
frames: Dict[str, Dict[str, Any]] = {}
|
||||||
|
counts = {"rendered": 0, "skipped_unchanged": 0, "error": 0}
|
||||||
|
driver = None
|
||||||
|
try:
|
||||||
|
for r in buckets["renderable"]:
|
||||||
|
last = prev_frames.get(r.frame_id) if isinstance(prev_frames, dict) else None
|
||||||
|
if is_unchanged(r, last):
|
||||||
|
frames[r.frame_id] = {**last, "status": "skipped_unchanged"}
|
||||||
|
counts["skipped_unchanged"] += 1
|
||||||
|
continue
|
||||||
|
if driver is None:
|
||||||
|
driver = _build_driver()
|
||||||
|
try:
|
||||||
|
w, h, _ = render_one(driver, r)
|
||||||
|
frames[r.frame_id] = _render_entry(r, w, h)
|
||||||
|
counts["rendered"] += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
frames[r.frame_id] = {"status": "error", "error": str(exc)}
|
||||||
|
counts["error"] += 1
|
||||||
|
finally:
|
||||||
|
if driver is not None:
|
||||||
|
try: driver.quit()
|
||||||
|
except Exception: pass
|
||||||
|
orphan_ids = {r.frame_id for r in buckets["orphan"]}
|
||||||
|
for r in buckets["missing_index_html"]:
|
||||||
|
frames[r.frame_id] = {"status": "orphan" if r.frame_id in orphan_ids else "missing_index_html", "has_preview": r.has_preview}
|
||||||
|
summary = {"total": len(rows), "renderable": len(buckets["renderable"]), "missing_index_html": len(buckets["missing_index_html"]), "orphan": len(buckets["orphan"]), **counts}
|
||||||
|
payload = {"schema": 1, "generated_at": datetime.now(timezone.utc).isoformat(), "blocks_dir": str(args.blocks_dir), "summary": summary, "frames": frames}
|
||||||
|
args.manifest.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||||
|
print(f"coverage: total={summary['total']} renderable={summary['renderable']} rendered={counts['rendered']} skipped_unchanged={counts['skipped_unchanged']} missing_index_html={summary['missing_index_html']} orphan={summary['orphan']} error={counts['error']}")
|
||||||
|
return 1 if counts["error"] else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+101
-21
@@ -7,20 +7,29 @@ A3 (zone_ratio_retry) 의 결과 (retry_trace) 를 받아 :
|
|||||||
본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup
|
본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup
|
||||||
실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가.
|
실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가.
|
||||||
|
|
||||||
**잠근 매핑** (사용자 잠금 — 2026-04-29) :
|
**잠근 매핑** (사용자 잠금 — 2026-05-17, IMP-12 u3 cascade) :
|
||||||
|
|
||||||
| failure_type | next_proposed_action |
|
| failure_type | next_proposed_action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| donor_slack_insufficient | layout_adjust |
|
| donor_slack_insufficient | cross_zone_redistribute |
|
||||||
| no_donor_candidates | layout_adjust |
|
| no_donor_candidates | cross_zone_redistribute |
|
||||||
| rerender_still_fails | frame_reselect |
|
| cross_zone_redistribute_insufficient | glue_compression |
|
||||||
| not_attempted | none |
|
| glue_absorption_insufficient | font_step_compression |
|
||||||
|
| font_step_insufficient | layout_adjust |
|
||||||
|
| rerender_still_fails | frame_reselect |
|
||||||
|
| not_attempted | none |
|
||||||
|
|
||||||
**escalation 단계 hierarchy** (이번 기본 매핑이 따르는 원칙) :
|
**escalation 단계 hierarchy** (Step 17 deterministic salvage cascade → layout/frame) :
|
||||||
```
|
```
|
||||||
layout_adjust (가장 가벼움 — zone 배치만 변경)
|
cross_zone_redistribute (fit_verifier.redistribute — role-height adjustment)
|
||||||
↓ 그래도 안 되면
|
↓ 그래도 안 되면
|
||||||
frame_reselect (중간 — frame 자체 변경)
|
glue_compression (SPACING_GLUE envelope, frame-scoped)
|
||||||
|
↓ 그래도 안 되면
|
||||||
|
font_step_compression (FONT_SIZE_STEPS, zone-scoped)
|
||||||
|
↓ 그래도 안 되면
|
||||||
|
layout_adjust (zone topology 변경)
|
||||||
|
↓ 그래도 안 되면
|
||||||
|
frame_reselect (V4 top-k 의 다른 frame)
|
||||||
↓ 그래도 안 되면
|
↓ 그래도 안 되면
|
||||||
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
||||||
```
|
```
|
||||||
@@ -53,26 +62,65 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"redistribution 실행 + rerender 까지 했는데도 visual_check 실패. "
|
"redistribution 실행 + rerender 까지 했는데도 visual_check 실패. "
|
||||||
"현재 frame/zone 조합이 content 와 맞지 않음"
|
"현재 frame/zone 조합이 content 와 맞지 않음"
|
||||||
),
|
),
|
||||||
|
"cross_zone_redistribute_insufficient": (
|
||||||
|
"cross_zone_redistribute salvage step failed — fit_verifier.redistribute "
|
||||||
|
"could not find a feasible role-height adjustment within the frame envelope"
|
||||||
|
),
|
||||||
|
"glue_absorption_insufficient": (
|
||||||
|
"glue_compression salvage step failed — frame envelope cannot absorb "
|
||||||
|
"remaining overflow via SPACING_GLUE overrides (no global spacing shrink)"
|
||||||
|
),
|
||||||
|
"font_step_insufficient": (
|
||||||
|
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
|
||||||
|
"down to the floor without resolving overflow (or text_metrics missing)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §A4-1b salvage_steps[-1].action → failure_type table ──────────
|
||||||
|
# u2 (IMP-12): _attempt_salvage_chain (u8) writes per-step records into
|
||||||
|
# retry_trace["salvage_steps"] with {action, passed, failure_reason}. classifier
|
||||||
|
# inspects salvage_steps[-1] so u3 can route 3 new types onto the cascade.
|
||||||
|
|
||||||
|
SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
|
||||||
|
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||||
|
"glue_compression": "glue_absorption_insufficient",
|
||||||
|
"font_step_compression": "font_step_insufficient",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ─── §A4-2 next_action mapping (사용자 잠금) ──────────────────────
|
# ─── §A4-2 next_action mapping (사용자 잠금) ──────────────────────
|
||||||
|
|
||||||
NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
||||||
"donor_slack_insufficient": "layout_adjust",
|
"donor_slack_insufficient": "cross_zone_redistribute",
|
||||||
"no_donor_candidates": "layout_adjust",
|
"no_donor_candidates": "cross_zone_redistribute",
|
||||||
"rerender_still_fails": "frame_reselect",
|
"cross_zone_redistribute_insufficient": "glue_compression",
|
||||||
"not_attempted": "none",
|
"glue_absorption_insufficient": "font_step_compression",
|
||||||
|
"font_step_insufficient": "layout_adjust",
|
||||||
|
"rerender_still_fails": "frame_reselect",
|
||||||
|
"not_attempted": "none",
|
||||||
}
|
}
|
||||||
|
|
||||||
NEXT_ACTION_RATIONALE: dict[str, str] = {
|
NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||||
"donor_slack_insufficient": (
|
"donor_slack_insufficient": (
|
||||||
"현재 layout 안 redistribution 끝남 → 다른 layout topology 검토 "
|
"primary donor slack 한도 도달 → cross_zone_redistribute 로 sibling zone "
|
||||||
"(layout_adjust). frame 자체는 아직 의심 대상 X"
|
"전체 role-height 재분배 (fit_verifier.redistribute). layout 변경은 cascade 끝"
|
||||||
),
|
),
|
||||||
"no_donor_candidates": (
|
"no_donor_candidates": (
|
||||||
"donor 자체 없거나 모두 막힘 → layout topology 부터 재구성하여 "
|
"단일 donor 후보 없음 → cross_zone_redistribute 로 role-height 전체 "
|
||||||
"sibling/space 다시 만들어 보는 게 우선 (layout_adjust). frame 변경은 그 다음"
|
"재할당 시도 (fit_verifier.redistribute). layout 변경은 cascade 끝"
|
||||||
|
),
|
||||||
|
"cross_zone_redistribute_insufficient": (
|
||||||
|
"role-height 재분배도 frame envelope 못 맞춤 → glue_compression "
|
||||||
|
"(SPACING_GLUE frame-scoped) 으로 frame 내부 여백 축소"
|
||||||
|
),
|
||||||
|
"glue_absorption_insufficient": (
|
||||||
|
"frame 여백 envelope 도 부족 → font_step_compression "
|
||||||
|
"(FONT_SIZE_STEPS zone-scoped) 으로 폰트 한 단계 축소"
|
||||||
|
),
|
||||||
|
"font_step_insufficient": (
|
||||||
|
"deterministic salvage cascade 모두 소진 → layout_adjust 로 zone "
|
||||||
|
"topology 부터 재구성. frame_reselect 는 그 다음 단계"
|
||||||
),
|
),
|
||||||
"rerender_still_fails": (
|
"rerender_still_fails": (
|
||||||
"redistribution + rerender 까지 했는데도 visual fail → 현재 "
|
"redistribution + rerender 까지 했는데도 visual fail → 현재 "
|
||||||
@@ -85,10 +133,19 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
|
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
|
||||||
|
# IMP-12 u7 (2026-05-18): 3 cascade salvage actions registered as IMPLEMENTED.
|
||||||
|
# plan/apply pairs live in phase_z2_retry (u4/u5/u6); pipeline orchestrator wiring
|
||||||
|
# (_attempt_salvage_chain) lands in u8/u9. router-level mapping is decoupled from
|
||||||
|
# orchestrator wiring on purpose so route_retry_failure → impl_status reflects
|
||||||
|
# the deterministic surface availability, not whether a given pipeline run has
|
||||||
|
# already invoked it.
|
||||||
NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||||
"layout_adjust": "MISSING",
|
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
|
||||||
"frame_reselect": "MISSING",
|
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
|
||||||
"none": "n/a",
|
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
|
||||||
|
"layout_adjust": "MISSING",
|
||||||
|
"frame_reselect": "MISSING",
|
||||||
|
"none": "n/a",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -106,6 +163,29 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
|
|||||||
if retry_trace.get("retry_passed"):
|
if retry_trace.get("retry_passed"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# case 0.5 : salvage chain 자체 성공 — failure 없음 (u8/u9 wiring)
|
||||||
|
if retry_trace.get("salvage_passed"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
|
||||||
|
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
|
||||||
|
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
|
||||||
|
salvage_steps = retry_trace.get("salvage_steps") or []
|
||||||
|
if salvage_steps:
|
||||||
|
last = salvage_steps[-1] or {}
|
||||||
|
if not last.get("passed"):
|
||||||
|
action = (last.get("action") or "").lower()
|
||||||
|
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||||
|
if ftype is not None:
|
||||||
|
reason = last.get("failure_reason") or ""
|
||||||
|
return {
|
||||||
|
"failure_type": ftype,
|
||||||
|
"classification_rule": (
|
||||||
|
f"salvage_steps[-1].action == {action!r} "
|
||||||
|
f"AND passed=False. raw failure_reason: {reason!r}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
||||||
if not retry_trace.get("retry_attempted"):
|
if not retry_trace.get("retry_attempted"):
|
||||||
return {
|
return {
|
||||||
@@ -204,7 +284,7 @@ def route_retry_failure(failure_type: str) -> dict:
|
|||||||
"next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get(
|
"next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get(
|
||||||
next_action, "unknown"
|
next_action, "unknown"
|
||||||
),
|
),
|
||||||
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-04-29)",
|
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-05-17, IMP-12 u3 cascade)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+565
-259
@@ -58,10 +58,19 @@ from phase_z2_classifier import classify_visual_runtime_check
|
|||||||
from phase_z2_router import route_fit_classification
|
from phase_z2_router import route_fit_classification
|
||||||
from phase_z2_retry import (
|
from phase_z2_retry import (
|
||||||
DEFAULT_SAFETY_MARGIN_PX,
|
DEFAULT_SAFETY_MARGIN_PX,
|
||||||
|
apply_cross_zone_redistribute_css,
|
||||||
|
apply_font_step_compression_css,
|
||||||
|
apply_glue_compression_css,
|
||||||
apply_retry_to_layout_css,
|
apply_retry_to_layout_css,
|
||||||
|
plan_cross_zone_redistribute,
|
||||||
|
plan_font_step_compression,
|
||||||
|
plan_glue_compression,
|
||||||
plan_zone_ratio_retry,
|
plan_zone_ratio_retry,
|
||||||
)
|
)
|
||||||
from phase_z2_failure_router import enrich_retry_trace_with_failure_classification
|
from phase_z2_failure_router import (
|
||||||
|
enrich_retry_trace_with_failure_classification,
|
||||||
|
route_retry_failure,
|
||||||
|
)
|
||||||
|
|
||||||
# trace-only runtime 연결 v0 — B1 → B4 chain.
|
# trace-only runtime 연결 v0 — B1 → B4 chain.
|
||||||
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
|
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
|
||||||
@@ -119,6 +128,16 @@ GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07)
|
|||||||
# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이.
|
# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이.
|
||||||
DEFAULT_ZONE_MIN_HEIGHT_PX = 100
|
DEFAULT_ZONE_MIN_HEIGHT_PX = 100
|
||||||
|
|
||||||
|
# Step 14 image_aspect_mismatch tolerance — |natural_ratio - rendered_ratio| > TOL ⇒ fail.
|
||||||
|
# Local anchor : IMP-15 실행-1 (Gitea issue #45) — image axis acceptance criteria.
|
||||||
|
# Spec doc row (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC) update deferred to IMP-15 실행-4.
|
||||||
|
IMAGE_ASPECT_DELTA_TOL = 0.05
|
||||||
|
|
||||||
|
# Step 14 table_self_overflow tolerance — scrollW−clientW or scrollH−clientH > TOL ⇒ fail.
|
||||||
|
# Local anchor : IMP-15 실행-2 (Gitea issue #46) — table axis acceptance criteria.
|
||||||
|
# Mirrors existing inline 5px tolerance used by slide/zone/clipped scans in run_overflow_check.
|
||||||
|
TABLE_SCROLL_TOL_PX = 5
|
||||||
|
|
||||||
# content_weight 계산 가중치
|
# content_weight 계산 가중치
|
||||||
CONTENT_WEIGHT_COEFFS = {
|
CONTENT_WEIGHT_COEFFS = {
|
||||||
"text_per_chars": 800, # text_len / 800 = score
|
"text_per_chars": 800, # text_len / 800 = score
|
||||||
@@ -953,6 +972,56 @@ def compute_zone_layout_cols(zones_data: list[dict],
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_zone_signals_per_track(
|
||||||
|
preset: dict,
|
||||||
|
zones_data: list[dict],
|
||||||
|
) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Build per-row + per-col virtual zones for 2-D dynamic dispatch.
|
||||||
|
|
||||||
|
Each virtual zone aggregates content_weight.score (max) and
|
||||||
|
min_height_px (max) across single-span zones on that track
|
||||||
|
(occupied_rows == {r} for rows, occupied_cols == {c} for cols).
|
||||||
|
Falls back to all-span zones (touching every track on the axis)
|
||||||
|
when a track has no single-span zone.
|
||||||
|
"""
|
||||||
|
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
||||||
|
R = len(rows_grid)
|
||||||
|
C = len(rows_grid[0])
|
||||||
|
|
||||||
|
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||||||
|
for z in zones_data:
|
||||||
|
pos = z["position"]
|
||||||
|
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
|
||||||
|
occ_cols = {
|
||||||
|
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
|
||||||
|
}
|
||||||
|
occupancy.append((z, occ_rows, occ_cols))
|
||||||
|
|
||||||
|
def _track_virtual(idx: int, axis: str) -> dict:
|
||||||
|
if axis == "row":
|
||||||
|
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||||||
|
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||||||
|
else:
|
||||||
|
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||||||
|
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||||||
|
candidates = single or allspan
|
||||||
|
return {
|
||||||
|
"position": f"_virtual_{axis}_{idx}",
|
||||||
|
"template_id": f"_virtual_{axis}_{idx}",
|
||||||
|
"content_weight": {
|
||||||
|
"score": max(c["content_weight"]["score"] for c in candidates)
|
||||||
|
},
|
||||||
|
"min_height_px": max(
|
||||||
|
c.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
|
||||||
|
for c in candidates
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
row_virtuals = [_track_virtual(r, "row") for r in range(R)]
|
||||||
|
col_virtuals = [_track_virtual(c, "col") for c in range(C)]
|
||||||
|
return row_virtuals, col_virtuals
|
||||||
|
|
||||||
|
|
||||||
def _compute_per_zone_geometry(
|
def _compute_per_zone_geometry(
|
||||||
layout_css: dict,
|
layout_css: dict,
|
||||||
debug_zones: list[dict],
|
debug_zones: list[dict],
|
||||||
@@ -1083,6 +1152,61 @@ def _build_rows_dynamic(preset: dict, zones_data: list[dict],
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grid_dynamic_2d(preset: dict, zones_data: list[dict],
|
||||||
|
gap: int = GRID_GAP) -> dict:
|
||||||
|
"""2-D dynamic path — dynamic row heights + dynamic column widths.
|
||||||
|
|
||||||
|
IMP-09 PR 2 (B-4) handler for the five preset families whose topology
|
||||||
|
is neither pure 'rows' nor pure 'cols':
|
||||||
|
- T (top-1-bottom-2)
|
||||||
|
- inverted-T (top-2-bottom-1)
|
||||||
|
- side-T-left (left-1-right-2)
|
||||||
|
- side-T-right (left-2-right-1)
|
||||||
|
- 2x2 (grid-2x2)
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1) _aggregate_zone_signals_per_track builds R per-row + C per-col
|
||||||
|
virtual zones (max content_weight.score + max min_height_px of
|
||||||
|
single-span zones, falling back to all-span zones).
|
||||||
|
2) Row virtuals → compute_zone_layout → heights_px (R).
|
||||||
|
3) Col virtuals → compute_zone_layout_cols → widths_px (C).
|
||||||
|
4) Assemble layout_css dict with computation='2d_dynamic_aggregated'
|
||||||
|
and dynamic_rows=True, dynamic_cols=True.
|
||||||
|
|
||||||
|
raw_zone_layout carries both solver outputs + the virtual zone lists
|
||||||
|
so step08 trace can explain the per-track aggregation.
|
||||||
|
"""
|
||||||
|
row_virtuals, col_virtuals = _aggregate_zone_signals_per_track(
|
||||||
|
preset, zones_data
|
||||||
|
)
|
||||||
|
zl_row = compute_zone_layout(row_virtuals, gap=gap)
|
||||||
|
zl_col = compute_zone_layout_cols(col_virtuals, gap=gap)
|
||||||
|
|
||||||
|
heights_px = zl_row["heights_px"]
|
||||||
|
widths_px = zl_col["widths_px"]
|
||||||
|
rows_str = " ".join(f"{h}px" for h in heights_px)
|
||||||
|
cols_str = " ".join(f"{w}px" for w in widths_px)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"areas": preset["css_areas"],
|
||||||
|
"cols": cols_str,
|
||||||
|
"rows": rows_str,
|
||||||
|
"heights_px": heights_px,
|
||||||
|
"widths_px": widths_px,
|
||||||
|
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
|
||||||
|
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
|
||||||
|
"computation": "2d_dynamic_aggregated",
|
||||||
|
"dynamic_rows": True,
|
||||||
|
"dynamic_cols": True,
|
||||||
|
"raw_zone_layout": {
|
||||||
|
"row_layout": zl_row,
|
||||||
|
"col_layout": zl_col,
|
||||||
|
"row_virtuals": row_virtuals,
|
||||||
|
"col_virtuals": col_virtuals,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_cols_dynamic(preset: dict, zones_data: list[dict],
|
def _build_cols_dynamic(preset: dict, zones_data: list[dict],
|
||||||
gap: int = GRID_GAP) -> dict:
|
gap: int = GRID_GAP) -> dict:
|
||||||
"""vertical-2 path — dynamic column widths, static fr row heights.
|
"""vertical-2 path — dynamic column widths, static fr row heights.
|
||||||
@@ -1113,224 +1237,105 @@ def _build_cols_dynamic(preset: dict, zones_data: list[dict],
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ─── IMP-09 PR 2 helpers (2-D dynamic dispatch) ──────────────────────
|
def _override_to_grid_tracks(
|
||||||
# 5 in-scope presets (top-1-bottom-2, top-2-bottom-1, left-1-right-2,
|
preset: dict,
|
||||||
# left-2-right-1, grid-2x2) are promoted from _build_fr_default to
|
zones_data: list[dict],
|
||||||
# dynamic computation on BOTH axes by aggregating zone positions onto
|
override_zone_geometries: dict[str, dict],
|
||||||
# each row/col track. Each preset's R x C grid is mapped to two
|
gap: int = GRID_GAP,
|
||||||
# 1-D allocations consumed by the PR 1 solvers (compute_zone_layout
|
) -> dict:
|
||||||
# for rows, compute_zone_layout_cols for cols). Spanning zones
|
"""2-D override path — derive heights_px (R) + widths_px (C) from
|
||||||
# contribute to one axis only (the axis they span exclusively
|
user-supplied zone_id -> {x, y, w, h} (0~1 within slide-body).
|
||||||
# defines); they are excluded from the perpendicular axis aggregate
|
|
||||||
# because their position does not bias either track on that axis.
|
|
||||||
|
|
||||||
TOPOLOGY_AXIS_MAP: dict[str, dict[str, list[list[str]]]] = {
|
IMP-09 PR 2 (B-4) override handler for the five preset families
|
||||||
"T": {
|
whose topology is neither pure 'rows' nor pure 'cols':
|
||||||
# "top top" / "bottom-left bottom-right"
|
- T (top-1-bottom-2)
|
||||||
# top spans both cols — excluded from col aggregates.
|
- inverted-T (top-2-bottom-1)
|
||||||
"rows": [["top"], ["bottom-left", "bottom-right"]],
|
- side-T-left (left-1-right-2)
|
||||||
"cols": [["bottom-left"], ["bottom-right"]],
|
- side-T-right (left-2-right-1)
|
||||||
},
|
- 2x2 (grid-2x2)
|
||||||
"inverted-T": {
|
|
||||||
# "top-left top-right" / "bottom bottom"
|
|
||||||
# bottom spans both cols — excluded from col aggregates.
|
|
||||||
"rows": [["top-left", "top-right"], ["bottom"]],
|
|
||||||
"cols": [["top-left"], ["top-right"]],
|
|
||||||
},
|
|
||||||
"side-T-left": {
|
|
||||||
# "left right-top" / "left right-bottom"
|
|
||||||
# left spans both rows — excluded from row aggregates.
|
|
||||||
"rows": [["right-top"], ["right-bottom"]],
|
|
||||||
"cols": [["left"], ["right-top", "right-bottom"]],
|
|
||||||
},
|
|
||||||
"side-T-right": {
|
|
||||||
# "left-top right" / "left-bottom right"
|
|
||||||
# right spans both rows — excluded from row aggregates.
|
|
||||||
"rows": [["left-top"], ["left-bottom"]],
|
|
||||||
"cols": [["left-top", "left-bottom"], ["right"]],
|
|
||||||
},
|
|
||||||
"2x2": {
|
|
||||||
# "top-left top-right" / "bottom-left bottom-right"
|
|
||||||
# No spanning — every zone participates in both axes.
|
|
||||||
"rows": [["top-left", "top-right"], ["bottom-left", "bottom-right"]],
|
|
||||||
"cols": [["top-left", "bottom-left"], ["top-right", "bottom-right"]],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Strategy:
|
||||||
_TWO_D_TOPOLOGIES = frozenset(TOPOLOGY_AXIS_MAP)
|
1) Parse css_areas into R x C grid.
|
||||||
|
2) For each row r: aggregate h via max over single-row zones
|
||||||
|
(occupied_rows == {r}); fallback to all-span zones; else 0.0.
|
||||||
def _aggregate_axis_zone(positions: list[str], zones_data: list[dict],
|
3) For each col c: same with w.
|
||||||
axis_label: str) -> dict:
|
4) Normalize per-axis (divide by total) and multiply by avail_*,
|
||||||
"""Combine zones occupying the same row/col track into a synthetic
|
absorbing rounding diff into the last element.
|
||||||
zone consumable by the PR 1 row/col solvers.
|
5) If total_h or total_w == 0 (degenerate / empty override),
|
||||||
|
fall back to _build_grid_dynamic_2d default path.
|
||||||
- 1 zone: returned as-is (no allocation overhead).
|
|
||||||
- N zones: synthetic zone with
|
|
||||||
min_height_px = max of constituents (both must clear the bound)
|
|
||||||
content_weight.score = mean (each zone contributes equally to track)
|
|
||||||
template_id / position = _AXIS_<axis_label> marker
|
|
||||||
"""
|
"""
|
||||||
src = [z for z in zones_data if z["position"] in positions]
|
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
||||||
seen = {z["position"] for z in src}
|
R = len(rows_grid)
|
||||||
missing = [p for p in positions if p not in seen]
|
C = len(rows_grid[0])
|
||||||
if missing:
|
|
||||||
raise ValueError(
|
|
||||||
f"_aggregate_axis_zone: zones_data missing positions {missing} "
|
|
||||||
f"required for axis {axis_label!r} (got {sorted(seen)!r})"
|
|
||||||
)
|
|
||||||
if len(src) == 1:
|
|
||||||
return src[0]
|
|
||||||
min_h = max(
|
|
||||||
z.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX) for z in src
|
|
||||||
)
|
|
||||||
score = sum(z["content_weight"]["score"] for z in src) / len(src)
|
|
||||||
return {
|
|
||||||
"position": f"_AXIS_{axis_label}",
|
|
||||||
"template_id": f"_AXIS_{axis_label}",
|
|
||||||
"content_weight": {"score": score},
|
|
||||||
"min_height_px": min_h,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
occupancy: list[tuple[dict, set[int], set[int]]] = []
|
||||||
|
for z in zones_data:
|
||||||
|
pos = z["position"]
|
||||||
|
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
|
||||||
|
occ_cols = {
|
||||||
|
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
|
||||||
|
}
|
||||||
|
occupancy.append((z, occ_rows, occ_cols))
|
||||||
|
|
||||||
def _build_2d_dynamic(preset: dict, zones_data: list[dict],
|
def _track_value(idx: int, axis: str) -> float:
|
||||||
gap: int = GRID_GAP) -> dict:
|
if axis == "row":
|
||||||
"""2-D dynamic path — both row heights and col widths computed
|
single = [z for z, rr, _cc in occupancy if rr == {idx}]
|
||||||
from per-axis aggregates of zones_data.
|
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
|
||||||
|
key = "h"
|
||||||
|
else:
|
||||||
|
single = [z for z, _rr, cc in occupancy if cc == {idx}]
|
||||||
|
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
|
||||||
|
key = "w"
|
||||||
|
candidates = single or allspan
|
||||||
|
vals = [
|
||||||
|
float(override_zone_geometries[z["position"]][key])
|
||||||
|
for z in candidates
|
||||||
|
if z["position"] in override_zone_geometries
|
||||||
|
]
|
||||||
|
return max(vals) if vals else 0.0
|
||||||
|
|
||||||
Topology-keyed aggregation lives in TOPOLOGY_AXIS_MAP. Spanning
|
row_values = [_track_value(r, "row") for r in range(R)]
|
||||||
zones (top in T, bottom in inverted-T, left in side-T-left,
|
col_values = [_track_value(c, "col") for c in range(C)]
|
||||||
right in side-T-right) appear only on the axis they span
|
|
||||||
exclusively; the perpendicular axis is driven by the non-spanning
|
|
||||||
zones to avoid double-biasing.
|
|
||||||
"""
|
|
||||||
topology = preset["topology"]
|
|
||||||
if topology not in TOPOLOGY_AXIS_MAP:
|
|
||||||
raise ValueError(
|
|
||||||
f"_build_2d_dynamic: topology {topology!r} not registered "
|
|
||||||
f"in TOPOLOGY_AXIS_MAP {sorted(TOPOLOGY_AXIS_MAP)!r}"
|
|
||||||
)
|
|
||||||
mapping = TOPOLOGY_AXIS_MAP[topology]
|
|
||||||
|
|
||||||
row_axis_zones = [
|
total_h = sum(row_values)
|
||||||
_aggregate_axis_zone(group, zones_data, f"R{i}")
|
total_w = sum(col_values)
|
||||||
for i, group in enumerate(mapping["rows"])
|
if total_h == 0 or total_w == 0:
|
||||||
]
|
return _build_grid_dynamic_2d(preset, zones_data, gap=gap)
|
||||||
col_axis_zones = [
|
|
||||||
_aggregate_axis_zone(group, zones_data, f"C{i}")
|
|
||||||
for i, group in enumerate(mapping["cols"])
|
|
||||||
]
|
|
||||||
|
|
||||||
row_zl = compute_zone_layout(row_axis_zones, gap=gap)
|
row_ratios = [v / total_h for v in row_values]
|
||||||
col_zl = compute_zone_layout_cols(col_axis_zones, gap=gap)
|
col_ratios = [v / total_w for v in col_values]
|
||||||
|
|
||||||
rows_str = " ".join(f"{h}px" for h in row_zl["heights_px"])
|
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
|
||||||
cols_str = " ".join(f"{w}px" for w in col_zl["widths_px"])
|
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
|
||||||
|
heights_px = [int(round(r * avail_h)) for r in row_ratios]
|
||||||
|
widths_px = [int(round(r * avail_w)) for r in col_ratios]
|
||||||
|
diff_h = avail_h - sum(heights_px)
|
||||||
|
if diff_h != 0 and heights_px:
|
||||||
|
heights_px[-1] += diff_h
|
||||||
|
diff_w = avail_w - sum(widths_px)
|
||||||
|
if diff_w != 0 and widths_px:
|
||||||
|
widths_px[-1] += diff_w
|
||||||
|
|
||||||
|
rows_str = " ".join(f"{h}px" for h in heights_px)
|
||||||
|
cols_str = " ".join(f"{w}px" for w in widths_px)
|
||||||
return {
|
return {
|
||||||
"areas": preset["css_areas"],
|
"areas": preset["css_areas"],
|
||||||
"cols": cols_str,
|
"cols": cols_str,
|
||||||
"rows": rows_str,
|
"rows": rows_str,
|
||||||
"heights_px": row_zl["heights_px"],
|
"heights_px": heights_px,
|
||||||
"widths_px": col_zl["widths_px"],
|
"widths_px": widths_px,
|
||||||
"ratios": row_zl["ratios"],
|
"ratios": [round(rr, 3) for rr in row_ratios],
|
||||||
"width_ratios": col_zl["width_ratios"],
|
"width_ratios": [round(rr, 3) for rr in col_ratios],
|
||||||
"computation": "2d_dynamic_aggregated",
|
"computation": "user_override_geometry",
|
||||||
"dynamic_rows": True,
|
"dynamic_rows": True,
|
||||||
"dynamic_cols": True,
|
"dynamic_cols": True,
|
||||||
"raw_zone_layout": {
|
"raw_zone_layout": {
|
||||||
"topology": topology,
|
"override_applied": True,
|
||||||
"rows": row_zl,
|
"source": override_zone_geometries,
|
||||||
"cols": col_zl,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _override_to_grid_tracks(preset: dict, override: dict[str, dict],
|
|
||||||
zones_data: list[dict],
|
|
||||||
gap: int = GRID_GAP) -> dict:
|
|
||||||
"""Derive row/col track sizes from override_zone_geometries for
|
|
||||||
the 5 in-scope 2-D presets.
|
|
||||||
|
|
||||||
Per-axis reconciliation:
|
|
||||||
- Row track size = max(h) of zones in that row (zones sharing
|
|
||||||
a row are constrained by the taller request).
|
|
||||||
- Col track size = max(w) of zones in that column (same
|
|
||||||
reasoning, w-axis).
|
|
||||||
- Spanning zones are mapped only to the axis they exclusively
|
|
||||||
define (TOPOLOGY_AXIS_MAP rows/cols).
|
|
||||||
- Missing or zero total on an axis → that axis falls back to
|
|
||||||
the dynamic normal-path solver (compute_zone_layout /
|
|
||||||
compute_zone_layout_cols) on aggregated axis zones.
|
|
||||||
"""
|
|
||||||
topology = preset["topology"]
|
|
||||||
mapping = TOPOLOGY_AXIS_MAP[topology]
|
|
||||||
rows_grid, _ = _parse_css_areas(preset["css_areas"])
|
|
||||||
R = len(rows_grid)
|
|
||||||
C = len(rows_grid[0])
|
|
||||||
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
|
|
||||||
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
|
|
||||||
|
|
||||||
# ── Row axis ──
|
|
||||||
row_raw = []
|
|
||||||
for group in mapping["rows"]:
|
|
||||||
hs = [
|
|
||||||
float(override.get(pos, {}).get("h", 0) or 0)
|
|
||||||
for pos in group
|
|
||||||
]
|
|
||||||
row_raw.append(max(hs) if hs else 0.0)
|
|
||||||
|
|
||||||
if sum(row_raw) > 0:
|
|
||||||
total = sum(row_raw)
|
|
||||||
row_ratios = [r / total for r in row_raw]
|
|
||||||
heights_px = [int(round(r * avail_h)) for r in row_ratios]
|
|
||||||
heights_px[-1] += avail_h - sum(heights_px)
|
|
||||||
row_source = "override"
|
|
||||||
else:
|
|
||||||
row_axis_zones = [
|
|
||||||
_aggregate_axis_zone(g, zones_data, f"R{i}")
|
|
||||||
for i, g in enumerate(mapping["rows"])
|
|
||||||
]
|
|
||||||
row_zl = compute_zone_layout(row_axis_zones, gap=gap)
|
|
||||||
heights_px = row_zl["heights_px"]
|
|
||||||
row_ratios = [h / SLIDE_BODY_HEIGHT for h in heights_px]
|
|
||||||
row_source = "dynamic_fallback"
|
|
||||||
|
|
||||||
# ── Col axis ──
|
|
||||||
col_raw = []
|
|
||||||
for group in mapping["cols"]:
|
|
||||||
ws = [
|
|
||||||
float(override.get(pos, {}).get("w", 0) or 0)
|
|
||||||
for pos in group
|
|
||||||
]
|
|
||||||
col_raw.append(max(ws) if ws else 0.0)
|
|
||||||
|
|
||||||
if sum(col_raw) > 0:
|
|
||||||
total = sum(col_raw)
|
|
||||||
col_ratios = [c / total for c in col_raw]
|
|
||||||
widths_px = [int(round(c * avail_w)) for c in col_ratios]
|
|
||||||
widths_px[-1] += avail_w - sum(widths_px)
|
|
||||||
col_source = "override"
|
|
||||||
else:
|
|
||||||
col_axis_zones = [
|
|
||||||
_aggregate_axis_zone(g, zones_data, f"C{i}")
|
|
||||||
for i, g in enumerate(mapping["cols"])
|
|
||||||
]
|
|
||||||
col_zl = compute_zone_layout_cols(col_axis_zones, gap=gap)
|
|
||||||
widths_px = col_zl["widths_px"]
|
|
||||||
col_ratios = [w / SLIDE_BODY_WIDTH for w in widths_px]
|
|
||||||
col_source = "dynamic_fallback"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"heights_px": heights_px,
|
|
||||||
"widths_px": widths_px,
|
|
||||||
"row_ratios": row_ratios,
|
|
||||||
"col_ratios": col_ratios,
|
|
||||||
"row_source": row_source,
|
|
||||||
"col_source": col_source,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
|
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
|
||||||
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
|
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
|
||||||
|
|
||||||
@@ -1349,19 +1354,15 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
|||||||
Dynamic dispatch:
|
Dynamic dispatch:
|
||||||
- topology="rows" -> _build_rows_dynamic (horizontal-2: row heights)
|
- topology="rows" -> _build_rows_dynamic (horizontal-2: row heights)
|
||||||
- topology="cols" -> _build_cols_dynamic (vertical-2: col widths)
|
- topology="cols" -> _build_cols_dynamic (vertical-2: col widths)
|
||||||
- topology in _TWO_D_TOPOLOGIES (T / inverted-T / side-T-left /
|
- other topologies (single / T / inverted-T / side-T / 2x2) fall
|
||||||
side-T-right / 2x2) -> _build_2d_dynamic (both axes dynamic
|
through to _build_fr_default in PR 1; PR 2 enables the 2-D
|
||||||
via aggregated row/col solver inputs).
|
dispatcher.
|
||||||
- topology="single" falls through to _build_fr_default (PR 3
|
|
||||||
promotion pending).
|
|
||||||
|
|
||||||
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id ->
|
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id ->
|
||||||
{x,y,w,h} slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제.
|
{x,y,w,h} slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제.
|
||||||
PR 1 lock: horizontal-2 / vertical-2 만 처리 (legacy inline preserve).
|
PR 1 lock: horizontal-2 / vertical-2 만 처리 (legacy inline preserve).
|
||||||
PR 2 lock: 5 in-scope 2-D presets (T / inverted-T / side-T-left /
|
다른 preset 은 warn-and-fallthrough (PR 2 가 unified _override_to_grid_tracks
|
||||||
side-T-right / 2x2) routed through _override_to_grid_tracks; the
|
로 promote).
|
||||||
`single` preset is the only remaining warn-and-fallthrough surface
|
|
||||||
(PR 3 will promote it).
|
|
||||||
"""
|
"""
|
||||||
preset = LAYOUT_PRESETS[layout_preset]
|
preset = LAYOUT_PRESETS[layout_preset]
|
||||||
positions = preset["positions"]
|
positions = preset["positions"]
|
||||||
@@ -1424,38 +1425,19 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
|||||||
"dynamic_cols": True,
|
"dynamic_cols": True,
|
||||||
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
|
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
|
||||||
}
|
}
|
||||||
elif topology in _TWO_D_TOPOLOGIES:
|
elif topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
|
||||||
# PR 2 — 2-D override via topology-keyed track derivation.
|
# IMP-09 PR 2 — 2-D override path (T / inverted-T / side-T / 2x2).
|
||||||
tracks = _override_to_grid_tracks(
|
# Degenerate inputs (total_h == 0 or total_w == 0) fall back to
|
||||||
preset, override_zone_geometries, zones_data, gap=gap
|
# _build_grid_dynamic_2d inside the helper.
|
||||||
|
return _override_to_grid_tracks(
|
||||||
|
preset, zones_data, override_zone_geometries, gap=gap
|
||||||
)
|
)
|
||||||
rows_str = " ".join(f"{h}px" for h in tracks["heights_px"])
|
|
||||||
cols_str = " ".join(f"{w}px" for w in tracks["widths_px"])
|
|
||||||
return {
|
|
||||||
"areas": preset["css_areas"],
|
|
||||||
"cols": cols_str,
|
|
||||||
"rows": rows_str,
|
|
||||||
"heights_px": tracks["heights_px"],
|
|
||||||
"widths_px": tracks["widths_px"],
|
|
||||||
"ratios": [round(r, 3) for r in tracks["row_ratios"]],
|
|
||||||
"width_ratios": [round(c, 3) for c in tracks["col_ratios"]],
|
|
||||||
"computation": "user_override_geometry",
|
|
||||||
"dynamic_rows": True,
|
|
||||||
"dynamic_cols": True,
|
|
||||||
"raw_zone_layout": {
|
|
||||||
"override_applied": True,
|
|
||||||
"source": override_zone_geometries,
|
|
||||||
"topology": topology,
|
|
||||||
"row_source": tracks["row_source"],
|
|
||||||
"col_source": tracks["col_source"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
# PR 2 lock — only `single` remains as warn-and-fallthrough.
|
# warn-and-fallthrough preserved for remaining presets (single).
|
||||||
# PR 3 will promote single via dedicated override handling.
|
# PR 3 territory.
|
||||||
print(
|
print(
|
||||||
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
|
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
|
||||||
f"(PR 2 후 single 만 fallthrough). default layout_css 사용.",
|
f"(현재 horizontal-2 / vertical-2 / 2-D presets 만). default layout_css 사용.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1464,10 +1446,9 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
|||||||
return _build_rows_dynamic(preset, zones_data, gap)
|
return _build_rows_dynamic(preset, zones_data, gap)
|
||||||
if topology == "cols":
|
if topology == "cols":
|
||||||
return _build_cols_dynamic(preset, zones_data, gap)
|
return _build_cols_dynamic(preset, zones_data, gap)
|
||||||
if topology in _TWO_D_TOPOLOGIES:
|
if topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
|
||||||
return _build_2d_dynamic(preset, zones_data, gap)
|
return _build_grid_dynamic_2d(preset, zones_data, gap)
|
||||||
# PR 3 will dispatch `single` here; until then it falls through to
|
# PR 3 will dispatch single here.
|
||||||
# fr_default_from_preset (length-locked sink).
|
|
||||||
return _build_fr_default(preset)
|
return _build_fr_default(preset)
|
||||||
|
|
||||||
|
|
||||||
@@ -1963,14 +1944,110 @@ def _attempt_zone_ratio_retry(
|
|||||||
return base_trace
|
return base_trace
|
||||||
|
|
||||||
|
|
||||||
|
# IMP-12 u8 — Step 17 salvage cascade orchestrator (deterministic, no normal-path AI).
|
||||||
|
# Plan/apply pairs: phase_z2_retry (u4/u5/u6). Routing: failure_router.route_retry_failure (u3).
|
||||||
|
# Pipeline wiring (cascade_inputs assembly + retry_trace merge) lands in u9.
|
||||||
|
_SALVAGE_FAIL_BY_ACTION = {
|
||||||
|
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||||
|
"glue_compression": "glue_absorption_insufficient",
|
||||||
|
"font_step_compression": "font_step_insufficient",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _attempt_salvage_chain(
|
||||||
|
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
|
||||||
|
zones_data: list[dict], layout_preset: str, layout_css: dict,
|
||||||
|
cascade_inputs: dict, initial_failure_type: str, gap_px: int,
|
||||||
|
) -> dict:
|
||||||
|
"""IMP-12 u8 — deterministic Step 17 salvage cascade (cross_zone → glue → font_step).
|
||||||
|
Per stage: plan → apply CSS → rerender → run_overflow_check. PASS promotes final.html;
|
||||||
|
cascade-exit (layout_adjust/frame_reselect/none) or all-fail preserves (b) revert.
|
||||||
|
Honors IMP-09 dynamic_cols / fr_default gate.
|
||||||
|
"""
|
||||||
|
trace = {"salvage_attempted": False, "salvage_passed": False, "salvage_steps": []}
|
||||||
|
if layout_css.get("dynamic_cols", False) or not layout_css.get("dynamic_rows", False):
|
||||||
|
trace["salvage_skipped_reason"] = "IMP-09 gate — dynamic_cols/no dynamic_rows; cascade no-op"
|
||||||
|
return trace
|
||||||
|
trace["salvage_attempted"] = True
|
||||||
|
failure_type = initial_failure_type
|
||||||
|
ci = cascade_inputs
|
||||||
|
for _ in range(len(_SALVAGE_FAIL_BY_ACTION)):
|
||||||
|
routing = route_retry_failure(failure_type) or {}
|
||||||
|
next_action = routing.get("next_proposed_action")
|
||||||
|
if next_action not in _SALVAGE_FAIL_BY_ACTION:
|
||||||
|
trace["salvage_terminal_action"] = next_action
|
||||||
|
trace["salvage_terminal_rationale"] = routing.get("rationale")
|
||||||
|
return trace
|
||||||
|
if next_action == "cross_zone_redistribute":
|
||||||
|
if ci.get("fit_analysis") is None:
|
||||||
|
plan = {"action": "cross_zone_redistribute", "feasible": False,
|
||||||
|
"failure_reason": "cascade_inputs.fit_analysis missing — cross_zone_redistribute requires fit_analysis."}
|
||||||
|
else:
|
||||||
|
plan = plan_cross_zone_redistribute(
|
||||||
|
fit_analysis=ci["fit_analysis"], containers=ci.get("containers") or {},
|
||||||
|
min_margin_px=ci.get("min_margin_px"))
|
||||||
|
apply_fn = apply_cross_zone_redistribute_css
|
||||||
|
elif next_action == "glue_compression":
|
||||||
|
plan = plan_glue_compression(
|
||||||
|
excess_px=float(ci.get("excess_px") or 0.0),
|
||||||
|
block_count=int(ci.get("block_count") or 0),
|
||||||
|
zone_position=str(ci.get("zone_position") or ""))
|
||||||
|
apply_fn = apply_glue_compression_css
|
||||||
|
else:
|
||||||
|
plan = plan_font_step_compression(
|
||||||
|
current_font_px=float(ci.get("current_font_px") or 0.0),
|
||||||
|
excess_after_glue_px=float(ci.get("excess_after_glue_px") or ci.get("excess_px") or 0.0),
|
||||||
|
available_lines=int(ci.get("available_lines") or 0),
|
||||||
|
chars_per_line=int(ci.get("chars_per_line") or 0),
|
||||||
|
zone_position=str(ci.get("zone_position") or ""))
|
||||||
|
apply_fn = apply_font_step_compression_css
|
||||||
|
css_override = apply_fn(plan) if (plan and plan.get("feasible")) else ""
|
||||||
|
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
|
||||||
|
candidate_html, candidate_overflow, passed = None, None, False
|
||||||
|
if css_override:
|
||||||
|
base = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=gap_px)
|
||||||
|
style = f"<style>\n{css_override}\n</style>"
|
||||||
|
candidate_html = base.replace("</head>", f"{style}\n</head>", 1) if "</head>" in base else style + base
|
||||||
|
candidate_path.write_text(candidate_html, encoding="utf-8")
|
||||||
|
candidate_overflow = run_overflow_check(candidate_path)
|
||||||
|
passed = bool(candidate_overflow.get("passed", False))
|
||||||
|
step = {"action": next_action, "plan": plan, "passed": passed,
|
||||||
|
"css_override": css_override or None,
|
||||||
|
"candidate_path": str(candidate_path.relative_to(PROJECT_ROOT)) if css_override else None}
|
||||||
|
if passed:
|
||||||
|
out_path.write_text(candidate_html, encoding="utf-8")
|
||||||
|
step["post_salvage_overflow"] = candidate_overflow
|
||||||
|
trace["salvage_steps"].append(step)
|
||||||
|
trace["salvage_passed"] = True
|
||||||
|
return trace
|
||||||
|
step["failure_reason"] = (
|
||||||
|
(plan.get("failure_reason") if isinstance(plan, dict) else None)
|
||||||
|
or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
|
||||||
|
or "infeasible or no CSS emitted")
|
||||||
|
trace["salvage_steps"].append(step)
|
||||||
|
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
|
||||||
|
return trace
|
||||||
|
|
||||||
|
|
||||||
def render_slide(slide_title: str, slide_footer: Optional[str],
|
def render_slide(slide_title: str, slide_footer: Optional[str],
|
||||||
zones_data: list[dict], layout_preset: str,
|
zones_data: list[dict], layout_preset: str,
|
||||||
layout_css: dict, gap_px: int = GRID_GAP) -> str:
|
layout_css: dict, gap_px: int = GRID_GAP,
|
||||||
|
*, embedded_mode: str = "auto") -> str:
|
||||||
"""Single slide HTML — slide_base.html + 8-preset layout vocabulary.
|
"""Single slide HTML — slide_base.html + 8-preset layout vocabulary.
|
||||||
|
|
||||||
layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag.
|
layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag.
|
||||||
Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입.
|
Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입.
|
||||||
|
|
||||||
|
embedded_mode (IMP-14): "auto" | "embedded" | "standalone". Controls
|
||||||
|
slide_base.html body CSS contract. Default "auto" preserves backward-compat
|
||||||
|
with run_overflow_check standalone path and lets iframe consumers signal via
|
||||||
|
?embedded=1 or window.self!==window.top.
|
||||||
"""
|
"""
|
||||||
|
if embedded_mode not in ("auto", "embedded", "standalone"):
|
||||||
|
raise ValueError(
|
||||||
|
f"render_slide: invalid embedded_mode={embedded_mode!r}; "
|
||||||
|
"expected one of 'auto', 'embedded', 'standalone'"
|
||||||
|
)
|
||||||
env = Environment(
|
env = Environment(
|
||||||
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
loader=FileSystemLoader(str(TEMPLATE_DIR)),
|
||||||
autoescape=select_autoescape(["html"]),
|
autoescape=select_autoescape(["html"]),
|
||||||
@@ -1994,6 +2071,7 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
|
|||||||
layout_css=layout_css,
|
layout_css=layout_css,
|
||||||
gap_px=gap_px,
|
gap_px=gap_px,
|
||||||
token_css=_read_token_css(),
|
token_css=_read_token_css(),
|
||||||
|
embedded_mode=embedded_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2058,6 +2136,14 @@ def run_overflow_check(html_path: Path) -> dict:
|
|||||||
|
|
||||||
const zones = [];
|
const zones = [];
|
||||||
const zone_geometries_px = [];
|
const zone_geometries_px = [];
|
||||||
|
|
||||||
|
// IMP-15 실행-2 (issue #46) — element-identity dedup map for table_events.
|
||||||
|
// Map<Element, integer> keyed by DOM node reference (NOT class string) so that
|
||||||
|
// two wrappers sharing identical className resolve to distinct map entries.
|
||||||
|
// Populated alongside the existing per-zone clipped_inner scan below.
|
||||||
|
const clippedWrapperMap = new Map();
|
||||||
|
let clippedIdxCounter = 0;
|
||||||
|
|
||||||
slide.querySelectorAll('.zone').forEach((z) => {
|
slide.querySelectorAll('.zone').forEach((z) => {
|
||||||
const pos = z.getAttribute('data-zone-position') || 'unknown';
|
const pos = z.getAttribute('data-zone-position') || 'unknown';
|
||||||
const tid = z.getAttribute('data-template-id') || '?';
|
const tid = z.getAttribute('data-template-id') || '?';
|
||||||
@@ -2105,6 +2191,13 @@ def run_overflow_check(html_path: Path) -> dict:
|
|||||||
scrollWidth: el.scrollWidth,
|
scrollWidth: el.scrollWidth,
|
||||||
scrollHeight: el.scrollHeight,
|
scrollHeight: el.scrollHeight,
|
||||||
});
|
});
|
||||||
|
// IMP-15 실행-2 (issue #46) — element-identity registration.
|
||||||
|
// Key by DOM node `el`, NOT className: two wrappers with identical
|
||||||
|
// class string still hash to distinct Map entries.
|
||||||
|
if (!clippedWrapperMap.has(el)) {
|
||||||
|
clippedWrapperMap.set(el, clippedIdxCounter);
|
||||||
|
clippedIdxCounter++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
m.clipped_inner = clipped;
|
m.clipped_inner = clipped;
|
||||||
@@ -2140,7 +2233,98 @@ def run_overflow_check(html_path: Path) -> dict:
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px };
|
// IMP-15 실행-1 (issue #45) — image_events[] for image_aspect_mismatch detection.
|
||||||
|
// 하나의 entry per <img> under .slide. natural vs rendered aspect 비교.
|
||||||
|
// zone_position : closest('.zone') data-zone-position. 없으면 literal "unknown".
|
||||||
|
const image_events = [];
|
||||||
|
slide.querySelectorAll('img').forEach((img) => {
|
||||||
|
const parentZone = img.closest('.zone');
|
||||||
|
const zonePos = parentZone
|
||||||
|
? (parentZone.getAttribute('data-zone-position') || 'unknown')
|
||||||
|
: 'unknown';
|
||||||
|
const zoneTid = parentZone
|
||||||
|
? (parentZone.getAttribute('data-template-id') || '?')
|
||||||
|
: '?';
|
||||||
|
const imgRect = img.getBoundingClientRect();
|
||||||
|
const rendered_w = imgRect.width;
|
||||||
|
const rendered_h = imgRect.height;
|
||||||
|
const natural_w = img.naturalWidth;
|
||||||
|
const natural_h = img.naturalHeight;
|
||||||
|
const natural_ratio = (natural_w > 0 && natural_h > 0)
|
||||||
|
? (natural_w / natural_h)
|
||||||
|
: null;
|
||||||
|
const rendered_ratio = (rendered_w > 0 && rendered_h > 0)
|
||||||
|
? (rendered_w / rendered_h)
|
||||||
|
: null;
|
||||||
|
const delta = (natural_ratio !== null && rendered_ratio !== null)
|
||||||
|
? (rendered_ratio - natural_ratio)
|
||||||
|
: null;
|
||||||
|
image_events.push({
|
||||||
|
src: img.getAttribute('src') || '',
|
||||||
|
zone_position: zonePos,
|
||||||
|
zone_template_id: zoneTid,
|
||||||
|
natural_w: natural_w,
|
||||||
|
natural_h: natural_h,
|
||||||
|
rendered_w: Math.round(rendered_w),
|
||||||
|
rendered_h: Math.round(rendered_h),
|
||||||
|
natural_ratio: natural_ratio,
|
||||||
|
rendered_ratio: rendered_ratio,
|
||||||
|
delta: delta,
|
||||||
|
bbox: {
|
||||||
|
x: Math.round(imgRect.left - slideRect.left),
|
||||||
|
y: Math.round(imgRect.top - slideRect.top),
|
||||||
|
w: Math.round(rendered_w),
|
||||||
|
h: Math.round(rendered_h),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// IMP-15 실행-2 (issue #46) — table_events[] for table_self_overflow detection.
|
||||||
|
// One entry per <table> under .slide. wrapper_clipped_index is the integer index
|
||||||
|
// (from clippedWrapperMap) of the nearest ancestor that is itself in the clipped
|
||||||
|
// wrapper set, or null. Element-identity walk (NOT className) so that two same-class
|
||||||
|
// wrappers (W1 clipped, W2 not) resolve independently for any contained <table>.
|
||||||
|
const table_events = [];
|
||||||
|
slide.querySelectorAll('table').forEach((tbl) => {
|
||||||
|
const parentZone = tbl.closest('.zone');
|
||||||
|
const zonePos = parentZone
|
||||||
|
? (parentZone.getAttribute('data-zone-position') || 'unknown')
|
||||||
|
: 'unknown';
|
||||||
|
const zoneTid = parentZone
|
||||||
|
? (parentZone.getAttribute('data-template-id') || '?')
|
||||||
|
: '?';
|
||||||
|
let wrapper_clipped_index = null;
|
||||||
|
let node = tbl.parentElement;
|
||||||
|
while (node && node !== slide) {
|
||||||
|
if (clippedWrapperMap.has(node)) {
|
||||||
|
wrapper_clipped_index = clippedWrapperMap.get(node);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
const tblRect = tbl.getBoundingClientRect();
|
||||||
|
const dx = tbl.scrollWidth - tbl.clientWidth;
|
||||||
|
const dy = tbl.scrollHeight - tbl.clientHeight;
|
||||||
|
table_events.push({
|
||||||
|
zone_position: zonePos,
|
||||||
|
zone_template_id: zoneTid,
|
||||||
|
clientWidth: tbl.clientWidth,
|
||||||
|
clientHeight: tbl.clientHeight,
|
||||||
|
scrollWidth: tbl.scrollWidth,
|
||||||
|
scrollHeight: tbl.scrollHeight,
|
||||||
|
excess_x: Math.max(0, dx),
|
||||||
|
excess_y: Math.max(0, dy),
|
||||||
|
wrapper_clipped_index: wrapper_clipped_index,
|
||||||
|
bbox: {
|
||||||
|
x: Math.round(tblRect.left - slideRect.left),
|
||||||
|
y: Math.round(tblRect.top - slideRect.top),
|
||||||
|
w: Math.round(tblRect.width),
|
||||||
|
h: Math.round(tblRect.height),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events, table_events };
|
||||||
""")
|
""")
|
||||||
|
|
||||||
screenshot_path = html_path.parent / "preview.png"
|
screenshot_path = html_path.parent / "preview.png"
|
||||||
@@ -2180,6 +2364,46 @@ def run_overflow_check(html_path: Path) -> dict:
|
|||||||
f"(content {c['scrollHeight']} vs container {c['clientHeight']})"
|
f"(content {c['scrollHeight']} vs container {c['clientHeight']})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# IMP-15 실행-1 (issue #45) — image_aspect_mismatch aggregation.
|
||||||
|
# |natural_ratio - rendered_ratio| > IMAGE_ASPECT_DELTA_TOL ⇒ fail_reason append.
|
||||||
|
# Entries with null ratio (image not loaded / natural dims = 0) are skipped (no false positive).
|
||||||
|
for ev in result.get("image_events", []):
|
||||||
|
delta = ev.get("delta")
|
||||||
|
if delta is None:
|
||||||
|
continue
|
||||||
|
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
|
||||||
|
n_ratio = ev.get("natural_ratio")
|
||||||
|
r_ratio = ev.get("rendered_ratio")
|
||||||
|
src = ev.get("src", "")
|
||||||
|
pos = ev.get("zone_position", "unknown")
|
||||||
|
tid = ev.get("zone_template_id", "?")
|
||||||
|
fail_reasons.append(
|
||||||
|
f"image aspect mismatch in zone--{pos}: "
|
||||||
|
f"natural={n_ratio:.3f} rendered={r_ratio:.3f} delta={delta:+.3f} "
|
||||||
|
f"(template={tid}, tol={IMAGE_ASPECT_DELTA_TOL}, src={src})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# IMP-15 실행-2 (issue #46) — table_self_overflow aggregation.
|
||||||
|
# Emit fail_reason only when (excess_x>TOL OR excess_y>TOL) AND wrapper_clipped_index is None.
|
||||||
|
# The clipped-wrapper case is already accounted for by the clipped_inner fail_reason above;
|
||||||
|
# element-identity dedup (clippedWrapperMap keyed by DOM node ref, NOT className) prevents
|
||||||
|
# double-counting and—critically—prevents two same-class wrappers from masking each other.
|
||||||
|
for ev in result.get("table_events", []):
|
||||||
|
if ev.get("wrapper_clipped_index") is not None:
|
||||||
|
continue
|
||||||
|
excess_x = ev.get("excess_x", 0) or 0
|
||||||
|
excess_y = ev.get("excess_y", 0) or 0
|
||||||
|
if excess_x > TABLE_SCROLL_TOL_PX or excess_y > TABLE_SCROLL_TOL_PX:
|
||||||
|
pos = ev.get("zone_position", "unknown")
|
||||||
|
tid = ev.get("zone_template_id", "?")
|
||||||
|
fail_reasons.append(
|
||||||
|
f"table self-overflow in zone--{pos}: "
|
||||||
|
f"excess {excess_y}px vert / {excess_x}px horiz "
|
||||||
|
f"(content {ev.get('scrollWidth')}x{ev.get('scrollHeight')} vs "
|
||||||
|
f"container {ev.get('clientWidth')}x{ev.get('clientHeight')}, "
|
||||||
|
f"template={tid}, tol={TABLE_SCROLL_TOL_PX})"
|
||||||
|
)
|
||||||
|
|
||||||
result["passed"] = len(fail_reasons) == 0
|
result["passed"] = len(fail_reasons) == 0
|
||||||
result["fail_reasons"] = fail_reasons
|
result["fail_reasons"] = fail_reasons
|
||||||
return result
|
return result
|
||||||
@@ -3633,7 +3857,14 @@ def run_phase_z2_mvp1(
|
|||||||
dz["ratio"] = geo["zone_height_ratio"]
|
dz["ratio"] = geo["zone_height_ratio"]
|
||||||
dz["width_px"] = geo["zone_width_px"]
|
dz["width_px"] = geo["zone_width_px"]
|
||||||
dz["width_ratio"] = geo["zone_width_ratio"]
|
dz["width_ratio"] = geo["zone_width_ratio"]
|
||||||
if layout_css["dynamic_rows"]:
|
if layout_css["dynamic_rows"] and layout_css.get("dynamic_cols"):
|
||||||
|
print(
|
||||||
|
f" zones : 2-D heights {layout_css['heights_px']} px, "
|
||||||
|
f"widths {layout_css['widths_px']} px, "
|
||||||
|
f"ratios {layout_css['ratios']}, "
|
||||||
|
f"width_ratios {layout_css['width_ratios']}"
|
||||||
|
)
|
||||||
|
elif layout_css["dynamic_rows"]:
|
||||||
print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}")
|
print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}")
|
||||||
elif layout_css.get("dynamic_cols"):
|
elif layout_css.get("dynamic_cols"):
|
||||||
print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}")
|
print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}")
|
||||||
@@ -4000,6 +4231,24 @@ def run_phase_z2_mvp1(
|
|||||||
"delegated_to": delegated,
|
"delegated_to": delegated,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog
|
||||||
|
# frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px).
|
||||||
|
# None when contract unregistered (frontend tolerates undefined).
|
||||||
|
# Single get_contract lookup binds both catalog_registered and min_height_px.
|
||||||
|
v4_all_judgments_list = []
|
||||||
|
for c in v4_all_for_unit:
|
||||||
|
_contract = get_contract(c.template_id)
|
||||||
|
v4_all_judgments_list.append({
|
||||||
|
"template_id": c.template_id,
|
||||||
|
"frame_id": c.frame_id,
|
||||||
|
"frame_number": c.frame_number,
|
||||||
|
"v4_rank": c.v4_rank,
|
||||||
|
"confidence": c.confidence,
|
||||||
|
"label": c.label,
|
||||||
|
"catalog_registered": _contract is not None,
|
||||||
|
"min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"),
|
||||||
|
})
|
||||||
|
|
||||||
application_plan_units.append({
|
application_plan_units.append({
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"layout_preset": layout_preset,
|
"layout_preset": layout_preset,
|
||||||
@@ -4035,18 +4284,8 @@ def run_phase_z2_mvp1(
|
|||||||
# v4_all_judgments 는 reject 포함.
|
# v4_all_judgments 는 reject 포함.
|
||||||
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
|
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
|
||||||
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
|
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
|
||||||
"v4_all_judgments": [
|
# IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered).
|
||||||
{
|
"v4_all_judgments": v4_all_judgments_list,
|
||||||
"template_id": c.template_id,
|
|
||||||
"frame_id": c.frame_id,
|
|
||||||
"frame_number": c.frame_number,
|
|
||||||
"v4_rank": c.v4_rank,
|
|
||||||
"confidence": c.confidence,
|
|
||||||
"label": c.label,
|
|
||||||
"catalog_registered": get_contract(c.template_id) is not None,
|
|
||||||
}
|
|
||||||
for c in v4_all_for_unit
|
|
||||||
],
|
|
||||||
"application_candidates": app_candidates,
|
"application_candidates": app_candidates,
|
||||||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
|
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
|
||||||
# additive fields. None / False / [] when no override CLI used.
|
# additive fields. None / False / [] when no override CLI used.
|
||||||
@@ -4272,7 +4511,8 @@ def run_phase_z2_mvp1(
|
|||||||
note=(
|
note=(
|
||||||
"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. "
|
"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. "
|
||||||
"Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. "
|
"Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. "
|
||||||
"image / table 검사 부재 — Step 14 ⚠ partial."
|
"image_aspect_mismatch 검사 추가 (IMP-15 실행-1, issue #45) — image_events[] + fail_reasons. "
|
||||||
|
"table 검사 부재 (실행-2 잔류) — Step 14 ⚠ partial (table only)."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4349,19 +4589,85 @@ def run_phase_z2_mvp1(
|
|||||||
# retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보).
|
# retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보).
|
||||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||||
|
|
||||||
|
# 11.7 IMP-12 u9 — Step 17 deterministic salvage cascade.
|
||||||
|
# Triggered on donor_slack_insufficient / no_donor_candidates: cross_zone_redistribute
|
||||||
|
# → glue_compression → font_step_compression (terminal → layout_adjust/frame_reselect).
|
||||||
|
_ft = (retry_trace.get("failure_classification") or {}).get("failure_type")
|
||||||
|
if _ft in {"donor_slack_insufficient", "no_donor_candidates"}:
|
||||||
|
_plan = retry_trace.get("plan") or {}
|
||||||
|
_tpos = _plan.get("target_zone_position")
|
||||||
|
_tdz = next((dz for dz in debug_zones if dz.get("position") == _tpos), {}) or {}
|
||||||
|
_excess = float(_plan.get("target_excess_y") or 0.0)
|
||||||
|
# Synthesize FitAnalysis from debug_zones + per-zone overflow so cross_zone_redistribute
|
||||||
|
# can compute feasibility against real Phase Z geometry (Phase Q's calculate_fit is
|
||||||
|
# not invoked in Phase Z — see comp_debug v4_fallback_summary policy at 2839-2843).
|
||||||
|
# Each position becomes a "role"; all share conceptual zone "slide_body" so
|
||||||
|
# fit_verifier.redistribute trades shortfalls between them. shortfall_px is signed:
|
||||||
|
# scrollHeight - clientHeight > 0 = deficit, < 0 = surplus (matches redistribute()).
|
||||||
|
from src.fit_verifier import FitAnalysis, RoleFit
|
||||||
|
_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
|
||||||
|
_fa_roles, _fa_containers = {}, {}
|
||||||
|
for _dz in debug_zones:
|
||||||
|
_pos = _dz.get("position")
|
||||||
|
if not _pos:
|
||||||
|
continue
|
||||||
|
_alloc = float(_dz.get("height_px") or 0.0)
|
||||||
|
_zm = _zof.get(_pos) or {}
|
||||||
|
_ch = float(_zm.get("clientHeight") or _alloc)
|
||||||
|
_sh = float(_zm.get("scrollHeight") or _ch)
|
||||||
|
_fa_roles[_pos] = RoleFit(role=_pos, allocated_px=_alloc, shortfall_px=_sh - _ch)
|
||||||
|
_fa_containers[_pos] = {"zone": "slide_body", "height_px": int(_alloc)}
|
||||||
|
_salvage_trace = _attempt_salvage_chain(
|
||||||
|
run_dir=run_dir, out_path=out_path,
|
||||||
|
slide_title=slide_title, slide_footer=slide_footer,
|
||||||
|
zones_data=zones_data, layout_preset=layout_preset, layout_css=layout_css,
|
||||||
|
cascade_inputs={
|
||||||
|
"fit_analysis": FitAnalysis(roles=_fa_roles),
|
||||||
|
"containers": _fa_containers,
|
||||||
|
"min_margin_px": None,
|
||||||
|
"excess_px": _excess, "excess_after_glue_px": _excess,
|
||||||
|
"block_count": len((_tdz.get("placement_trace") or {}).get("internal_regions") or []) or 1,
|
||||||
|
"zone_position": _tpos or "",
|
||||||
|
"current_font_px": float(_tdz.get("font_size_px") or 0.0),
|
||||||
|
"available_lines": int(_tdz.get("available_lines") or 0),
|
||||||
|
"chars_per_line": int(_tdz.get("chars_per_line") or 0),
|
||||||
|
},
|
||||||
|
initial_failure_type=_ft, gap_px=GRID_GAP,
|
||||||
|
)
|
||||||
|
retry_trace.update(_salvage_trace)
|
||||||
|
if _salvage_trace.get("salvage_passed"):
|
||||||
|
overflow = (_salvage_trace["salvage_steps"][-1].get("post_salvage_overflow")) or overflow
|
||||||
|
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
|
||||||
|
router_decision = route_fit_classification(fit_classification)
|
||||||
|
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
|
||||||
|
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
|
||||||
|
router_decision["frame_reselect_fallback_status"] = (
|
||||||
|
"pre_render_rank_2_3_fallback_implemented; "
|
||||||
|
"post_render visual-fail rerender remains routed through existing action trace"
|
||||||
|
)
|
||||||
|
# Refresh failure_classification / next_action_proposal so Step 18 / Step 19
|
||||||
|
# do not surface the pre-salvage donor_slack_insufficient / no_donor_candidates
|
||||||
|
# state. classify_retry_failure short-circuits on salvage_passed=True → both
|
||||||
|
# fields become None (no failure to classify, no escalation pending).
|
||||||
|
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||||
|
|
||||||
# ─── Step 17: Implemented Action (retry) ───
|
# ─── Step 17: Implemented Action (retry) ───
|
||||||
_write_step_artifact(
|
_write_step_artifact(
|
||||||
run_dir, 17, "retry_trace",
|
run_dir, 17, "retry_trace",
|
||||||
data=retry_trace,
|
data=retry_trace,
|
||||||
step_status=(
|
step_status=(
|
||||||
"failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
"done" if retry_trace.get("retry_passed") or retry_trace.get("salvage_passed")
|
||||||
else "done" if retry_trace.get("retry_passed")
|
else "failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed")
|
||||||
else "skipped"
|
else "skipped"
|
||||||
),
|
),
|
||||||
pipeline_path_connected=True,
|
pipeline_path_connected=True,
|
||||||
inputs=["step16_router_decision.json"],
|
inputs=["step16_router_decision.json"],
|
||||||
outputs=["step17_retry_trace.json"],
|
outputs=["step17_retry_trace.json"],
|
||||||
note="A3 — zone_ratio_retry action only. 다른 actions (layout_adjust / frame_internal_fit 등) 미구현 — Step 17 ⚠ partial.",
|
note=(
|
||||||
|
"A3 — zone_ratio_retry + IMP-12 u8/u9 salvage cascade "
|
||||||
|
"(cross_zone_redistribute → glue_compression → font_step_compression). "
|
||||||
|
"Terminal actions (layout_adjust / frame_reselect / details_popup_escalation) still MISSING."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ─── Step 18: Failure Classification (A4-1) ───
|
# ─── Step 18: Failure Classification (A4-1) ───
|
||||||
|
|||||||
+207
-13
@@ -162,35 +162,55 @@ def plan_zone_ratio_retry(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# A3 minimal : single primary donor (multi-donor 는 future)
|
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
|
||||||
primary_donor = donor_candidates[0]
|
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
|
||||||
if primary_donor["slack"] < target_added_px:
|
if aggregate_slack_available < target_added_px:
|
||||||
return {
|
return {
|
||||||
**base_plan,
|
**base_plan,
|
||||||
"feasible": False,
|
"feasible": False,
|
||||||
"donor_zone_position": primary_donor["position"],
|
"donor_zone_position": donor_candidates[0]["position"],
|
||||||
"donor_max_slack": primary_donor["slack"],
|
"donor_max_slack": donor_candidates[0]["slack"],
|
||||||
"donor_reduced_px": 0,
|
"donor_reduced_px": 0,
|
||||||
|
"donors_used": [],
|
||||||
|
"aggregate_slack_used": 0,
|
||||||
|
"aggregate_slack_available": aggregate_slack_available,
|
||||||
"zones_after": dict(zones_before),
|
"zones_after": dict(zones_before),
|
||||||
"failure_reason": (
|
"failure_reason": (
|
||||||
f"primary donor '{primary_donor['position']}' slack {primary_donor['slack']}px "
|
f"primary donor '{donor_candidates[0]['position']}' slack "
|
||||||
f"< target_added_px {target_added_px}px (excess_y {target_excess_y} + "
|
f"{donor_candidates[0]['slack']}px (aggregate "
|
||||||
f"safety_margin {safety_margin_px}). multi-donor aggregation is future axis."
|
f"{aggregate_slack_available}px across {len(donor_candidates)} "
|
||||||
|
f"candidate(s)) < target_added_px {target_added_px}px "
|
||||||
|
f"(excess_y {target_excess_y} + safety_margin {safety_margin_px})."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# feasible
|
# feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
|
||||||
zones_after = dict(zones_before)
|
zones_after = dict(zones_before)
|
||||||
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
|
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
|
||||||
zones_after[primary_donor["position"]] = (
|
donors_used: list[dict] = []
|
||||||
zones_before[primary_donor["position"]] - target_added_px
|
remaining = target_added_px
|
||||||
)
|
for donor in donor_candidates:
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
take = min(donor["slack"], remaining)
|
||||||
|
zones_after[donor["position"]] = zones_before[donor["position"]] - take
|
||||||
|
donors_used.append({
|
||||||
|
"position": donor["position"],
|
||||||
|
"reduced_px": take,
|
||||||
|
"slack_before": donor["slack"],
|
||||||
|
"slack_after": donor["slack"] - take,
|
||||||
|
})
|
||||||
|
remaining -= take
|
||||||
|
|
||||||
|
primary_donor = donors_used[0]
|
||||||
return {
|
return {
|
||||||
**base_plan,
|
**base_plan,
|
||||||
"feasible": True,
|
"feasible": True,
|
||||||
"donor_zone_position": primary_donor["position"],
|
"donor_zone_position": primary_donor["position"],
|
||||||
"donor_reduced_px": target_added_px,
|
"donor_reduced_px": primary_donor["reduced_px"],
|
||||||
|
"donors_used": donors_used,
|
||||||
|
"aggregate_slack_used": target_added_px,
|
||||||
|
"aggregate_slack_available": aggregate_slack_available,
|
||||||
"zones_after": zones_after,
|
"zones_after": zones_after,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,3 +233,177 @@ def apply_retry_to_layout_css(layout_css: dict, plan: dict, zones_data: list[dic
|
|||||||
new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy()
|
new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy()
|
||||||
new_layout_css["raw_zone_layout"]["retry_applied"] = True
|
new_layout_css["raw_zone_layout"]["retry_applied"] = True
|
||||||
return new_layout_css
|
return new_layout_css
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────
|
||||||
|
# IMP-12 u4 : cross_zone_redistribute (Step 17 salvage cascade — stage 1)
|
||||||
|
# Wraps src.fit_verifier.redistribute in the Step-17 plan signature so the
|
||||||
|
# failure-router cascade (donor_slack_insufficient → cross_zone_redistribute)
|
||||||
|
# can drive it deterministically. Plan-only — no rerender / no final.html
|
||||||
|
# mutation. Side-effect-free (operates on deepcopy of fit_analysis).
|
||||||
|
# ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def plan_cross_zone_redistribute(
|
||||||
|
*,
|
||||||
|
fit_analysis,
|
||||||
|
containers: dict,
|
||||||
|
min_margin_px: float | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Cross-zone (intra-zone role-to-role) redistribute plan.
|
||||||
|
|
||||||
|
Plan-only — no rerender / no final.html mutation. Side-effect-free
|
||||||
|
(operates on deepcopy of fit_analysis).
|
||||||
|
"""
|
||||||
|
from copy import deepcopy
|
||||||
|
from src.fit_verifier import redistribute as _fv_redistribute
|
||||||
|
|
||||||
|
role_heights_before = {
|
||||||
|
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
|
||||||
|
}
|
||||||
|
base_plan = {
|
||||||
|
"action": "cross_zone_redistribute",
|
||||||
|
"role_heights_before": role_heights_before,
|
||||||
|
}
|
||||||
|
if not role_heights_before:
|
||||||
|
return {**base_plan, "feasible": False, "role_heights_after": {},
|
||||||
|
"can_redistribute": False,
|
||||||
|
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
|
||||||
|
|
||||||
|
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
|
||||||
|
redistribution = dict(result.redistribution or {})
|
||||||
|
can_redistribute = bool(result.can_redistribute)
|
||||||
|
|
||||||
|
if not can_redistribute or not redistribution:
|
||||||
|
return {
|
||||||
|
**base_plan,
|
||||||
|
"feasible": False,
|
||||||
|
"role_heights_after": redistribution or dict(role_heights_before),
|
||||||
|
"can_redistribute": can_redistribute,
|
||||||
|
"failure_reason": (
|
||||||
|
"fit_verifier.redistribute can_redistribute=False — single-role zone(s) "
|
||||||
|
"or surplus insufficient to cover deficit within envelope."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {**base_plan, "feasible": True, "role_heights_after": redistribution,
|
||||||
|
"can_redistribute": True}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_cross_zone_redistribute_css(plan: dict) -> str:
|
||||||
|
"""Emit scoped role-height CSS overrides — [data-role="<role>"] only.
|
||||||
|
|
||||||
|
Honors feedback_phase_z_spacing_direction: no :root / body / .slide / .zone selectors.
|
||||||
|
"""
|
||||||
|
if not plan.get("feasible"):
|
||||||
|
return ""
|
||||||
|
role_heights_after = plan.get("role_heights_after") or {}
|
||||||
|
role_heights_before = plan.get("role_heights_before") or {}
|
||||||
|
rules: list[str] = []
|
||||||
|
for role, new_height in role_heights_after.items():
|
||||||
|
before = role_heights_before.get(role)
|
||||||
|
if before is None or abs(float(before) - float(new_height)) < 0.5:
|
||||||
|
continue
|
||||||
|
new_h_int = int(round(float(new_height)))
|
||||||
|
rules.append(
|
||||||
|
f'[data-role="{role}"] {{ height: {new_h_int}px; min-height: {new_h_int}px; }}'
|
||||||
|
)
|
||||||
|
return "\n".join(rules)
|
||||||
|
|
||||||
|
|
||||||
|
# IMP-12 u5 : glue_compression — Step 17 salvage cascade (stage 2).
|
||||||
|
# Wraps space_allocator.compute_glue_css_overrides in the Step-17 plan signature.
|
||||||
|
# Frame-scoped: overrides emitted only under [data-zone-position="<pos>"]
|
||||||
|
# (feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
|
||||||
|
|
||||||
|
|
||||||
|
def plan_glue_compression(
|
||||||
|
*, excess_px: float, block_count: int, zone_position: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Glue compression plan (frame-scoped). feasible only when envelope absorbs excess."""
|
||||||
|
from src.space_allocator import (
|
||||||
|
calculate_glue_absorption, compute_glue_css_overrides,
|
||||||
|
)
|
||||||
|
base = {"action": "glue_compression", "zone_position": zone_position,
|
||||||
|
"excess_px": float(excess_px), "block_count": int(block_count)}
|
||||||
|
if excess_px <= 0:
|
||||||
|
return {**base, "feasible": False, "overrides": {}, "absorption_max_px": 0.0,
|
||||||
|
"failure_reason": "excess_px <= 0 — no compression needed."}
|
||||||
|
absorption_max = float(calculate_glue_absorption(block_count))
|
||||||
|
overrides = compute_glue_css_overrides(excess_px, block_count) or {}
|
||||||
|
if excess_px > absorption_max:
|
||||||
|
return {**base, "feasible": False, "overrides": overrides,
|
||||||
|
"absorption_max_px": absorption_max,
|
||||||
|
"failure_reason": (
|
||||||
|
f"glue envelope insufficient — excess_px {excess_px:.1f} > "
|
||||||
|
f"max absorption {absorption_max:.1f}px "
|
||||||
|
f"(block_count={block_count}, SPACING_GLUE shrink budget)."
|
||||||
|
)}
|
||||||
|
return {**base, "feasible": True, "overrides": overrides,
|
||||||
|
"absorption_max_px": absorption_max}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_glue_compression_css(plan: dict) -> str:
|
||||||
|
"""Emit zone-scoped glue CSS — wrapped in [data-zone-position="<pos>"] only."""
|
||||||
|
if not plan.get("feasible"):
|
||||||
|
return ""
|
||||||
|
zone_position = plan.get("zone_position")
|
||||||
|
overrides = plan.get("overrides") or {}
|
||||||
|
if not zone_position or not overrides:
|
||||||
|
return ""
|
||||||
|
var_lines = "\n".join(f" {k}: {v};" for k, v in overrides.items())
|
||||||
|
return f'[data-zone-position="{zone_position}"] {{\n{var_lines}\n}}'
|
||||||
|
|
||||||
|
|
||||||
|
# IMP-12 u6 : font_step_compression — Step 17 salvage cascade (stage 3).
|
||||||
|
# Wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
|
||||||
|
# Zone-scoped: only [data-zone-position="<pos>"] (no :root/body/.slide/.zone).
|
||||||
|
|
||||||
|
|
||||||
|
def plan_font_step_compression(
|
||||||
|
*, current_font_px: float, excess_after_glue_px: float,
|
||||||
|
available_lines: int, chars_per_line: int, zone_position: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Font-step compression plan (zone-scoped). feasible only when FONT_SIZE_STEPS
|
||||||
|
contains a size whose line-height savings cover excess_after_glue_px. Missing
|
||||||
|
text_metrics yields feasible=False (cascade routes onward to layout_adjust)."""
|
||||||
|
from src.space_allocator import FONT_SIZE_STEPS, find_fitting_font_size
|
||||||
|
floor = float(FONT_SIZE_STEPS[-1])
|
||||||
|
base = {"action": "font_step_compression", "zone_position": zone_position,
|
||||||
|
"current_font_px": float(current_font_px),
|
||||||
|
"excess_after_glue_px": float(excess_after_glue_px),
|
||||||
|
"available_lines": int(available_lines or 0),
|
||||||
|
"chars_per_line": int(chars_per_line or 0),
|
||||||
|
"font_floor_px": floor}
|
||||||
|
if excess_after_glue_px <= 0:
|
||||||
|
return {**base, "feasible": False, "target_font_px": None,
|
||||||
|
"failure_reason": "excess_after_glue_px <= 0 — no font compression needed."}
|
||||||
|
if not available_lines or available_lines <= 0 or not chars_per_line or chars_per_line <= 0:
|
||||||
|
return {**base, "feasible": False, "target_font_px": None,
|
||||||
|
"failure_reason": "text_metrics missing — available_lines/chars_per_line required."}
|
||||||
|
if current_font_px <= floor:
|
||||||
|
return {**base, "feasible": False, "target_font_px": None,
|
||||||
|
"failure_reason": (
|
||||||
|
f"current_font_px {current_font_px:.1f} already at FONT_SIZE_STEPS floor {floor:.1f}px.")}
|
||||||
|
target = find_fitting_font_size(
|
||||||
|
current_font_px=float(current_font_px),
|
||||||
|
excess_after_glue_px=float(excess_after_glue_px),
|
||||||
|
available_lines=int(available_lines), chars_per_line=int(chars_per_line))
|
||||||
|
if target is None:
|
||||||
|
return {**base, "feasible": False, "target_font_px": None,
|
||||||
|
"failure_reason": (
|
||||||
|
f"font_step floor — {floor:.1f}px cannot absorb "
|
||||||
|
f"excess_after_glue_px={excess_after_glue_px:.1f}px "
|
||||||
|
f"(available_lines={available_lines}, FONT_SIZE_STEPS exhausted).")}
|
||||||
|
return {**base, "feasible": True, "target_font_px": float(target)}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_font_step_compression_css(plan: dict) -> str:
|
||||||
|
"""Emit zone-scoped font-size CSS — [data-zone-position="<pos>"] only."""
|
||||||
|
if not plan.get("feasible"):
|
||||||
|
return ""
|
||||||
|
zone_position = plan.get("zone_position")
|
||||||
|
target_font_px = plan.get("target_font_px")
|
||||||
|
if not zone_position or target_font_px is None:
|
||||||
|
return ""
|
||||||
|
return (f'[data-zone-position="{zone_position}"] {{\n'
|
||||||
|
f" font-size: {float(target_font_px):.1f}px;\n}}")
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ ACTION_RATIONALE: dict[str, str] = {
|
|||||||
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준)
|
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
|
||||||
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
||||||
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||||
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
||||||
@@ -65,6 +65,12 @@ ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
|||||||
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
|
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
|
||||||
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
|
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
|
||||||
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
|
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
|
||||||
|
# IMP-12 u7 (2026-05-18): cascade-only salvage actions (no ACTION_BY_CATEGORY row;
|
||||||
|
# surfaced via NEXT_ACTION_BY_FAILURE in phase_z2_failure_router). plan/apply pairs
|
||||||
|
# implemented in phase_z2_retry; pipeline orchestrator wiring lands in u8/u9.
|
||||||
|
"cross_zone_redistribute": "IMPLEMENTED", # u4 phase_z2_retry.plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
|
||||||
|
"glue_compression": "IMPLEMENTED", # u5 phase_z2_retry.plan_glue_compression + apply_glue_compression_css
|
||||||
|
"font_step_compression": "IMPLEMENTED", # u6 phase_z2_retry.plan_font_step_compression + apply_font_step_compression_css
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
<!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones).
|
<!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones).
|
||||||
원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. -->
|
원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. -->
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko"{% if embedded_mode == "embedded" %} class="embedded"{% endif %}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=1280">
|
<meta name="viewport" content="width=1280">
|
||||||
<title>{{ slide_title }}</title>
|
<title>{{ slide_title }}</title>
|
||||||
|
{% if embedded_mode == "auto" %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('embedded') === '1' || window.self !== window.top) {
|
||||||
|
document.documentElement.classList.add('embedded');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
<style>
|
<style>
|
||||||
/* ── existing tokens (inlined) ── */
|
/* ── existing tokens (inlined) ── */
|
||||||
{{ token_css | safe }}
|
{{ token_css | safe }}
|
||||||
@@ -20,6 +32,19 @@
|
|||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── IMP-14 A-4: embedded mode reset (iframe consumer) ──
|
||||||
|
standalone-only body centering/min-height/padding undone so the .slide
|
||||||
|
(1280×720) sits at origin without vertical shift or clipping. */
|
||||||
|
html.embedded body {
|
||||||
|
background: transparent;
|
||||||
|
display: block;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
html.embedded .slide {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 16:9 슬라이드 (single, 1280×720) ── */
|
/* ── 16:9 슬라이드 (single, 1280×720) ── */
|
||||||
.slide {
|
.slide {
|
||||||
width: 1280px; height: 720px;
|
width: 1280px; height: 720px;
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""P0-3 (2026-05-18) — orchestrator self-test minimum set.
|
||||||
|
|
||||||
|
Covers detect_agent (the bug that caused #45 infinite loop), parse_consensus,
|
||||||
|
parse_remaining_units, IMPLEMENTATION_UNITS parsing, dual-write normalize.
|
||||||
|
|
||||||
|
Run: pytest -q tests/orchestrator_unit/
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add design_agent root to sys.path so we can import orchestrator.py
|
||||||
|
ROOT = Path(__file__).parent.parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from orchestrator import (
|
||||||
|
detect_agent,
|
||||||
|
parse_consensus,
|
||||||
|
parse_remaining_units,
|
||||||
|
_is_execution_issue,
|
||||||
|
)
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecutionIssueDetection:
|
||||||
|
"""P1-4 — execution sub-issue title detection."""
|
||||||
|
|
||||||
|
def test_execution_korean_pattern(self):
|
||||||
|
assert _is_execution_issue("[IMP-15 실행-1] image_aspect_mismatch") is True
|
||||||
|
assert _is_execution_issue("[IMP-15 실행-2] table overflow") is True
|
||||||
|
assert _is_execution_issue("[IMP-15 실행 3] something") is True
|
||||||
|
|
||||||
|
def test_execution_english_pattern(self):
|
||||||
|
assert _is_execution_issue("[IMP-15 exec-1] image") is True
|
||||||
|
assert _is_execution_issue("[IMP-15 EXEC 2] table") is True
|
||||||
|
|
||||||
|
def test_non_execution_title(self):
|
||||||
|
assert _is_execution_issue("IMP-15 Step 14 visual_check 보강") is False
|
||||||
|
assert _is_execution_issue("IMP-09 B-4 다른 layout zone-geometry") is False
|
||||||
|
|
||||||
|
def test_empty_title(self):
|
||||||
|
assert _is_execution_issue("") is False
|
||||||
|
assert _is_execution_issue(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# detect_agent — the bug that caused #45 infinite loop
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestDetectAgent:
|
||||||
|
def test_claude_header(self):
|
||||||
|
assert detect_agent("[Claude #1] Stage 1 ...") == "claude"
|
||||||
|
|
||||||
|
def test_codex_header(self):
|
||||||
|
assert detect_agent("[Codex #1] Stage 1 review") == "codex"
|
||||||
|
|
||||||
|
def test_codex_body_with_claude_citation(self):
|
||||||
|
"""The exact bug from #45 — Codex body contains [Claude #N] citation in
|
||||||
|
EVIDENCE section. Old detect_agent returned 'claude' (wrong)."""
|
||||||
|
body = """[Codex #2] Stage 2 Round #1 simulation-plan verification
|
||||||
|
|
||||||
|
Verdict: NO.
|
||||||
|
|
||||||
|
=== EVIDENCE ===
|
||||||
|
- Read current-stage Gitea comment `[Claude #2] Stage 2 Round #1 - Plan` only
|
||||||
|
"""
|
||||||
|
assert detect_agent(body) == "codex", \
|
||||||
|
"Codex body containing [Claude #N] citation must still detect as codex"
|
||||||
|
|
||||||
|
def test_claude_body_with_codex_citation(self):
|
||||||
|
body = """[Claude #3] Stage 2 Round #2 - Plan
|
||||||
|
|
||||||
|
Addressing [Codex #2] findings ...
|
||||||
|
"""
|
||||||
|
assert detect_agent(body) == "claude"
|
||||||
|
|
||||||
|
def test_empty_body(self):
|
||||||
|
assert detect_agent("") is None
|
||||||
|
assert detect_agent(None) is None
|
||||||
|
assert detect_agent(" \n ") is None
|
||||||
|
|
||||||
|
def test_no_agent_header(self):
|
||||||
|
assert detect_agent("This is some random text without any agent marker") is None
|
||||||
|
|
||||||
|
def test_leading_whitespace_before_header(self):
|
||||||
|
body = " \n[Codex #1] header after whitespace"
|
||||||
|
assert detect_agent(body) == "codex"
|
||||||
|
|
||||||
|
def test_header_must_be_at_start(self):
|
||||||
|
"""Body that doesn't start with [Agent header should return None."""
|
||||||
|
body = "Some intro text.\n[Codex #1] header on second line"
|
||||||
|
# P0-1 fix: only first non-empty line is checked.
|
||||||
|
# First line = "Some intro text." → no match → None
|
||||||
|
assert detect_agent(body) is None
|
||||||
|
|
||||||
|
def test_header_with_hash_immediately(self):
|
||||||
|
"""[Codex#1] (no space) should still match per regex \\[Codex[\\s#]."""
|
||||||
|
assert detect_agent("[Codex#1] hello") == "codex"
|
||||||
|
assert detect_agent("[Claude#5] hi") == "claude"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# parse_consensus — YES/NO + rewind_target
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseConsensus:
|
||||||
|
def test_yes_only(self):
|
||||||
|
body = "Some text.\nFINAL_CONSENSUS: YES"
|
||||||
|
assert parse_consensus(body) == ("YES", None)
|
||||||
|
|
||||||
|
def test_no_with_rewind_target(self):
|
||||||
|
body = "Some text.\nrewind_target: stage_2_plan\nFINAL_CONSENSUS: NO"
|
||||||
|
assert parse_consensus(body) == ("NO", "stage_2_plan")
|
||||||
|
|
||||||
|
def test_no_with_continue_same(self):
|
||||||
|
body = "blah\nrewind_target: continue_same\nFINAL_CONSENSUS: NO"
|
||||||
|
assert parse_consensus(body) == ("NO", "continue_same")
|
||||||
|
|
||||||
|
def test_no_target_only_in_last_10_lines(self):
|
||||||
|
"""parse_consensus only scans last 10 lines."""
|
||||||
|
body = "rewind_target: stage_1_review\n" + "\n".join(["filler"] * 20) + "\nFINAL_CONSENSUS: NO"
|
||||||
|
status, target = parse_consensus(body)
|
||||||
|
assert status == "NO"
|
||||||
|
assert target is None # too far from end to be picked up
|
||||||
|
|
||||||
|
def test_no_consensus_marker(self):
|
||||||
|
assert parse_consensus("just text, no marker") == (None, None)
|
||||||
|
|
||||||
|
def test_empty_body(self):
|
||||||
|
assert parse_consensus("") == (None, None)
|
||||||
|
assert parse_consensus(None) == (None, None)
|
||||||
|
|
||||||
|
def test_unknown_rewind_target_ignored(self):
|
||||||
|
body = "rewind_target: bogus_target\nFINAL_CONSENSUS: NO"
|
||||||
|
status, target = parse_consensus(body)
|
||||||
|
assert status == "NO"
|
||||||
|
assert target is None # bogus is not in REWIND_TARGET_TO_SID
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# parse_remaining_units — Stage 3 continue_same progress detection
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseRemainingUnits:
|
||||||
|
def test_bracketed_list(self):
|
||||||
|
body = "Remaining units: [u2, u3, u4]"
|
||||||
|
assert parse_remaining_units(body) == {"u2", "u3", "u4"}
|
||||||
|
|
||||||
|
def test_comma_list_no_brackets(self):
|
||||||
|
body = "Remaining units: u5, u6, u7"
|
||||||
|
assert parse_remaining_units(body) == {"u5", "u6", "u7"}
|
||||||
|
|
||||||
|
def test_none_explicit(self):
|
||||||
|
assert parse_remaining_units("Remaining units: none") == set()
|
||||||
|
assert parse_remaining_units("Remaining units: []") == set()
|
||||||
|
assert parse_remaining_units("Remaining units: (none)") == set()
|
||||||
|
assert parse_remaining_units("Remaining units: -") == set()
|
||||||
|
|
||||||
|
def test_line_not_present(self):
|
||||||
|
assert parse_remaining_units("no remaining units mentioned here") is None
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
body = "REMAINING UNITS: [U1, U2]"
|
||||||
|
assert parse_remaining_units(body) == {"u1", "u2"}
|
||||||
|
|
||||||
|
def test_only_u_prefixed_digits(self):
|
||||||
|
"""Sentence noise ignored — only u\\d+ pattern matched."""
|
||||||
|
body = "Remaining units: I still need to do u3 and u7 work"
|
||||||
|
assert parse_remaining_units(body) == {"u3", "u7"}
|
||||||
|
|
||||||
|
def test_empty_body(self):
|
||||||
|
assert parse_remaining_units("") is None
|
||||||
|
assert parse_remaining_units(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# IMPLEMENTATION_UNITS block parsing (used in Stage 2 YES guard)
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestImplementationUnitsBlock:
|
||||||
|
"""Reproduces the parser in run_stage Stage 2 YES guard (line ~810)."""
|
||||||
|
|
||||||
|
def _parse(self, body):
|
||||||
|
iu_block_pat = re.compile(
|
||||||
|
r"===\s*IMPLEMENTATION_UNITS\s*===\s*\n(.*?)(?=\n===\s|\Z)",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
iu_unit_pat = re.compile(r"^\s*-\s*id:\s*u\d+", re.IGNORECASE | re.MULTILINE)
|
||||||
|
m = iu_block_pat.search(body or "")
|
||||||
|
return bool(m and iu_unit_pat.search(m.group(1)))
|
||||||
|
|
||||||
|
def test_valid_block(self):
|
||||||
|
body = """text
|
||||||
|
|
||||||
|
=== IMPLEMENTATION_UNITS ===
|
||||||
|
- id: u1
|
||||||
|
summary: ...
|
||||||
|
- id: u2
|
||||||
|
summary: ...
|
||||||
|
"""
|
||||||
|
assert self._parse(body) is True
|
||||||
|
|
||||||
|
def test_empty_block(self):
|
||||||
|
body = "=== IMPLEMENTATION_UNITS ===\n(no entries)\n"
|
||||||
|
assert self._parse(body) is False # header but no - id: uN entry
|
||||||
|
|
||||||
|
def test_block_missing(self):
|
||||||
|
body = "just text, no implementation_units"
|
||||||
|
assert self._parse(body) is False
|
||||||
|
|
||||||
|
def test_block_with_only_non_u_entries(self):
|
||||||
|
body = """=== IMPLEMENTATION_UNITS ===
|
||||||
|
- id: alpha
|
||||||
|
summary: ...
|
||||||
|
"""
|
||||||
|
assert self._parse(body) is False # 'alpha' is not 'u\\d+'
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Direct integration check — the #45 bug case
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestRegressionForIssue45Bug:
|
||||||
|
"""Verify the exact body shape that caused #45 infinite loop is now handled."""
|
||||||
|
|
||||||
|
def test_codex_no_with_claude_citation_full_flow(self):
|
||||||
|
body = """[Codex #3] Stage 2 Round #2 simulation-plan verification for issue #45
|
||||||
|
|
||||||
|
Verdict: NO. The plan covers main axes but violates two Stage 2 requirements.
|
||||||
|
|
||||||
|
Findings:
|
||||||
|
- Unit u1 declares tests: [] in === IMPLEMENTATION_UNITS ===
|
||||||
|
- xfail-strict mechanism unclear
|
||||||
|
|
||||||
|
=== EVIDENCE ===
|
||||||
|
Commands run:
|
||||||
|
- git rev-parse HEAD
|
||||||
|
- Read current-stage Gitea comment `[Claude #3] Stage 2 Round #2 - Plan`
|
||||||
|
|
||||||
|
rewind_target: stage_2_plan
|
||||||
|
FINAL_CONSENSUS: NO
|
||||||
|
"""
|
||||||
|
# P0-1 fix: detect_agent reads only first line → "[Codex #3]" → codex
|
||||||
|
assert detect_agent(body) == "codex", "P0-1 regression test"
|
||||||
|
# parse_consensus: NO + rewind_target stage_2_plan
|
||||||
|
status, target = parse_consensus(body)
|
||||||
|
assert status == "NO"
|
||||||
|
assert target == "stage_2_plan"
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""P3-5 (2026-05-18) — subprocess cleanup hardening verification.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
C1: 정상 종료 → tree 잔류 0
|
||||||
|
C2: timeout → TimeoutExpired raise + 자손 0
|
||||||
|
C3: grandchild spawn 후 parent timeout → grandchild 정리
|
||||||
|
C4: 외부 (orchestrator 가 spawn 안한) 프로세스 보호
|
||||||
|
C5: _kill_process_tree(self.pid) 호출해도 orchestrator 자살 안 함
|
||||||
|
C6 (CORE): parent 정상 종료 후 grandchild orphan 정리 — PID 2780 regression
|
||||||
|
|
||||||
|
Run: pytest -q tests/orchestrator_unit/test_subprocess_cleanup.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent.parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from orchestrator import (
|
||||||
|
_kill_process_tree,
|
||||||
|
_kill_tracked,
|
||||||
|
_run_with_tree_kill,
|
||||||
|
_proc_signature,
|
||||||
|
_is_same_process,
|
||||||
|
_SPAWNED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _py():
|
||||||
|
"""Path to current Python interpreter — used to spawn dummy subprocesses."""
|
||||||
|
return sys.executable
|
||||||
|
|
||||||
|
def _alive(pid):
|
||||||
|
try:
|
||||||
|
return psutil.Process(pid).is_running() and psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Signature helpers
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSignatureHelpers:
|
||||||
|
def test_proc_signature_alive(self):
|
||||||
|
p = psutil.Process(os.getpid())
|
||||||
|
sig = _proc_signature(p)
|
||||||
|
assert sig is not None
|
||||||
|
assert sig[0] == os.getpid()
|
||||||
|
assert isinstance(sig[1], float)
|
||||||
|
|
||||||
|
def test_is_same_process_orch_self_blocked(self):
|
||||||
|
"""C5 prep — orchestrator 자기 자신은 절대 same-process true 안 됨."""
|
||||||
|
p = psutil.Process(os.getpid())
|
||||||
|
sig = _proc_signature(p)
|
||||||
|
# _is_same_process 가 _ORCH_PID 체크로 False 반환해야 함.
|
||||||
|
assert _is_same_process(sig[0], sig[1]) is False
|
||||||
|
|
||||||
|
def test_is_same_process_dead_pid(self):
|
||||||
|
# 사용 가능성 낮은 PID 999999 — 거의 확실히 죽음.
|
||||||
|
assert _is_same_process(999999, time.time()) is False
|
||||||
|
|
||||||
|
def test_is_same_process_wrong_create_time(self):
|
||||||
|
"""PID 재사용 회피 검증 — 같은 PID 라도 create_time 안 맞으면 False."""
|
||||||
|
# 살아있는 외부 프로세스 빌려서 일부러 어긋난 create_time 으로 호출.
|
||||||
|
# System Idle 같은 특수 프로세스 (create_time=0) 회피 — 우리가 띄운 dummy 사용.
|
||||||
|
dummy = subprocess.Popen(
|
||||||
|
[_py(), "-c", "import time; time.sleep(5)"],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# 실제 create_time 보다 1 년 전 시각 → 명백한 mismatch.
|
||||||
|
far_past = time.time() - 365 * 24 * 3600
|
||||||
|
assert _is_same_process(dummy.pid, far_past) is False
|
||||||
|
# 맞는 create_time 으로는 True 여야 함 (sanity).
|
||||||
|
real_ct = psutil.Process(dummy.pid).create_time()
|
||||||
|
assert _is_same_process(dummy.pid, real_ct) is True
|
||||||
|
finally:
|
||||||
|
dummy.kill()
|
||||||
|
dummy.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# C1: 정상 종료 — tree 잔류 0
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestC1_NormalExit:
|
||||||
|
def test_dummy_short_run_no_residue(self):
|
||||||
|
r = _run_with_tree_kill(
|
||||||
|
[_py(), "-c", "import time; time.sleep(0.3)"],
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
assert r.returncode == 0
|
||||||
|
# 호출 후 _SPAWNED 에 우리 호출 잔재가 남으면 안 됨 (wrapper 가 discard).
|
||||||
|
# 다른 테스트 영향 가능성 있어서 set 전체가 0 이 아니어도 됨, 단 우리 잔재 없으면 OK.
|
||||||
|
# 보수적으로 — 우리 호출 직전에 _SPAWNED 가 비어있었으면 직후에도 비어있어야 함.
|
||||||
|
assert len(_SPAWNED) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# C2: Timeout — TimeoutExpired raise + 자손 정리
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestC2_Timeout:
|
||||||
|
def test_dummy_long_sleep_times_out(self):
|
||||||
|
with pytest.raises(subprocess.TimeoutExpired):
|
||||||
|
_run_with_tree_kill(
|
||||||
|
[_py(), "-c", "import time; time.sleep(60)"],
|
||||||
|
timeout=1.5,
|
||||||
|
)
|
||||||
|
# raise 후에도 _SPAWNED 우리 잔재 없어야 함 (wrapper finally 가 discard).
|
||||||
|
assert len(_SPAWNED) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# C3: grandchild orphan 정리 — parent timeout path
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestC3_GrandchildTimeoutPath:
|
||||||
|
def test_grandchild_killed_on_parent_timeout(self):
|
||||||
|
# parent 가 grandchild 띄우고 자기는 sleep — timeout 으로 강제 종료.
|
||||||
|
# grandchild 도 정리돼야 함.
|
||||||
|
# PID 캡처를 위해 grandchild 가 자기 PID 를 파일에 기록.
|
||||||
|
marker = ROOT / ".orchestrator" / "tmp" / "test_c3_gc_pid.txt"
|
||||||
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if marker.exists(): marker.unlink()
|
||||||
|
|
||||||
|
# grandchild 의 stdin/stdout/stderr 를 DEVNULL 로 분리 — production 의 claude.exe→python.exe -
|
||||||
|
# 케이스와 동일 (grandchild 가 wrapper 의 pipe 핸들 안 상속). 안 그러면 pipe inheritance 로
|
||||||
|
# communicate() 가 hang.
|
||||||
|
spawn_code = (
|
||||||
|
f"import subprocess, time, sys, os; "
|
||||||
|
f"gc = subprocess.Popen("
|
||||||
|
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
|
||||||
|
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
|
||||||
|
f"open(r'{marker}', 'w').write(str(gc.pid)); "
|
||||||
|
f"time.sleep(60)"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(subprocess.TimeoutExpired):
|
||||||
|
_run_with_tree_kill(
|
||||||
|
[_py(), "-c", spawn_code],
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# marker 파일에서 grandchild PID 읽기.
|
||||||
|
assert marker.exists(), "grandchild marker not written — parent died too early"
|
||||||
|
gc_pid = int(marker.read_text().strip())
|
||||||
|
|
||||||
|
# 잠시 대기 (cleanup 비동기 가능성) 후 grandchild 죽었는지 확인.
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline and _alive(gc_pid):
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert not _alive(gc_pid), f"grandchild PID {gc_pid} still alive after parent timeout"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# C4: 외부 프로세스 보호
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestC4_ExternalProcessProtection:
|
||||||
|
def test_outsider_not_killed(self):
|
||||||
|
# 사용자가 직접 띄운 척하는 외부 프로세스 (orchestrator 가 spawn 안 함).
|
||||||
|
outsider = subprocess.Popen([_py(), "-c", "import time; time.sleep(10)"])
|
||||||
|
try:
|
||||||
|
# _kill_tracked 에 외부 PID 의 (잘못된) signature 넘기면 무시돼야 함.
|
||||||
|
# signature 일치 안 하면 _is_same_process False → kill 안 됨.
|
||||||
|
wrong_sig = [(outsider.pid, 0.0)] # create_time 안 맞음
|
||||||
|
cleaned = _kill_tracked(wrong_sig)
|
||||||
|
assert cleaned == 0
|
||||||
|
assert _alive(outsider.pid), "outsider killed despite wrong create_time"
|
||||||
|
finally:
|
||||||
|
outsider.kill()
|
||||||
|
outsider.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# C5: orchestrator 자살 방지
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestC5_SelfKillProtection:
|
||||||
|
def test_kill_process_tree_self_pid_noop(self):
|
||||||
|
"""orchestrator(=pytest) PID 로 _kill_process_tree 호출해도 죽으면 안 됨."""
|
||||||
|
result = _kill_process_tree(os.getpid())
|
||||||
|
assert result == 0 # ORCH_PID 검사로 즉시 0 반환
|
||||||
|
|
||||||
|
def test_kill_tracked_with_orch_pid_noop(self):
|
||||||
|
# 일부러 self signature 를 tracked 에 넣어도 _is_same_process False → skip.
|
||||||
|
self_p = psutil.Process(os.getpid())
|
||||||
|
self_sig = _proc_signature(self_p)
|
||||||
|
cleaned = _kill_tracked([self_sig])
|
||||||
|
assert cleaned == 0 # 자기 자신 보호
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# C6 (CORE): parent 정상 종료 후 grandchild orphan 정리
|
||||||
|
# — PID 2780 regression test
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestC6_OrphanGrandchildAfterNormalExit:
|
||||||
|
"""PID 2780 path: parent 가 정상 exit 했는데 grandchild 만 살아남는 케이스.
|
||||||
|
monitor thread 가 parent 살아있을 때 grandchild 를 미리 추적해서 finally 에서 정리해야 함."""
|
||||||
|
|
||||||
|
def test_grandchild_killed_after_parent_normal_exit(self):
|
||||||
|
marker = ROOT / ".orchestrator" / "tmp" / "test_c6_gc_pid.txt"
|
||||||
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if marker.exists(): marker.unlink()
|
||||||
|
|
||||||
|
# parent 가:
|
||||||
|
# 1. grandchild 띄움 (DEVNULL 격리 — production claude.exe→python.exe - 과 동등).
|
||||||
|
# 2. PID 마커에 기록.
|
||||||
|
# 3. monitor 가 1초 polling 으로 catch 할 시간 확보 (2.5초 sleep).
|
||||||
|
# 4. 정상 종료.
|
||||||
|
spawn_code = (
|
||||||
|
f"import subprocess, time, sys, os; "
|
||||||
|
f"gc = subprocess.Popen("
|
||||||
|
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
|
||||||
|
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
|
||||||
|
f"open(r'{marker}', 'w').write(str(gc.pid)); "
|
||||||
|
f"time.sleep(2.5); "
|
||||||
|
f"sys.exit(0)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 정상 종료 (timeout 안 걸림) — wrapper 의 finally cleanup 만으로 grandchild 잡혀야 함.
|
||||||
|
r = _run_with_tree_kill(
|
||||||
|
[_py(), "-c", spawn_code],
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
assert r.returncode == 0, "parent did not exit normally"
|
||||||
|
|
||||||
|
# marker 에서 grandchild PID.
|
||||||
|
assert marker.exists(), "grandchild marker missing"
|
||||||
|
gc_pid = int(marker.read_text().strip())
|
||||||
|
|
||||||
|
# 정리 비동기 가능성 → 짧게 대기 후 확인.
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline and _alive(gc_pid):
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert not _alive(gc_pid), (
|
||||||
|
f"REGRESSION: grandchild PID {gc_pid} survived parent normal exit "
|
||||||
|
f"(PID 2780 path not fixed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Bonus: _SPAWNED discipline — 다중 호출 후 누적 안 됨
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSpawnedDiscipline:
|
||||||
|
def test_spawned_drained_between_calls(self):
|
||||||
|
for _ in range(3):
|
||||||
|
_run_with_tree_kill([_py(), "-c", "pass"], timeout=10)
|
||||||
|
# 3 회 호출 후에도 우리 잔재 없음 (wrapper finally 가 discard).
|
||||||
|
assert len(_SPAWNED) == 0
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: grid-2x2
|
||||||
|
zones_data:
|
||||||
|
- position: top-left
|
||||||
|
template_id: MOCK_top-left
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: top-right
|
||||||
|
template_id: MOCK_top-right
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom-left
|
||||||
|
template_id: MOCK_bottom-left
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom-right
|
||||||
|
template_id: MOCK_bottom-right
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries: null
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||||
|
cols: 583px 583px
|
||||||
|
rows: 286px 285px
|
||||||
|
heights_px:
|
||||||
|
- 286
|
||||||
|
- 285
|
||||||
|
widths_px:
|
||||||
|
- 583
|
||||||
|
- 583
|
||||||
|
ratios:
|
||||||
|
- 0.489
|
||||||
|
- 0.487
|
||||||
|
width_ratios:
|
||||||
|
- 0.494
|
||||||
|
- 0.494
|
||||||
|
computation: 2d_dynamic_aggregated
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: grid-2x2
|
||||||
|
zones_data:
|
||||||
|
- position: top-left
|
||||||
|
template_id: MOCK_top-left
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: top-right
|
||||||
|
template_id: MOCK_top-right
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom-left
|
||||||
|
template_id: MOCK_bottom-left
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom-right
|
||||||
|
template_id: MOCK_bottom-right
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries:
|
||||||
|
top-left:
|
||||||
|
x: 0
|
||||||
|
y: 0
|
||||||
|
w: 0.55
|
||||||
|
h: 0.4
|
||||||
|
top-right:
|
||||||
|
x: 0.55
|
||||||
|
y: 0
|
||||||
|
w: 0.45
|
||||||
|
h: 0.4
|
||||||
|
bottom-left:
|
||||||
|
x: 0
|
||||||
|
y: 0.4
|
||||||
|
w: 0.55
|
||||||
|
h: 0.6
|
||||||
|
bottom-right:
|
||||||
|
x: 0.55
|
||||||
|
y: 0.4
|
||||||
|
w: 0.45
|
||||||
|
h: 0.6
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||||
|
cols: 641px 525px
|
||||||
|
rows: 228px 343px
|
||||||
|
heights_px:
|
||||||
|
- 228
|
||||||
|
- 343
|
||||||
|
widths_px:
|
||||||
|
- 641
|
||||||
|
- 525
|
||||||
|
ratios:
|
||||||
|
- 0.4
|
||||||
|
- 0.6
|
||||||
|
width_ratios:
|
||||||
|
- 0.55
|
||||||
|
- 0.45
|
||||||
|
computation: user_override_geometry
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: left-1-right-2
|
||||||
|
zones_data:
|
||||||
|
- position: left
|
||||||
|
template_id: MOCK_left
|
||||||
|
content_weight:
|
||||||
|
score: 0.5
|
||||||
|
min_height_px: 200
|
||||||
|
- position: right-top
|
||||||
|
template_id: MOCK_right-top
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: right-bottom
|
||||||
|
template_id: MOCK_right-bottom
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries: null
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"left right-top" "left right-bottom"'
|
||||||
|
cols: 777px 389px
|
||||||
|
rows: 286px 285px
|
||||||
|
heights_px:
|
||||||
|
- 286
|
||||||
|
- 285
|
||||||
|
widths_px:
|
||||||
|
- 777
|
||||||
|
- 389
|
||||||
|
ratios:
|
||||||
|
- 0.489
|
||||||
|
- 0.487
|
||||||
|
width_ratios:
|
||||||
|
- 0.658
|
||||||
|
- 0.33
|
||||||
|
computation: 2d_dynamic_aggregated
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: left-1-right-2
|
||||||
|
zones_data:
|
||||||
|
- position: left
|
||||||
|
template_id: MOCK_left
|
||||||
|
content_weight:
|
||||||
|
score: 0.5
|
||||||
|
min_height_px: 200
|
||||||
|
- position: right-top
|
||||||
|
template_id: MOCK_right-top
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: right-bottom
|
||||||
|
template_id: MOCK_right-bottom
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries:
|
||||||
|
left:
|
||||||
|
x: 0
|
||||||
|
y: 0
|
||||||
|
w: 0.4
|
||||||
|
h: 1.0
|
||||||
|
right-top:
|
||||||
|
x: 0.4
|
||||||
|
y: 0
|
||||||
|
w: 0.6
|
||||||
|
h: 0.5
|
||||||
|
right-bottom:
|
||||||
|
x: 0.4
|
||||||
|
y: 0.5
|
||||||
|
w: 0.6
|
||||||
|
h: 0.5
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"left right-top" "left right-bottom"'
|
||||||
|
cols: 466px 700px
|
||||||
|
rows: 286px 285px
|
||||||
|
heights_px:
|
||||||
|
- 286
|
||||||
|
- 285
|
||||||
|
widths_px:
|
||||||
|
- 466
|
||||||
|
- 700
|
||||||
|
ratios:
|
||||||
|
- 0.5
|
||||||
|
- 0.5
|
||||||
|
width_ratios:
|
||||||
|
- 0.4
|
||||||
|
- 0.6
|
||||||
|
computation: user_override_geometry
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: left-2-right-1
|
||||||
|
zones_data:
|
||||||
|
- position: left-top
|
||||||
|
template_id: MOCK_left-top
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: left-bottom
|
||||||
|
template_id: MOCK_left-bottom
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: right
|
||||||
|
template_id: MOCK_right
|
||||||
|
content_weight:
|
||||||
|
score: 0.5
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries: null
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"left-top right" "left-bottom right"'
|
||||||
|
cols: 389px 777px
|
||||||
|
rows: 286px 285px
|
||||||
|
heights_px:
|
||||||
|
- 286
|
||||||
|
- 285
|
||||||
|
widths_px:
|
||||||
|
- 389
|
||||||
|
- 777
|
||||||
|
ratios:
|
||||||
|
- 0.489
|
||||||
|
- 0.487
|
||||||
|
width_ratios:
|
||||||
|
- 0.33
|
||||||
|
- 0.658
|
||||||
|
computation: 2d_dynamic_aggregated
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: left-2-right-1
|
||||||
|
zones_data:
|
||||||
|
- position: left-top
|
||||||
|
template_id: MOCK_left-top
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: left-bottom
|
||||||
|
template_id: MOCK_left-bottom
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: right
|
||||||
|
template_id: MOCK_right
|
||||||
|
content_weight:
|
||||||
|
score: 0.5
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries:
|
||||||
|
left-top:
|
||||||
|
x: 0
|
||||||
|
y: 0
|
||||||
|
w: 0.35
|
||||||
|
h: 0.6
|
||||||
|
left-bottom:
|
||||||
|
x: 0
|
||||||
|
y: 0.6
|
||||||
|
w: 0.35
|
||||||
|
h: 0.4
|
||||||
|
right:
|
||||||
|
x: 0.35
|
||||||
|
y: 0
|
||||||
|
w: 0.65
|
||||||
|
h: 1.0
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"left-top right" "left-bottom right"'
|
||||||
|
cols: 408px 758px
|
||||||
|
rows: 343px 228px
|
||||||
|
heights_px:
|
||||||
|
- 343
|
||||||
|
- 228
|
||||||
|
widths_px:
|
||||||
|
- 408
|
||||||
|
- 758
|
||||||
|
ratios:
|
||||||
|
- 0.6
|
||||||
|
- 0.4
|
||||||
|
width_ratios:
|
||||||
|
- 0.35
|
||||||
|
- 0.65
|
||||||
|
computation: user_override_geometry
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: top-1-bottom-2
|
||||||
|
zones_data:
|
||||||
|
- position: top
|
||||||
|
template_id: MOCK_top
|
||||||
|
content_weight:
|
||||||
|
score: 0.5
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom-left
|
||||||
|
template_id: MOCK_bottom-left
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom-right
|
||||||
|
template_id: MOCK_bottom-right
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries: null
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"top top" "bottom-left bottom-right"'
|
||||||
|
cols: 583px 583px
|
||||||
|
rows: 314px 257px
|
||||||
|
heights_px:
|
||||||
|
- 314
|
||||||
|
- 257
|
||||||
|
widths_px:
|
||||||
|
- 583
|
||||||
|
- 583
|
||||||
|
ratios:
|
||||||
|
- 0.537
|
||||||
|
- 0.439
|
||||||
|
width_ratios:
|
||||||
|
- 0.494
|
||||||
|
- 0.494
|
||||||
|
computation: 2d_dynamic_aggregated
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: top-1-bottom-2
|
||||||
|
zones_data:
|
||||||
|
- position: top
|
||||||
|
template_id: MOCK_top
|
||||||
|
content_weight:
|
||||||
|
score: 0.5
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom-left
|
||||||
|
template_id: MOCK_bottom-left
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom-right
|
||||||
|
template_id: MOCK_bottom-right
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries:
|
||||||
|
top:
|
||||||
|
x: 0
|
||||||
|
y: 0
|
||||||
|
w: 1.0
|
||||||
|
h: 0.3
|
||||||
|
bottom-left:
|
||||||
|
x: 0
|
||||||
|
y: 0.3
|
||||||
|
w: 0.5
|
||||||
|
h: 0.7
|
||||||
|
bottom-right:
|
||||||
|
x: 0.5
|
||||||
|
y: 0.3
|
||||||
|
w: 0.5
|
||||||
|
h: 0.7
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"top top" "bottom-left bottom-right"'
|
||||||
|
cols: 583px 583px
|
||||||
|
rows: 171px 400px
|
||||||
|
heights_px:
|
||||||
|
- 171
|
||||||
|
- 400
|
||||||
|
widths_px:
|
||||||
|
- 583
|
||||||
|
- 583
|
||||||
|
ratios:
|
||||||
|
- 0.3
|
||||||
|
- 0.7
|
||||||
|
width_ratios:
|
||||||
|
- 0.5
|
||||||
|
- 0.5
|
||||||
|
computation: user_override_geometry
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: top-2-bottom-1
|
||||||
|
zones_data:
|
||||||
|
- position: top-left
|
||||||
|
template_id: MOCK_top-left
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: top-right
|
||||||
|
template_id: MOCK_top-right
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom
|
||||||
|
template_id: MOCK_bottom
|
||||||
|
content_weight:
|
||||||
|
score: 0.5
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries: null
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"top-left top-right" "bottom bottom"'
|
||||||
|
cols: 583px 583px
|
||||||
|
rows: 257px 314px
|
||||||
|
heights_px:
|
||||||
|
- 257
|
||||||
|
- 314
|
||||||
|
widths_px:
|
||||||
|
- 583
|
||||||
|
- 583
|
||||||
|
ratios:
|
||||||
|
- 0.439
|
||||||
|
- 0.537
|
||||||
|
width_ratios:
|
||||||
|
- 0.494
|
||||||
|
- 0.494
|
||||||
|
computation: 2d_dynamic_aggregated
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
input:
|
||||||
|
layout_preset: top-2-bottom-1
|
||||||
|
zones_data:
|
||||||
|
- position: top-left
|
||||||
|
template_id: MOCK_top-left
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: top-right
|
||||||
|
template_id: MOCK_top-right
|
||||||
|
content_weight:
|
||||||
|
score: 0.25
|
||||||
|
min_height_px: 200
|
||||||
|
- position: bottom
|
||||||
|
template_id: MOCK_bottom
|
||||||
|
content_weight:
|
||||||
|
score: 0.5
|
||||||
|
min_height_px: 200
|
||||||
|
override_zone_geometries:
|
||||||
|
top-left:
|
||||||
|
x: 0
|
||||||
|
y: 0
|
||||||
|
w: 0.6
|
||||||
|
h: 0.4
|
||||||
|
top-right:
|
||||||
|
x: 0.6
|
||||||
|
y: 0
|
||||||
|
w: 0.4
|
||||||
|
h: 0.4
|
||||||
|
bottom:
|
||||||
|
x: 0
|
||||||
|
y: 0.4
|
||||||
|
w: 1.0
|
||||||
|
h: 0.6
|
||||||
|
expected_layout_css:
|
||||||
|
areas: '"top-left top-right" "bottom bottom"'
|
||||||
|
cols: 700px 466px
|
||||||
|
rows: 228px 343px
|
||||||
|
heights_px:
|
||||||
|
- 228
|
||||||
|
- 343
|
||||||
|
widths_px:
|
||||||
|
- 700
|
||||||
|
- 466
|
||||||
|
ratios:
|
||||||
|
- 0.4
|
||||||
|
- 0.6
|
||||||
|
width_ratios:
|
||||||
|
- 0.6
|
||||||
|
- 0.4
|
||||||
|
computation: user_override_geometry
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
case_id: grid-2x2_dynamic_2d
|
||||||
|
description: |
|
||||||
|
grid-2x2 (2x2 topology) is promoted to 2-D dynamic in IMP-09 PR 2.
|
||||||
|
Row-axis retry MUST be skipped by the gate with the
|
||||||
|
"dynamic_cols (2-D topology)" reason.
|
||||||
|
input_layout_css:
|
||||||
|
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||||
|
cols: 583px 583px
|
||||||
|
rows: 286px 285px
|
||||||
|
heights_px: [286, 285]
|
||||||
|
widths_px: [583, 583]
|
||||||
|
ratios: [0.489, 0.487]
|
||||||
|
width_ratios: [0.494, 0.494]
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
|
router_decision:
|
||||||
|
router_active: true
|
||||||
|
proposed_actions_summary: [zone_ratio_retry]
|
||||||
|
expected_gate:
|
||||||
|
retry_attempted: false
|
||||||
|
retry_skipped_reason_contains:
|
||||||
|
- "dynamic_cols"
|
||||||
|
- "2-D"
|
||||||
|
- "IMP-09"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
case_id: left-1-right-2_dynamic_2d
|
||||||
|
description: |
|
||||||
|
left-1-right-2 (side-T-left topology) is promoted to 2-D dynamic in
|
||||||
|
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||||
|
"dynamic_cols (2-D topology)" reason.
|
||||||
|
input_layout_css:
|
||||||
|
areas: '"left right-top" "left right-bottom"'
|
||||||
|
cols: 777px 389px
|
||||||
|
rows: 286px 285px
|
||||||
|
heights_px: [286, 285]
|
||||||
|
widths_px: [777, 389]
|
||||||
|
ratios: [0.489, 0.487]
|
||||||
|
width_ratios: [0.658, 0.33]
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
|
router_decision:
|
||||||
|
router_active: true
|
||||||
|
proposed_actions_summary: [zone_ratio_retry]
|
||||||
|
expected_gate:
|
||||||
|
retry_attempted: false
|
||||||
|
retry_skipped_reason_contains:
|
||||||
|
- "dynamic_cols"
|
||||||
|
- "2-D"
|
||||||
|
- "IMP-09"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
case_id: left-2-right-1_dynamic_2d
|
||||||
|
description: |
|
||||||
|
left-2-right-1 (side-T-right topology) is promoted to 2-D dynamic in
|
||||||
|
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||||
|
"dynamic_cols (2-D topology)" reason.
|
||||||
|
input_layout_css:
|
||||||
|
areas: '"left-top right" "left-bottom right"'
|
||||||
|
cols: 389px 777px
|
||||||
|
rows: 286px 285px
|
||||||
|
heights_px: [286, 285]
|
||||||
|
widths_px: [389, 777]
|
||||||
|
ratios: [0.489, 0.487]
|
||||||
|
width_ratios: [0.33, 0.658]
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
|
router_decision:
|
||||||
|
router_active: true
|
||||||
|
proposed_actions_summary: [zone_ratio_retry]
|
||||||
|
expected_gate:
|
||||||
|
retry_attempted: false
|
||||||
|
retry_skipped_reason_contains:
|
||||||
|
- "dynamic_cols"
|
||||||
|
- "2-D"
|
||||||
|
- "IMP-09"
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
case_id: single_fr_default
|
case_id: single_fr_default
|
||||||
description: |
|
description: |
|
||||||
Post-IMP-09 PR 2 the only preset that still falls through to
|
Any layout that fell through to fr_default_from_preset (single,
|
||||||
fr_default_from_preset is `single` (top-1-bottom-2 / top-2-bottom-1 /
|
T-shape, 2x2 in PR 1) has neither dynamic_rows nor dynamic_cols.
|
||||||
left-1-right-2 / left-2-right-1 / grid-2x2 were promoted to 2-D
|
Row-axis retry is a no-op and must be skipped by the IMP-09 gate
|
||||||
dynamic). This fixture exercises the IMP-09 retry-gate fr_default
|
with a fr_default_from_preset skip reason.
|
||||||
skip path using a layout_css with dynamic_rows=False AND
|
|
||||||
dynamic_cols=False (the surviving fr_default signature). Row-axis
|
|
||||||
retry must be skipped with a fr_default_from_preset skip reason.
|
|
||||||
input_layout_css:
|
input_layout_css:
|
||||||
areas: '"top top" "bottom-left bottom-right"'
|
areas: '"top top" "bottom-left bottom-right"'
|
||||||
cols: 1fr 1fr
|
cols: 1fr 1fr
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
case_id: top-1-bottom-2_dynamic_2d
|
||||||
|
description: |
|
||||||
|
top-1-bottom-2 (T topology) is promoted to 2-D dynamic in IMP-09
|
||||||
|
PR 2 (dynamic_rows=True, dynamic_cols=True). Row-axis retry MUST be
|
||||||
|
skipped by the IMP-09 gate with the "dynamic_cols (2-D topology)"
|
||||||
|
skip reason, because row-only redistribution cannot reconcile both
|
||||||
|
axes simultaneously.
|
||||||
|
input_layout_css:
|
||||||
|
areas: '"top top" "bottom-left bottom-right"'
|
||||||
|
cols: 583px 583px
|
||||||
|
rows: 314px 257px
|
||||||
|
heights_px: [314, 257]
|
||||||
|
widths_px: [583, 583]
|
||||||
|
ratios: [0.537, 0.439]
|
||||||
|
width_ratios: [0.494, 0.494]
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
|
router_decision:
|
||||||
|
router_active: true
|
||||||
|
proposed_actions_summary: [zone_ratio_retry]
|
||||||
|
expected_gate:
|
||||||
|
retry_attempted: false
|
||||||
|
retry_skipped_reason_contains:
|
||||||
|
- "dynamic_cols"
|
||||||
|
- "2-D"
|
||||||
|
- "IMP-09"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
case_id: top-2-bottom-1_dynamic_2d
|
||||||
|
description: |
|
||||||
|
top-2-bottom-1 (inverted-T topology) is promoted to 2-D dynamic in
|
||||||
|
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||||
|
"dynamic_cols (2-D topology)" reason.
|
||||||
|
input_layout_css:
|
||||||
|
areas: '"top-left top-right" "bottom bottom"'
|
||||||
|
cols: 583px 583px
|
||||||
|
rows: 257px 314px
|
||||||
|
heights_px: [257, 314]
|
||||||
|
widths_px: [583, 583]
|
||||||
|
ratios: [0.439, 0.537]
|
||||||
|
width_ratios: [0.494, 0.494]
|
||||||
|
dynamic_rows: true
|
||||||
|
dynamic_cols: true
|
||||||
|
router_decision:
|
||||||
|
router_active: true
|
||||||
|
proposed_actions_summary: [zone_ratio_retry]
|
||||||
|
expected_gate:
|
||||||
|
retry_attempted: false
|
||||||
|
retry_skipped_reason_contains:
|
||||||
|
- "dynamic_cols"
|
||||||
|
- "2-D"
|
||||||
|
- "IMP-09"
|
||||||
@@ -138,109 +138,21 @@ def test_vertical_2_override_keeps_fr_cols_legacy():
|
|||||||
assert result["width_ratios"] == [0.4, 0.6]
|
assert result["width_ratios"] == [0.4, 0.6]
|
||||||
|
|
||||||
|
|
||||||
# ───────────────── PR 2: 5 in-scope 2-D presets dynamic ─────────────────
|
# ────────────────────── 2-D dynamic dispatch (PR 2) ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
_TWO_D_PRESETS = [
|
def test_top_1_bottom_2_dynamic_2d_populates_geometry():
|
||||||
"top-1-bottom-2",
|
"""T-shape (top-1-bottom-2) is dispatched through the 2-D dynamic
|
||||||
"top-2-bottom-1",
|
builder in PR 2: heights_px / widths_px length-locked to grid
|
||||||
"left-1-right-2",
|
R=2, C=2 with both dynamic flags True."""
|
||||||
"left-2-right-1",
|
zones = [
|
||||||
"grid-2x2",
|
_zone("top", 0.5),
|
||||||
]
|
_zone("bottom-left", 0.25),
|
||||||
|
_zone("bottom-right", 0.25),
|
||||||
|
]
|
||||||
def _zones_for(preset: str) -> list[dict]:
|
result = build_layout_css("top-1-bottom-2", zones)
|
||||||
"""Default zone fixtures (positions per LAYOUT_PRESETS, equal score)."""
|
|
||||||
if preset == "top-1-bottom-2":
|
|
||||||
return [_zone("top", 0.5), _zone("bottom-left", 0.25),
|
|
||||||
_zone("bottom-right", 0.25)]
|
|
||||||
if preset == "top-2-bottom-1":
|
|
||||||
return [_zone("top-left", 0.3), _zone("top-right", 0.2),
|
|
||||||
_zone("bottom", 0.5)]
|
|
||||||
if preset == "left-1-right-2":
|
|
||||||
return [_zone("left", 0.5), _zone("right-top", 0.3),
|
|
||||||
_zone("right-bottom", 0.2)]
|
|
||||||
if preset == "left-2-right-1":
|
|
||||||
return [_zone("left-top", 0.3), _zone("left-bottom", 0.2),
|
|
||||||
_zone("right", 0.5)]
|
|
||||||
if preset == "grid-2x2":
|
|
||||||
return [_zone("top-left", 0.25), _zone("top-right", 0.25),
|
|
||||||
_zone("bottom-left", 0.25), _zone("bottom-right", 0.25)]
|
|
||||||
raise ValueError(f"no _zones_for({preset!r})")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("preset", _TWO_D_PRESETS)
|
|
||||||
def test_two_d_preset_promoted_to_dynamic(preset):
|
|
||||||
"""PR 2 — T / inverted-T / side-T-{left,right} / 2x2 must dispatch
|
|
||||||
to _build_2d_dynamic (computation=='2d_dynamic_aggregated') with
|
|
||||||
dynamic_rows=True AND dynamic_cols=True, and grid-template strings
|
|
||||||
in pixels."""
|
|
||||||
result = build_layout_css(preset, _zones_for(preset))
|
|
||||||
assert result["computation"] == "2d_dynamic_aggregated"
|
assert result["computation"] == "2d_dynamic_aggregated"
|
||||||
assert result["dynamic_rows"] is True
|
assert result["dynamic_rows"] is True
|
||||||
assert result["dynamic_cols"] is True
|
assert result["dynamic_cols"] is True
|
||||||
# Both axes pixel-based.
|
assert len(result["heights_px"]) == 2 # R rows
|
||||||
assert "fr" not in result["rows"]
|
assert len(result["widths_px"]) == 2 # C cols
|
||||||
assert "fr" not in result["cols"]
|
|
||||||
assert result["rows"].count("px") == 2
|
|
||||||
assert result["cols"].count("px") == 2
|
|
||||||
# Length contract — R=2 rows, C=2 cols for all 5 in-scope presets.
|
|
||||||
assert len(result["heights_px"]) == 2
|
|
||||||
assert len(result["widths_px"]) == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("preset", _TWO_D_PRESETS)
|
|
||||||
def test_two_d_preset_override_dispatches_via_helper(preset):
|
|
||||||
"""Override on a 2-D preset must route through
|
|
||||||
_override_to_grid_tracks (computation=='user_override_geometry')
|
|
||||||
with both axes marked dynamic."""
|
|
||||||
zones = _zones_for(preset)
|
|
||||||
positions = [z["position"] for z in zones]
|
|
||||||
override = {pos: {"x": 0, "y": 0, "w": 0.5, "h": 0.5} for pos in positions}
|
|
||||||
result = build_layout_css(preset, zones, override_zone_geometries=override)
|
|
||||||
assert result["computation"] == "user_override_geometry"
|
|
||||||
assert result["dynamic_rows"] is True
|
|
||||||
assert result["dynamic_cols"] is True
|
|
||||||
assert len(result["heights_px"]) == 2
|
|
||||||
assert len(result["widths_px"]) == 2
|
|
||||||
# Total of axis cell sums equals body minus inter-track gap.
|
|
||||||
assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP
|
|
||||||
assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("preset", _TWO_D_PRESETS)
|
|
||||||
def test_two_d_preset_override_invalid_falls_back_to_dynamic(preset):
|
|
||||||
"""Zero-only override on a 2-D preset must fall back to dynamic
|
|
||||||
normal-path solvers on both axes; computation still labeled
|
|
||||||
user_override_geometry (override invocation context preserved)
|
|
||||||
and raw_zone_layout.{row_source,col_source}=='dynamic_fallback'."""
|
|
||||||
zones = _zones_for(preset)
|
|
||||||
positions = [z["position"] for z in zones]
|
|
||||||
# Zero-only override — must trigger per-axis fallback to solvers.
|
|
||||||
override = {pos: {"x": 0, "y": 0, "w": 0.0, "h": 0.0} for pos in positions}
|
|
||||||
result = build_layout_css(preset, zones, override_zone_geometries=override)
|
|
||||||
assert result["computation"] == "user_override_geometry"
|
|
||||||
rzl = result["raw_zone_layout"]
|
|
||||||
assert rzl["row_source"] == "dynamic_fallback"
|
|
||||||
assert rzl["col_source"] == "dynamic_fallback"
|
|
||||||
# Result must match what dynamic normal path would produce.
|
|
||||||
normal = build_layout_css(preset, zones)
|
|
||||||
assert result["heights_px"] == normal["heights_px"]
|
|
||||||
assert result["widths_px"] == normal["widths_px"]
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────── fr_default sink (PR 2 — single only) ──────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_single_remains_fr_default_sink():
|
|
||||||
"""After PR 2 the only preset that still falls through to
|
|
||||||
_build_fr_default is `single` (PR 3 will promote it). Length
|
|
||||||
contract (R=1, C=1) must still hold."""
|
|
||||||
zones = [_zone("primary", 1.0)]
|
|
||||||
result = build_layout_css("single", zones)
|
|
||||||
assert result["computation"] == "fr_default_from_preset"
|
|
||||||
assert result["dynamic_rows"] is False
|
|
||||||
assert result["dynamic_cols"] is False
|
|
||||||
assert len(result["heights_px"]) == 1
|
|
||||||
assert len(result["widths_px"]) == 1
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""IMP-12 u11 — plan_cross_zone_redistribute tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u11):
|
||||||
|
- multi-role zone feasible (deficit role + surplus role in the same zone)
|
||||||
|
- single-role zone infeasible reason (no peer to donate surplus)
|
||||||
|
|
||||||
|
u4 wraps fit_verifier.redistribute() in the Step-17 plan signature; feasibility
|
||||||
|
depends on whether deficit roles can be covered by surplus roles within the
|
||||||
|
same container.zone group (see src/fit_verifier.py:496-590). The plan exposes
|
||||||
|
role_heights_before / role_heights_after and surfaces the
|
||||||
|
'can_redistribute=False — single-role zone(s)' substring when redistribution
|
||||||
|
is impossible. The apply helper must scope output to [data-role=...] only
|
||||||
|
(feedback_phase_z_spacing_direction — no :root / body / .slide / .zone).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.fit_verifier import FitAnalysis, RoleFit
|
||||||
|
from src.phase_z2_retry import (
|
||||||
|
apply_cross_zone_redistribute_css,
|
||||||
|
plan_cross_zone_redistribute,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fit(roles: dict[str, tuple[float, float]]) -> FitAnalysis:
|
||||||
|
"""roles dict = {role: (allocated_px, shortfall_px)}.
|
||||||
|
|
||||||
|
Sign convention matches fit_verifier.redistribute: shortfall_px > 0 = deficit,
|
||||||
|
shortfall_px < 0 = surplus (usable = abs(shortfall) - min_margin_px).
|
||||||
|
"""
|
||||||
|
return FitAnalysis(
|
||||||
|
roles={
|
||||||
|
name: RoleFit(role=name, allocated_px=alloc, shortfall_px=short)
|
||||||
|
for name, (alloc, short) in roles.items()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_role_zone_feasible():
|
||||||
|
"""Two roles in the same zone — deficit covered by surplus → feasible."""
|
||||||
|
fit = _fit({"top": (200.0, 30.0), "bottom_l": (300.0, -50.0)})
|
||||||
|
containers = {
|
||||||
|
"top": {"zone": "slide_body", "height_px": 200},
|
||||||
|
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||||
|
}
|
||||||
|
plan = plan_cross_zone_redistribute(
|
||||||
|
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||||
|
)
|
||||||
|
assert plan["action"] == "cross_zone_redistribute"
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["can_redistribute"] is True
|
||||||
|
assert plan["role_heights_before"] == {"top": 200.0, "bottom_l": 300.0}
|
||||||
|
after = plan["role_heights_after"]
|
||||||
|
# deficit (30) shifts top up, surplus (50-margin=40) shifts bottom_l down by 30.
|
||||||
|
assert after["top"] > 200.0
|
||||||
|
assert after["bottom_l"] < 300.0
|
||||||
|
assert abs((after["top"] - 200.0) - (300.0 - after["bottom_l"])) < 1.0
|
||||||
|
css = apply_cross_zone_redistribute_css(plan)
|
||||||
|
assert '[data-role="top"]' in css
|
||||||
|
assert '[data-role="bottom_l"]' in css
|
||||||
|
# Scope lock — no global rules emitted.
|
||||||
|
for forbidden in (":root", "body", ".slide", ".zone"):
|
||||||
|
assert forbidden not in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_role_zone_infeasible_reason():
|
||||||
|
"""Lone role in a zone has no peer to donate surplus → infeasible."""
|
||||||
|
fit = _fit({"top": (200.0, 30.0)})
|
||||||
|
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||||
|
plan = plan_cross_zone_redistribute(
|
||||||
|
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["can_redistribute"] is False
|
||||||
|
reason = plan["failure_reason"]
|
||||||
|
assert "single-role zone" in reason
|
||||||
|
assert "can_redistribute=False" in reason
|
||||||
|
# apply emits nothing when infeasible.
|
||||||
|
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_fit_analysis_infeasible():
|
||||||
|
"""No roles at all → defensive infeasible (cannot redistribute nothing)."""
|
||||||
|
plan = plan_cross_zone_redistribute(
|
||||||
|
fit_analysis=FitAnalysis(roles={}), containers={}, min_margin_px=10.0,
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["role_heights_before"] == {}
|
||||||
|
assert "no roles" in plan["failure_reason"]
|
||||||
|
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""IMP-12 u14 — failure_router cascade tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u14):
|
||||||
|
- donor_slack_insufficient → cross_zone_redistribute (impl=IMPLEMENTED)
|
||||||
|
- 3 new failure types (cross_zone_redistribute_insufficient,
|
||||||
|
glue_absorption_insufficient, font_step_insufficient) all route to
|
||||||
|
expected next actions per the locked NEXT_ACTION_BY_FAILURE table
|
||||||
|
- rerender_still_fails preserved → frame_reselect
|
||||||
|
|
||||||
|
u2 (classifier) inspects retry_trace["salvage_steps"][-1] for the 3 new
|
||||||
|
salvage failure types via SALVAGE_FAILURE_TYPE_BY_ACTION; u3 wires those
|
||||||
|
failure types onto the deterministic cascade in NEXT_ACTION_BY_FAILURE.
|
||||||
|
u7 records the cascade actions as IMPLEMENTED in NEXT_ACTION_IMPLEMENTATION_STATUS.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_failure_router import (
|
||||||
|
NEXT_ACTION_BY_FAILURE,
|
||||||
|
NEXT_ACTION_IMPLEMENTATION_STATUS,
|
||||||
|
classify_retry_failure,
|
||||||
|
enrich_retry_trace_with_failure_classification,
|
||||||
|
route_retry_failure,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_donor_slack_insufficient_routes_to_cross_zone_redistribute_implemented():
|
||||||
|
"""Stage 1 root cause — primary donor slack insufficient classifies as
|
||||||
|
donor_slack_insufficient and routes onto the deterministic salvage cascade
|
||||||
|
starting with cross_zone_redistribute (IMPLEMENTED per u7)."""
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"plan": {
|
||||||
|
"feasible": False,
|
||||||
|
"failure_reason": (
|
||||||
|
"primary donor 'bottom' slack 15px (aggregate 25px from 2 donor(s)) "
|
||||||
|
"< target_added_px 70px"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc is not None
|
||||||
|
assert fc["failure_type"] == "donor_slack_insufficient"
|
||||||
|
|
||||||
|
nr = route_retry_failure("donor_slack_insufficient")
|
||||||
|
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||||
|
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||||
|
|
||||||
|
# enrichment composes both fields onto the trace
|
||||||
|
enrich_retry_trace_with_failure_classification(trace)
|
||||||
|
assert trace["failure_classification"]["failure_type"] == "donor_slack_insufficient"
|
||||||
|
assert trace["next_action_proposal"]["next_proposed_action"] == "cross_zone_redistribute"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_donor_candidates_routes_to_cross_zone_redistribute_implemented():
|
||||||
|
"""no_donor_candidates is the second cascade entry — also onto
|
||||||
|
cross_zone_redistribute per the locked mapping."""
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"plan": {"feasible": False, "failure_reason": "no donor candidates"},
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc["failure_type"] == "no_donor_candidates"
|
||||||
|
nr = route_retry_failure("no_donor_candidates")
|
||||||
|
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||||
|
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
|
||||||
|
"""u2 classifier inspects salvage_steps[-1]. u3 routes the 3 new failure
|
||||||
|
types through the deterministic cascade: cross_zone → glue → font_step →
|
||||||
|
layout_adjust. Verifies the locked NEXT_ACTION_BY_FAILURE table directly
|
||||||
|
and via the classifier path."""
|
||||||
|
# Direct mapping (u3 lock)
|
||||||
|
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
|
||||||
|
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
|
||||||
|
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
|
||||||
|
|
||||||
|
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
|
||||||
|
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
|
||||||
|
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
|
||||||
|
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
|
||||||
|
|
||||||
|
# Classifier path via salvage_steps[-1].action → failure_type → next action
|
||||||
|
cases = [
|
||||||
|
("cross_zone_redistribute", "cross_zone_redistribute_insufficient", "glue_compression"),
|
||||||
|
("glue_compression", "glue_absorption_insufficient", "font_step_compression"),
|
||||||
|
("font_step_compression", "font_step_insufficient", "layout_adjust"),
|
||||||
|
]
|
||||||
|
for action, expected_ftype, expected_next in cases:
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"salvage_passed": False,
|
||||||
|
"salvage_steps": [
|
||||||
|
{"action": action, "passed": False, "failure_reason": "salvage failed"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc is not None, f"classifier returned None for action={action}"
|
||||||
|
assert fc["failure_type"] == expected_ftype
|
||||||
|
nr = route_retry_failure(fc["failure_type"])
|
||||||
|
assert nr["next_proposed_action"] == expected_next
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerender_still_fails_preserved_routes_to_frame_reselect():
|
||||||
|
"""Pre-cascade behavior preserved: when plan was feasible and rerender ran
|
||||||
|
but visual still failed, classifier emits rerender_still_fails → frame_reselect."""
|
||||||
|
trace = {
|
||||||
|
"retry_attempted": True,
|
||||||
|
"retry_passed": False,
|
||||||
|
"plan": {"feasible": True},
|
||||||
|
"rerender_attempted": True,
|
||||||
|
}
|
||||||
|
fc = classify_retry_failure(trace)
|
||||||
|
assert fc["failure_type"] == "rerender_still_fails"
|
||||||
|
nr = route_retry_failure("rerender_still_fails")
|
||||||
|
assert nr["next_proposed_action"] == "frame_reselect"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""IMP-12 u13 — plan_font_step_compression tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u13):
|
||||||
|
- feasible case (15.2 → 13 closes excess)
|
||||||
|
- infeasible (8px floor — FONT_SIZE_STEPS exhausted)
|
||||||
|
- text_metrics missing → defensive infeasible reason
|
||||||
|
|
||||||
|
u6 wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
|
||||||
|
Height savings per candidate font_size (Korean 1.6 line-height):
|
||||||
|
height_saved = (current_font_px * 1.6 - font_size * 1.6) * available_lines
|
||||||
|
|
||||||
|
Scope lock per feedback_phase_z_spacing_direction:
|
||||||
|
- apply_font_step_compression_css emits ONLY [data-zone-position="<pos>"] rule.
|
||||||
|
- No :root / body / .slide / .zone selectors permitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.phase_z2_retry import (
|
||||||
|
apply_font_step_compression_css,
|
||||||
|
plan_font_step_compression,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feasible_15_2_to_13_closes_excess() -> None:
|
||||||
|
"""current=15.2, excess=20, lines=10 → 14.0 saves 19.2 (insufficient);
|
||||||
|
13.0 saves 35.2 (>=20) → target_font_px=13.0. Emitted CSS scope-locked."""
|
||||||
|
plan = plan_font_step_compression(
|
||||||
|
current_font_px=15.2, excess_after_glue_px=20.0,
|
||||||
|
available_lines=10, chars_per_line=40, zone_position="bottom_l",
|
||||||
|
)
|
||||||
|
assert plan["action"] == "font_step_compression"
|
||||||
|
assert plan["zone_position"] == "bottom_l"
|
||||||
|
assert plan["current_font_px"] == 15.2
|
||||||
|
assert plan["excess_after_glue_px"] == 20.0
|
||||||
|
assert plan["available_lines"] == 10
|
||||||
|
assert plan["chars_per_line"] == 40
|
||||||
|
assert plan["font_floor_px"] == 8.0
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["target_font_px"] == 13.0
|
||||||
|
assert "failure_reason" not in plan
|
||||||
|
|
||||||
|
css = apply_font_step_compression_css(plan)
|
||||||
|
assert '[data-zone-position="bottom_l"]' in css
|
||||||
|
assert "font-size: 13.0px" in css
|
||||||
|
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||||
|
assert forbidden not in css, f"scope-lock violation: {forbidden!r} in css"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infeasible_font_floor_exhausted() -> None:
|
||||||
|
"""current=15.2, excess=200, lines=10 — even 8.0px floor saves only 115.2,
|
||||||
|
so FONT_SIZE_STEPS is exhausted → feasible=False, classifier-matching reason."""
|
||||||
|
plan = plan_font_step_compression(
|
||||||
|
current_font_px=15.2, excess_after_glue_px=200.0,
|
||||||
|
available_lines=10, chars_per_line=40, zone_position="top",
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["target_font_px"] is None
|
||||||
|
assert plan["font_floor_px"] == 8.0
|
||||||
|
reason = plan["failure_reason"]
|
||||||
|
assert "font_step floor" in reason
|
||||||
|
assert "8.0px" in reason
|
||||||
|
assert "200.0px" in reason
|
||||||
|
assert "FONT_SIZE_STEPS exhausted" in reason
|
||||||
|
assert apply_font_step_compression_css(plan) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_metrics_missing_defensive_infeasible() -> None:
|
||||||
|
"""available_lines=0 → guard fires before find_fitting_font_size;
|
||||||
|
failure_reason carries the text_metrics missing substring (classifier-friendly)."""
|
||||||
|
plan = plan_font_step_compression(
|
||||||
|
current_font_px=15.2, excess_after_glue_px=40.0,
|
||||||
|
available_lines=0, chars_per_line=40, zone_position="bottom_r",
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["target_font_px"] is None
|
||||||
|
assert "text_metrics missing" in plan["failure_reason"]
|
||||||
|
assert "available_lines/chars_per_line required" in plan["failure_reason"]
|
||||||
|
assert apply_font_step_compression_css(plan) == ""
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""IMP-12 u12 — plan_glue_compression tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u12):
|
||||||
|
- feasible case asserts emitted CSS contains [data-zone-position=...]
|
||||||
|
selector and NO global :root / body / .slide rule (scope lock)
|
||||||
|
- insufficient case feasible=False with envelope reason
|
||||||
|
|
||||||
|
u5 wraps space_allocator.calculate_glue_absorption + compute_glue_css_overrides
|
||||||
|
in the Step-17 plan signature. Glue envelope per block_count (SPACING_GLUE):
|
||||||
|
absorption_max = block_gap.shrink * (block_count-1) # 12 * (n-1)
|
||||||
|
+ inner_gap.shrink * block_count # 8 * n
|
||||||
|
+ title_gap.shrink * block_count # 4 * n
|
||||||
|
+ container_padding.shrink * 2 # 8 * 2
|
||||||
|
|
||||||
|
block_count=3 → 12*2 + 8*3 + 4*3 + 8*2 = 24+24+12+16 = 76 px
|
||||||
|
block_count=1 → 12*0 + 8*1 + 4*1 + 8*2 = 0+8+4+16 = 28 px
|
||||||
|
|
||||||
|
CSS must be wrapped under [data-zone-position="<pos>"] only
|
||||||
|
(feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_retry import (
|
||||||
|
apply_glue_compression_css,
|
||||||
|
plan_glue_compression,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feasible_case_emits_zone_scoped_css():
|
||||||
|
"""excess (40px) <= absorption_max (76px @ block_count=3) → feasible.
|
||||||
|
|
||||||
|
Emitted CSS must wrap overrides in [data-zone-position=...] selector and
|
||||||
|
contain none of the global selectors banned by feedback_phase_z_spacing_direction.
|
||||||
|
"""
|
||||||
|
plan = plan_glue_compression(
|
||||||
|
excess_px=40.0, block_count=3, zone_position="bottom_l",
|
||||||
|
)
|
||||||
|
assert plan["action"] == "glue_compression"
|
||||||
|
assert plan["zone_position"] == "bottom_l"
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["excess_px"] == 40.0
|
||||||
|
assert plan["block_count"] == 3
|
||||||
|
assert plan["absorption_max_px"] == 76.0
|
||||||
|
overrides = plan["overrides"]
|
||||||
|
assert overrides, "feasible plan must return non-empty overrides"
|
||||||
|
for key in ("--spacing-block", "--spacing-inner", "--container-padding"):
|
||||||
|
assert key in overrides, f"missing override key {key}"
|
||||||
|
|
||||||
|
css = apply_glue_compression_css(plan)
|
||||||
|
assert '[data-zone-position="bottom_l"]' in css
|
||||||
|
assert "--spacing-block:" in css
|
||||||
|
assert "--spacing-inner:" in css
|
||||||
|
assert "--container-padding:" in css
|
||||||
|
# Scope lock — no global rules permitted.
|
||||||
|
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||||
|
assert forbidden not in css, f"forbidden selector {forbidden!r} leaked into glue CSS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_envelope_feasible_false_with_reason():
|
||||||
|
"""excess (80px) > absorption_max (28px @ block_count=1) → infeasible.
|
||||||
|
|
||||||
|
failure_reason must surface the envelope shortage so the cascade router
|
||||||
|
(NEXT_ACTION_BY_FAILURE) can route onward to font_step_compression.
|
||||||
|
"""
|
||||||
|
plan = plan_glue_compression(
|
||||||
|
excess_px=80.0, block_count=1, zone_position="top",
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["absorption_max_px"] == 28.0
|
||||||
|
reason = plan["failure_reason"]
|
||||||
|
assert "glue envelope insufficient" in reason
|
||||||
|
assert "excess_px 80" in reason
|
||||||
|
assert "max absorption 28" in reason
|
||||||
|
# apply emits nothing when infeasible — no accidental CSS mutation on revert path.
|
||||||
|
assert apply_glue_compression_css(plan) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_excess_non_positive_no_compression_needed():
|
||||||
|
"""excess_px <= 0 → defensive infeasible (no compression required)."""
|
||||||
|
plan = plan_glue_compression(
|
||||||
|
excess_px=0.0, block_count=3, zone_position="bottom_r",
|
||||||
|
)
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["overrides"] == {}
|
||||||
|
assert plan["absorption_max_px"] == 0.0
|
||||||
|
assert "no compression needed" in plan["failure_reason"]
|
||||||
|
assert apply_glue_compression_css(plan) == ""
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""IMP-12 u10 — plan_zone_ratio_retry multi-donor aggregation tests.
|
||||||
|
|
||||||
|
Stage 2 contract (unit u10):
|
||||||
|
- single-donor sufficient (regression — backward compat preserved)
|
||||||
|
- single insufficient + 2nd sufficient (multi-donor PASS path)
|
||||||
|
- aggregate insufficient (multi-donor FAIL path)
|
||||||
|
|
||||||
|
u1 extended plan_zone_ratio_retry from a single primary donor to greedy
|
||||||
|
slack-desc aggregation across all eligible sibling zones. The plan dict
|
||||||
|
now carries donors_used / aggregate_slack_used / aggregate_slack_available
|
||||||
|
while preserving donor_zone_position + donor_reduced_px for the failure
|
||||||
|
classifier substrings (router still keys off primary donor name).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_retry import plan_zone_ratio_retry
|
||||||
|
|
||||||
|
|
||||||
|
_ROUTER_ACTIVE = {"router_active": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _classification(target_pos: str, excess_y: float) -> dict:
|
||||||
|
return {
|
||||||
|
"classifications": [
|
||||||
|
{
|
||||||
|
"proposed_action": "zone_ratio_retry",
|
||||||
|
"zone_position": target_pos,
|
||||||
|
"inputs": {"excess_y": excess_y},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _zone(position: str, height_px: int, min_height_px: int,
|
||||||
|
fit_status: str | None = "ok") -> dict:
|
||||||
|
return {
|
||||||
|
"position": position,
|
||||||
|
"height_px": height_px,
|
||||||
|
"min_height_px": min_height_px,
|
||||||
|
"composition_rationale": {
|
||||||
|
"capacity_fit": {"fit_status": fit_status},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _overflow_clean(donor_positions: list[str]) -> dict:
|
||||||
|
return {
|
||||||
|
"zones": [
|
||||||
|
{"position": p, "overflowed": False, "clipped_inner": False}
|
||||||
|
for p in donor_positions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_donor_sufficient_regression():
|
||||||
|
"""One donor with abundant slack. Plan must remain feasible and the
|
||||||
|
legacy donor_zone_position / donor_reduced_px fields must reflect the
|
||||||
|
primary donor (router classifier substring stability)."""
|
||||||
|
debug_zones = [
|
||||||
|
_zone("top", height_px=200, min_height_px=180),
|
||||||
|
_zone("bottom", height_px=400, min_height_px=200), # slack=200
|
||||||
|
]
|
||||||
|
plan = plan_zone_ratio_retry(
|
||||||
|
debug_zones=debug_zones,
|
||||||
|
overflow=_overflow_clean(["bottom"]),
|
||||||
|
fit_classification=_classification("top", excess_y=20.0),
|
||||||
|
router_decision=_ROUTER_ACTIVE,
|
||||||
|
)
|
||||||
|
assert plan is not None
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
# target_added_px = ceil(20) + DEFAULT_SAFETY_MARGIN_PX(4) = 24
|
||||||
|
assert plan["target_added_px"] == 24
|
||||||
|
assert plan["donor_zone_position"] == "bottom"
|
||||||
|
assert plan["donor_reduced_px"] == 24
|
||||||
|
assert plan["donors_used"] == [
|
||||||
|
{"position": "bottom", "reduced_px": 24,
|
||||||
|
"slack_before": 200, "slack_after": 176}
|
||||||
|
]
|
||||||
|
assert plan["aggregate_slack_used"] == 24
|
||||||
|
assert plan["aggregate_slack_available"] == 200
|
||||||
|
assert plan["zones_after"]["top"] == 224
|
||||||
|
assert plan["zones_after"]["bottom"] == 376
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_donor_pass_primary_insufficient_secondary_covers():
|
||||||
|
"""Primary donor alone has insufficient slack but primary + secondary
|
||||||
|
aggregate covers target_added_px. Multi-donor greedy aggregation must
|
||||||
|
split the deficit across both donors in slack-desc order."""
|
||||||
|
debug_zones = [
|
||||||
|
_zone("top", height_px=300, min_height_px=200),
|
||||||
|
_zone("middle", height_px=250, min_height_px=200), # slack=50
|
||||||
|
_zone("bottom", height_px=240, min_height_px=200), # slack=40
|
||||||
|
]
|
||||||
|
plan = plan_zone_ratio_retry(
|
||||||
|
debug_zones=debug_zones,
|
||||||
|
overflow=_overflow_clean(["middle", "bottom"]),
|
||||||
|
fit_classification=_classification("top", excess_y=66.0),
|
||||||
|
router_decision=_ROUTER_ACTIVE,
|
||||||
|
)
|
||||||
|
# target_added_px = ceil(66)+4 = 70. Primary (middle, slack=50) alone
|
||||||
|
# cannot cover, but middle(50)+bottom(40)=90 >= 70.
|
||||||
|
assert plan["feasible"] is True
|
||||||
|
assert plan["target_added_px"] == 70
|
||||||
|
assert plan["aggregate_slack_available"] == 90
|
||||||
|
assert plan["aggregate_slack_used"] == 70
|
||||||
|
assert plan["donor_zone_position"] == "middle" # primary
|
||||||
|
assert plan["donor_reduced_px"] == 50 # primary takes its full slack
|
||||||
|
assert [d["position"] for d in plan["donors_used"]] == ["middle", "bottom"]
|
||||||
|
assert plan["donors_used"][0]["reduced_px"] == 50
|
||||||
|
assert plan["donors_used"][1]["reduced_px"] == 20 # remainder
|
||||||
|
assert plan["zones_after"]["top"] == 370
|
||||||
|
assert plan["zones_after"]["middle"] == 200
|
||||||
|
assert plan["zones_after"]["bottom"] == 220
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_donor_fail_aggregate_insufficient():
|
||||||
|
"""All donors combined still cannot cover target_added_px. Plan must
|
||||||
|
be feasible=False with primary-donor substring preserved so the
|
||||||
|
failure_router classifier still routes through donor_slack_insufficient."""
|
||||||
|
debug_zones = [
|
||||||
|
_zone("top", height_px=300, min_height_px=200),
|
||||||
|
_zone("middle", height_px=210, min_height_px=200), # slack=10
|
||||||
|
_zone("bottom", height_px=215, min_height_px=200), # slack=15
|
||||||
|
]
|
||||||
|
plan = plan_zone_ratio_retry(
|
||||||
|
debug_zones=debug_zones,
|
||||||
|
overflow=_overflow_clean(["middle", "bottom"]),
|
||||||
|
fit_classification=_classification("top", excess_y=66.0),
|
||||||
|
router_decision=_ROUTER_ACTIVE,
|
||||||
|
)
|
||||||
|
# target_added_px=70, aggregate=25 → fail
|
||||||
|
assert plan["feasible"] is False
|
||||||
|
assert plan["aggregate_slack_available"] == 25
|
||||||
|
assert plan["aggregate_slack_used"] == 0
|
||||||
|
assert plan["donors_used"] == []
|
||||||
|
# Primary = highest-slack donor = bottom (15)
|
||||||
|
assert plan["donor_zone_position"] == "bottom"
|
||||||
|
assert plan["donor_max_slack"] == 15
|
||||||
|
# Classifier substring stability: "donor", "slack", and "<" still present
|
||||||
|
reason = plan["failure_reason"]
|
||||||
|
assert "donor" in reason
|
||||||
|
assert "slack" in reason
|
||||||
|
assert "<" in reason
|
||||||
|
# zones unchanged on fail (revert-friendly)
|
||||||
|
assert plan["zones_after"]["top"] == 300
|
||||||
|
assert plan["zones_after"]["middle"] == 210
|
||||||
|
assert plan["zones_after"]["bottom"] == 215
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""IMP-15 실행-1 (Gitea issue #45) — Step 14 image_aspect_mismatch detection.
|
||||||
|
|
||||||
|
Tests Selenium-driven `<img>` aspect measurement added to ``run_overflow_check``:
|
||||||
|
|
||||||
|
* Fixture A — 200×100 image rendered at 200×100 → ``abs(delta) < tol``, no fail
|
||||||
|
reason, ``passed=True``.
|
||||||
|
* Fixture B — 200×100 image forced to render 200×200 → ``abs(delta) > 0.30``,
|
||||||
|
fail reason includes ``image aspect mismatch in zone--primary:``,
|
||||||
|
``passed=False``.
|
||||||
|
* Fixture C — ``<img>`` with no ``.zone`` ancestor (attached directly under
|
||||||
|
``.slide``) → event reports ``zone_position == "unknown"``.
|
||||||
|
|
||||||
|
Chromedriver resolution mirrors the pipeline's order
|
||||||
|
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH fallback). When no driver is
|
||||||
|
resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the
|
||||||
|
tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.phase_z2_pipeline import (
|
||||||
|
IMAGE_ASPECT_DELTA_TOL,
|
||||||
|
PROJECT_ROOT,
|
||||||
|
run_overflow_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
PIL_Image = pytest.importorskip("PIL.Image", reason="Pillow required for fixture PNGs")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── chromedriver skip / xfail guard ─────────────────────────────────
|
||||||
|
|
||||||
|
def _selenium_manager_resolvable() -> bool:
|
||||||
|
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
|
||||||
|
|
||||||
|
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
|
||||||
|
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
|
||||||
|
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
|
||||||
|
for driver auto-resolution. The test resolver must mirror that fallback
|
||||||
|
or PHASE_Z_REQUIRE_SELENIUM=1 produces spurious strict-XPASS failures on
|
||||||
|
machines where Selenium Manager can satisfy the pipeline at runtime.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options as _Opts
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
opts = _Opts()
|
||||||
|
opts.add_argument("--headless=new")
|
||||||
|
opts.add_argument("--no-sandbox")
|
||||||
|
opts.add_argument("--disable-dev-shm-usage")
|
||||||
|
try:
|
||||||
|
drv = webdriver.Chrome(options=opts)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
drv.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _chromedriver_resolvable() -> bool:
|
||||||
|
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
|
||||||
|
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||||
|
if candidate.is_file():
|
||||||
|
return True
|
||||||
|
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||||
|
return True
|
||||||
|
return _selenium_manager_resolvable()
|
||||||
|
|
||||||
|
|
||||||
|
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||||
|
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||||
|
|
||||||
|
if not _DRIVER_AVAILABLE:
|
||||||
|
if _REQUIRE_SELENIUM:
|
||||||
|
pytestmark = pytest.mark.xfail(
|
||||||
|
strict=True,
|
||||||
|
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pytestmark = pytest.mark.skip(
|
||||||
|
reason=(
|
||||||
|
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||||
|
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── HTML / PNG fixture helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
_SLIDE_CSS = """
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
|
||||||
|
.zone { display: block; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _write_png(path: Path, width: int, height: int, colour=(120, 160, 200)) -> Path:
|
||||||
|
img = PIL_Image.new("RGB", (width, height), colour)
|
||||||
|
img.save(path, format="PNG")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
|
||||||
|
html = (
|
||||||
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||||
|
f"<style>{_SLIDE_CSS}</style></head><body>"
|
||||||
|
'<div class="slide" data-page="1">'
|
||||||
|
f"{body_inner}"
|
||||||
|
"</div></body></html>"
|
||||||
|
)
|
||||||
|
path = tmp_path / name
|
||||||
|
path.write_text(html, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _find_event(events, src_basename: str) -> dict:
|
||||||
|
for ev in events:
|
||||||
|
if Path(ev.get("src", "")).name == src_basename:
|
||||||
|
return ev
|
||||||
|
raise AssertionError(f"image_events missing entry for {src_basename}; got {events}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_image_no_distortion(tmp_path: Path) -> None:
|
||||||
|
"""Fixture A — 200×100 image rendered at native 200×100. delta ≈ 0."""
|
||||||
|
png = _write_png(tmp_path / "ok.png", 200, 100)
|
||||||
|
body = (
|
||||||
|
'<div class="zone" data-zone-position="primary" data-template-id="t_ok">'
|
||||||
|
f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
html_path = _write_slide_html(tmp_path, body, name="ok.html")
|
||||||
|
result = run_overflow_check(html_path)
|
||||||
|
|
||||||
|
assert "error" not in result, result
|
||||||
|
assert result.get("image_events"), "image_events must be populated"
|
||||||
|
|
||||||
|
ev = _find_event(result["image_events"], png.name)
|
||||||
|
assert ev["zone_position"] == "primary"
|
||||||
|
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||||
|
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 100
|
||||||
|
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
|
||||||
|
|
||||||
|
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||||
|
assert image_fails == [], f"unexpected image fail_reasons: {image_fails}"
|
||||||
|
assert result["passed"] is True, result.get("fail_reasons")
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_forced_distortion(tmp_path: Path) -> None:
|
||||||
|
"""Fixture B — 200×100 image forced to 200×200. delta > 0.30, fail emitted."""
|
||||||
|
png = _write_png(tmp_path / "bad.png", 200, 100, colour=(200, 80, 80))
|
||||||
|
body = (
|
||||||
|
'<div class="zone" data-zone-position="primary" data-template-id="t_bad">'
|
||||||
|
f'<img src="{png.name}" style="width:200px;height:200px;display:block">'
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
html_path = _write_slide_html(tmp_path, body, name="bad.html")
|
||||||
|
result = run_overflow_check(html_path)
|
||||||
|
|
||||||
|
assert "error" not in result, result
|
||||||
|
ev = _find_event(result["image_events"], png.name)
|
||||||
|
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||||
|
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 200
|
||||||
|
assert ev["delta"] is not None and abs(ev["delta"]) > 0.30
|
||||||
|
|
||||||
|
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||||
|
assert len(image_fails) == 1, f"expected one image fail_reason, got: {image_fails}"
|
||||||
|
msg = image_fails[0]
|
||||||
|
assert msg.startswith("image aspect mismatch in zone--primary:"), msg
|
||||||
|
assert "natural=2.000" in msg and "rendered=1.000" in msg
|
||||||
|
assert f"src={png.name}" in msg or png.name in msg
|
||||||
|
assert result["passed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_no_zone_ancestor(tmp_path: Path) -> None:
|
||||||
|
"""Fixture C — <img> attached directly under .slide → zone_position == 'unknown'."""
|
||||||
|
png = _write_png(tmp_path / "loose.png", 200, 100, colour=(80, 200, 120))
|
||||||
|
body = f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
|
||||||
|
html_path = _write_slide_html(tmp_path, body, name="loose.html")
|
||||||
|
result = run_overflow_check(html_path)
|
||||||
|
|
||||||
|
assert "error" not in result, result
|
||||||
|
ev = _find_event(result["image_events"], png.name)
|
||||||
|
assert ev["zone_position"] == "unknown"
|
||||||
|
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||||
|
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
|
||||||
|
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||||
|
assert image_fails == []
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""IMP-15 실행-2 (Gitea issue #46) — Step 14 table_self_overflow detection.
|
||||||
|
|
||||||
|
Tests Selenium-driven ``<table>`` self-overflow measurement and element-identity
|
||||||
|
wrapper dedup added to ``run_overflow_check``:
|
||||||
|
|
||||||
|
* Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper ancestor →
|
||||||
|
``table_events`` entry reports ``wrapper_clipped_index = None`` and an
|
||||||
|
``excess_*`` exceeding ``TABLE_SCROLL_TOL_PX``; Python aggregation then emits
|
||||||
|
a ``table self-overflow`` fail_reason and flips ``result["passed"] = False``.
|
||||||
|
* Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper. The wrapper itself
|
||||||
|
self-overflows (registers in ``clippedWrapperMap``) and the inner table also
|
||||||
|
self-overflows. Asserts dedup is honored: the table's ``wrapper_clipped_index``
|
||||||
|
resolves to the wrapper's map index (non-null) so the Python aggregation MUST
|
||||||
|
NOT emit a ``table self-overflow`` fail_reason — only the wrapper's pre-existing
|
||||||
|
``inner clipped`` fail line remains.
|
||||||
|
|
||||||
|
* Fixture F — two wrappers W1 / W2 share identical className ``f13b-cell``. W1
|
||||||
|
contains an overflowing inline-block child (no ``<table>``) → W1 self-overflows
|
||||||
|
and registers in ``clippedWrapperMap`` (emits ``inner clipped``). W2 contains
|
||||||
|
only a self-overflowing ``<table>``; W2's own scrollWidth equals its clientWidth
|
||||||
|
(the table's ``overflow:hidden`` keeps W2 itself uncliped). The element-identity
|
||||||
|
ancestor walk MUST resolve the W2 table's ``wrapper_clipped_index`` to ``None``
|
||||||
|
(W2 ≠ W1 by DOM reference, despite identical class string). A class-string
|
||||||
|
lookup would have falsely resolved the W2 table → W1 and suppressed the fail —
|
||||||
|
the test thereby proves ``Map<Element, int>`` distinguishes by node identity.
|
||||||
|
|
||||||
|
Chromedriver resolution mirrors the pipeline order
|
||||||
|
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH → Selenium Manager). When no driver
|
||||||
|
is resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1``
|
||||||
|
the tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.phase_z2_pipeline import (
|
||||||
|
PROJECT_ROOT,
|
||||||
|
TABLE_SCROLL_TOL_PX,
|
||||||
|
run_overflow_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── chromedriver skip / xfail guard ─────────────────────────────────
|
||||||
|
|
||||||
|
def _selenium_manager_resolvable() -> bool:
|
||||||
|
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
|
||||||
|
|
||||||
|
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
|
||||||
|
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
|
||||||
|
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
|
||||||
|
for driver auto-resolution. The test resolver must mirror that fallback
|
||||||
|
or ``PHASE_Z_REQUIRE_SELENIUM=1`` produces spurious strict-XPASS failures
|
||||||
|
on machines where Selenium Manager can satisfy the pipeline at runtime.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options as _Opts
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
opts = _Opts()
|
||||||
|
opts.add_argument("--headless=new")
|
||||||
|
opts.add_argument("--no-sandbox")
|
||||||
|
opts.add_argument("--disable-dev-shm-usage")
|
||||||
|
try:
|
||||||
|
drv = webdriver.Chrome(options=opts)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
drv.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _chromedriver_resolvable() -> bool:
|
||||||
|
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
|
||||||
|
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||||
|
if candidate.is_file():
|
||||||
|
return True
|
||||||
|
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||||
|
return True
|
||||||
|
return _selenium_manager_resolvable()
|
||||||
|
|
||||||
|
|
||||||
|
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||||
|
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||||
|
|
||||||
|
if not _DRIVER_AVAILABLE:
|
||||||
|
if _REQUIRE_SELENIUM:
|
||||||
|
pytestmark = pytest.mark.xfail(
|
||||||
|
strict=True,
|
||||||
|
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pytestmark = pytest.mark.skip(
|
||||||
|
reason=(
|
||||||
|
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||||
|
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── HTML fixture helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SLIDE_CSS = """
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
|
||||||
|
.zone { display: block; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
|
||||||
|
html = (
|
||||||
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||||
|
f"<style>{_SLIDE_CSS}</style></head><body>"
|
||||||
|
'<div class="slide" data-page="1">'
|
||||||
|
f"{body_inner}"
|
||||||
|
"</div></body></html>"
|
||||||
|
)
|
||||||
|
path = tmp_path / name
|
||||||
|
path.write_text(html, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ─── tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fixture_d_standalone_table_overflow(tmp_path: Path) -> None:
|
||||||
|
"""Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper.
|
||||||
|
|
||||||
|
The table is forced into block layout with a fixed clientWidth (100px) and
|
||||||
|
``overflow: hidden``; the inner cell is 600px wide with ``white-space:nowrap``,
|
||||||
|
so the table's scrollWidth exceeds clientWidth by well over ``TABLE_SCROLL_TOL_PX``.
|
||||||
|
No ancestor carries an ``f13b/f29b/f16b`` class, so the element-identity walk
|
||||||
|
must report ``wrapper_clipped_index = None``. Python aggregation then emits a
|
||||||
|
``table self-overflow`` fail_reason and flips ``result["passed"]`` to ``False``.
|
||||||
|
"""
|
||||||
|
body = (
|
||||||
|
'<div class="zone" data-zone-position="primary" data-template-id="t_table">'
|
||||||
|
'<table style="display:block; width:100px; height:30px; overflow:hidden; '
|
||||||
|
'box-sizing:border-box; table-layout:fixed;">'
|
||||||
|
'<tr><td style="width:600px; white-space:nowrap;">'
|
||||||
|
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||||
|
'</td></tr>'
|
||||||
|
'</table>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
html_path = _write_slide_html(tmp_path, body, name="fixture_d.html")
|
||||||
|
result = run_overflow_check(html_path)
|
||||||
|
|
||||||
|
assert "error" not in result, result
|
||||||
|
assert "table_events" in result, "run_overflow_check must expose table_events"
|
||||||
|
table_events = result["table_events"]
|
||||||
|
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||||
|
|
||||||
|
ev = table_events[0]
|
||||||
|
assert ev["zone_position"] == "primary", ev
|
||||||
|
assert ev["zone_template_id"] == "t_table", ev
|
||||||
|
assert ev["wrapper_clipped_index"] is None, (
|
||||||
|
f"standalone table must have null wrapper_clipped_index; got {ev['wrapper_clipped_index']}"
|
||||||
|
)
|
||||||
|
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||||
|
f"expected excess_x > {TABLE_SCROLL_TOL_PX}; got {ev['excess_x']} "
|
||||||
|
f"(clientWidth={ev['clientWidth']}, scrollWidth={ev['scrollWidth']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Python aggregation: emitted fail_reason + passed flipped to False.
|
||||||
|
fail_reasons = result.get("fail_reasons", [])
|
||||||
|
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||||
|
assert len(table_fails) == 1, (
|
||||||
|
f"expected exactly one 'table self-overflow' fail_reason; got fail_reasons={fail_reasons}"
|
||||||
|
)
|
||||||
|
assert "zone--primary" in table_fails[0], table_fails[0]
|
||||||
|
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
|
||||||
|
assert result["passed"] is False, (
|
||||||
|
f"table self-overflow must flip passed=False; got result={result}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixture_e_table_in_clipped_wrapper_dedup(tmp_path: Path) -> None:
|
||||||
|
"""Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper (dedup honored).
|
||||||
|
|
||||||
|
The wrapper (clientWidth=300, ``overflow:hidden``) contains a ``display:block``
|
||||||
|
table forced to width=500px → wrapper.scrollWidth (≈500) − clientWidth (300) > 5px,
|
||||||
|
so the wrapper is registered in ``clippedWrapperMap`` (emits ``inner clipped`` fail).
|
||||||
|
The inner table is itself self-overflowing (clientWidth=500, content nowrap-cell
|
||||||
|
width=900 → scrollWidth ≈ 900). The element-identity ancestor walk MUST resolve
|
||||||
|
the table's ``wrapper_clipped_index`` to the wrapper's integer map index, and the
|
||||||
|
Python aggregation MUST then SKIP emitting a ``table self-overflow`` fail_reason
|
||||||
|
(the clipped wrapper already accounts for this).
|
||||||
|
"""
|
||||||
|
body = (
|
||||||
|
'<div class="zone" data-zone-position="primary" data-template-id="t_table_wrap">'
|
||||||
|
'<div class="f13b-cell" style="width:300px; height:60px; overflow:hidden; '
|
||||||
|
'box-sizing:border-box; position:relative;">'
|
||||||
|
'<table style="display:block; width:500px; height:40px; overflow:hidden; '
|
||||||
|
'box-sizing:border-box; table-layout:fixed;">'
|
||||||
|
'<tr><td style="width:900px; white-space:nowrap;">'
|
||||||
|
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'
|
||||||
|
'</td></tr>'
|
||||||
|
'</table>'
|
||||||
|
'</div>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
html_path = _write_slide_html(tmp_path, body, name="fixture_e.html")
|
||||||
|
result = run_overflow_check(html_path)
|
||||||
|
|
||||||
|
assert "error" not in result, result
|
||||||
|
table_events = result.get("table_events", [])
|
||||||
|
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||||
|
|
||||||
|
ev = table_events[0]
|
||||||
|
# Dedup signal: ancestor walk must hit the f13b wrapper via Map.has(node).
|
||||||
|
assert ev["wrapper_clipped_index"] is not None, (
|
||||||
|
f"table inside clipped wrapper must inherit wrapper index; got ev={ev}"
|
||||||
|
)
|
||||||
|
assert isinstance(ev["wrapper_clipped_index"], int), ev
|
||||||
|
# The inner table is itself overflowing — proves the dedup is the only thing
|
||||||
|
# suppressing the table_self_overflow fail (not absence of overflow).
|
||||||
|
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||||
|
f"inner table must be self-overflowing for this test to be meaningful; ev={ev}"
|
||||||
|
)
|
||||||
|
|
||||||
|
fail_reasons = result.get("fail_reasons", [])
|
||||||
|
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||||
|
assert table_fails == [], (
|
||||||
|
f"dedup must suppress table self-overflow fail when wrapper is clipped; "
|
||||||
|
f"got table_fails={table_fails} fail_reasons={fail_reasons}"
|
||||||
|
)
|
||||||
|
# Wrapper's clipped_inner fail line must still be present.
|
||||||
|
clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
|
||||||
|
assert len(clipped_fails) >= 1, (
|
||||||
|
f"wrapper clipped_inner fail must remain; got fail_reasons={fail_reasons}"
|
||||||
|
)
|
||||||
|
assert result["passed"] is False, result
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixture_f_two_same_class_wrappers_element_identity(tmp_path: Path) -> None:
|
||||||
|
"""Fixture F (F1 acceptance) — two same-class wrappers, element-identity dedup.
|
||||||
|
|
||||||
|
W1 and W2 share the identical className ``f13b-cell``. W1 (clientWidth=300,
|
||||||
|
``overflow:hidden``) contains an inline-block ``<div>`` of width 600px →
|
||||||
|
W1.scrollWidth − clientWidth ≈ 300 > 5; W1 is registered in
|
||||||
|
``clippedWrapperMap`` and emits an ``inner clipped`` fail line. W2
|
||||||
|
(clientWidth=600, ``overflow:hidden``) contains a 500px-wide block-display
|
||||||
|
``<table>`` (matching the Fixture E table shape so the table is itself
|
||||||
|
self-overflowing with excess_x > 5). W2's clientWidth (600) is larger than
|
||||||
|
the table's outer width (500), so W2's own scrollWidth ≈ 500 < clientWidth
|
||||||
|
and W2 is NOT registered in ``clippedWrapperMap``.
|
||||||
|
|
||||||
|
The element-identity ancestor walk in the pipeline (L2298–L2304) walks from
|
||||||
|
the W2 table upward via ``parentElement`` and queries
|
||||||
|
``clippedWrapperMap.has(node)`` — keyed by DOM node, NOT className. W2 is
|
||||||
|
a different ``Element`` reference from W1 despite identical class string,
|
||||||
|
so the lookup returns false at W2 and the walk terminates at ``.slide`` with
|
||||||
|
``wrapper_clipped_index = null``. A class-substring keyed map (the F1
|
||||||
|
regression scenario described in issue #46) would have resolved any
|
||||||
|
``[class*="f13b"]`` ancestor of the W2 table → W1's index and falsely
|
||||||
|
suppressed the W2 table_self_overflow fail.
|
||||||
|
|
||||||
|
Asserts:
|
||||||
|
* Exactly ONE ``inner clipped`` fail line (for W1) — proves W1 is in the map.
|
||||||
|
* Exactly ONE ``table self-overflow`` fail line (for W2's table) — proves
|
||||||
|
the W2 table is NOT suppressed by W1's identical class string.
|
||||||
|
* W2 table's ``table_events`` entry reports ``wrapper_clipped_index = None``
|
||||||
|
(element-identity contract) and ``excess_x > TABLE_SCROLL_TOL_PX``.
|
||||||
|
"""
|
||||||
|
body = (
|
||||||
|
'<div class="zone" data-zone-position="primary" '
|
||||||
|
'data-template-id="t_table_same_class">'
|
||||||
|
# W1 — same className, overflowing non-table child.
|
||||||
|
'<div class="f13b-cell" id="w1" style="width:300px; height:60px; '
|
||||||
|
'overflow:hidden; box-sizing:border-box; position:relative; '
|
||||||
|
'margin-bottom:8px;">'
|
||||||
|
'<div style="display:inline-block; width:600px; white-space:nowrap;">'
|
||||||
|
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
|
||||||
|
'</div>'
|
||||||
|
'</div>'
|
||||||
|
# W2 — same className, NOT clipped (W2.clientWidth=600 > table.outer=500),
|
||||||
|
# but the inner table itself self-overflows (table width=500, td width=900).
|
||||||
|
'<div class="f13b-cell" id="w2" style="width:600px; height:60px; '
|
||||||
|
'overflow:hidden; box-sizing:border-box; position:relative;">'
|
||||||
|
'<table style="display:block; width:500px; height:40px; '
|
||||||
|
'overflow:hidden; box-sizing:border-box; table-layout:fixed;">'
|
||||||
|
'<tr><td style="width:900px; white-space:nowrap;">'
|
||||||
|
'YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'
|
||||||
|
'</td></tr>'
|
||||||
|
'</table>'
|
||||||
|
'</div>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
html_path = _write_slide_html(tmp_path, body, name="fixture_f.html")
|
||||||
|
result = run_overflow_check(html_path)
|
||||||
|
|
||||||
|
assert "error" not in result, result
|
||||||
|
|
||||||
|
# Exactly one table_events entry (the W2 table — W1 has no <table>).
|
||||||
|
table_events = result.get("table_events", [])
|
||||||
|
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||||
|
|
||||||
|
ev = table_events[0]
|
||||||
|
# Element-identity contract: W2 ≠ W1, so the ancestor walk MUST NOT inherit
|
||||||
|
# W1's wrapper index merely because W2 shares W1's class string.
|
||||||
|
assert ev["wrapper_clipped_index"] is None, (
|
||||||
|
f"W2 (not itself clipped) must NOT inherit W1's index via class string; "
|
||||||
|
f"got wrapper_clipped_index={ev['wrapper_clipped_index']}. "
|
||||||
|
"This is the F1 regression — a class-substring map would have failed here."
|
||||||
|
)
|
||||||
|
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||||
|
f"W2's inner table must self-overflow for this test to be meaningful; ev={ev}"
|
||||||
|
)
|
||||||
|
|
||||||
|
fail_reasons = result.get("fail_reasons", [])
|
||||||
|
|
||||||
|
# W1: inner clipped fail emitted (W1 is in clippedWrapperMap, has overflowing inner div).
|
||||||
|
w1_clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
|
||||||
|
assert len(w1_clipped_fails) == 1, (
|
||||||
|
f"expected exactly one W1 'inner clipped' fail; got fail_reasons={fail_reasons}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# W2: table self-overflow fail emitted because element-identity dedup correctly
|
||||||
|
# reports wrapper_clipped_index=None for the W2 table (W2 ≠ W1 by DOM ref).
|
||||||
|
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||||
|
assert len(table_fails) == 1, (
|
||||||
|
f"expected exactly one W2 'table self-overflow' fail (element-identity dedup); "
|
||||||
|
f"got fail_reasons={fail_reasons}"
|
||||||
|
)
|
||||||
|
assert "zone--primary" in table_fails[0], table_fails[0]
|
||||||
|
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
|
||||||
|
|
||||||
|
assert result["passed"] is False, result
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""IMP-12 u15 — End-to-end test of `_attempt_salvage_chain` (Step 17 deterministic salvage cascade).
|
||||||
|
|
||||||
|
Three Stage 2 cases against `src.phase_z2_pipeline._attempt_salvage_chain`:
|
||||||
|
(a) zone_ratio fail + cross_zone pass → final.html promoted, salvage_passed=True
|
||||||
|
(b) cross_zone fail + glue pass → 2nd cascade step promoted, salvage_passed=True
|
||||||
|
(c) all 3 fail → (b)-revert preserved, original final.html intact, salvage_passed=False
|
||||||
|
|
||||||
|
`render_slide` and `run_overflow_check` are monkey-patched so the test stays deterministic
|
||||||
|
(no Selenium / Jinja2 template files). The patches only stand in for the rendering / overflow
|
||||||
|
oracles — the planners (`plan_cross_zone_redistribute`, `plan_glue_compression`,
|
||||||
|
`plan_font_step_compression`) and the cascade router (`route_retry_failure` /
|
||||||
|
`SALVAGE_FAIL_BY_ACTION`) all run unmocked.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import src.phase_z2_pipeline as _pz_pipeline
|
||||||
|
from src.fit_verifier import FitAnalysis, RoleFit
|
||||||
|
from src.phase_z2_pipeline import _attempt_salvage_chain
|
||||||
|
|
||||||
|
|
||||||
|
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project_tmp(tmp_path_factory):
|
||||||
|
"""Temp dir under PROJECT_ROOT so _attempt_salvage_chain can call
|
||||||
|
candidate_path.relative_to(PROJECT_ROOT) without ValueError on a
|
||||||
|
cross-drive system tmp path (pytest's default tmp_path is under
|
||||||
|
%LOCALAPPDATA% on Windows, which lives on a different drive from
|
||||||
|
the project root in this repo)."""
|
||||||
|
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
d = Path(tempfile.mkdtemp(prefix="u15_salvage_", dir=str(base)))
|
||||||
|
try:
|
||||||
|
yield d
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
_LAYOUT_CSS_GATE_PASS = {
|
||||||
|
"areas": '"top" "bottom"',
|
||||||
|
"cols": "1fr",
|
||||||
|
"rows": "1fr 1fr",
|
||||||
|
"heights_px": [300, 290],
|
||||||
|
"widths_px": [1180],
|
||||||
|
"ratios": [0.508, 0.491],
|
||||||
|
"width_ratios": [1.0],
|
||||||
|
"dynamic_rows": True,
|
||||||
|
"dynamic_cols": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_render(monkeypatch):
|
||||||
|
"""Stub render_slide → deterministic HTML envelope so the cascade does not
|
||||||
|
need real Jinja2 templates. Returns a counter so tests can assert how many
|
||||||
|
times it was invoked (one per CSS-feasible cascade step)."""
|
||||||
|
counter = {"n": 0}
|
||||||
|
|
||||||
|
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
|
||||||
|
counter["n"] += 1
|
||||||
|
return (
|
||||||
|
f"<html><head><meta charset='utf-8'></head>"
|
||||||
|
f"<body><div data-slide-title='{slide_title}'></div></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
|
||||||
|
return counter
|
||||||
|
|
||||||
|
|
||||||
|
def _kwargs(*, run_dir: Path, out_path: Path, cascade_inputs: dict,
|
||||||
|
initial_failure_type: str = "donor_slack_insufficient") -> dict:
|
||||||
|
return {
|
||||||
|
"run_dir": run_dir,
|
||||||
|
"out_path": out_path,
|
||||||
|
"slide_title": "u15-test",
|
||||||
|
"slide_footer": None,
|
||||||
|
"zones_data": [],
|
||||||
|
"layout_preset": "horizontal-2",
|
||||||
|
"layout_css": _LAYOUT_CSS_GATE_PASS,
|
||||||
|
"cascade_inputs": cascade_inputs,
|
||||||
|
"initial_failure_type": initial_failure_type,
|
||||||
|
"gap_px": 14,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_a_cross_zone_passes_final_html_promoted(project_tmp, monkeypatch):
|
||||||
|
"""(a) cross_zone_redistribute is feasible + run_overflow_check returns
|
||||||
|
passed=True → out_path overwritten with the cross_zone candidate HTML and
|
||||||
|
salvage_passed=True after the very first cascade iteration."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||||
|
|
||||||
|
# Multi-role same-zone FitAnalysis: top +30 deficit, bottom_l -50 surplus.
|
||||||
|
fit_analysis = FitAnalysis(roles={
|
||||||
|
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||||
|
"bottom_l": RoleFit(role="bottom_l", allocated_px=300, shortfall_px=-50.0),
|
||||||
|
})
|
||||||
|
containers = {
|
||||||
|
"top": {"zone": "slide_body", "height_px": 200},
|
||||||
|
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||||
|
}
|
||||||
|
cascade_inputs = {
|
||||||
|
"fit_analysis": fit_analysis,
|
||||||
|
"containers": containers,
|
||||||
|
"min_margin_px": 10,
|
||||||
|
"excess_px": 30.0, "excess_after_glue_px": 30.0,
|
||||||
|
"block_count": 3, "zone_position": "top",
|
||||||
|
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
_patch_render(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check",
|
||||||
|
lambda p: {"passed": True, "fail_reasons": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
trace = _attempt_salvage_chain(
|
||||||
|
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||||
|
)
|
||||||
|
|
||||||
|
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"] == "cross_zone_redistribute"
|
||||||
|
assert step0["passed"] is True
|
||||||
|
assert step0["plan"]["feasible"] is True
|
||||||
|
assert step0["css_override"] and '[data-role=' in step0["css_override"]
|
||||||
|
# out_path was overwritten with the salvage candidate.
|
||||||
|
promoted = out_path.read_text(encoding="utf-8")
|
||||||
|
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||||
|
assert "u15-test" in promoted
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkeypatch):
|
||||||
|
"""(b) cross_zone is infeasible (single-role zone) → glue_compression CSS
|
||||||
|
emitted + run_overflow_check passes → out_path overwritten with the glue
|
||||||
|
candidate (2nd cascade step). salvage_passed=True; salvage_steps[0]
|
||||||
|
records the infeasible cross_zone attempt."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||||
|
|
||||||
|
# Single-role zone → fit_verifier.redistribute returns can_redistribute=False
|
||||||
|
# (peer required, none present).
|
||||||
|
fit_analysis = FitAnalysis(roles={
|
||||||
|
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||||
|
})
|
||||||
|
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||||
|
# Glue envelope at block_count=3 = 12*(3-1)+8*3+4*3+8*2 = 76 px → 40 px is feasible.
|
||||||
|
cascade_inputs = {
|
||||||
|
"fit_analysis": fit_analysis,
|
||||||
|
"containers": containers,
|
||||||
|
"min_margin_px": 10,
|
||||||
|
"excess_px": 40.0, "excess_after_glue_px": 40.0,
|
||||||
|
"block_count": 3, "zone_position": "bottom_l",
|
||||||
|
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
render_counter = _patch_render(monkeypatch)
|
||||||
|
# cross_zone is infeasible → no CSS → no rerender / no overflow call. Glue is
|
||||||
|
# feasible → exactly one rerender + overflow call → return passed=True.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_pz_pipeline, "run_overflow_check",
|
||||||
|
lambda p: {"passed": True, "fail_reasons": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
trace = _attempt_salvage_chain(
|
||||||
|
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trace["salvage_attempted"] is True
|
||||||
|
assert trace["salvage_passed"] is True
|
||||||
|
assert len(trace["salvage_steps"]) == 2
|
||||||
|
|
||||||
|
s0 = trace["salvage_steps"][0]
|
||||||
|
assert s0["action"] == "cross_zone_redistribute"
|
||||||
|
assert s0["passed"] is False
|
||||||
|
assert s0["plan"]["feasible"] is False
|
||||||
|
assert s0["css_override"] is None
|
||||||
|
assert "single-role zone" in (s0["plan"].get("failure_reason") or "")
|
||||||
|
|
||||||
|
s1 = trace["salvage_steps"][1]
|
||||||
|
assert s1["action"] == "glue_compression"
|
||||||
|
assert s1["passed"] is True
|
||||||
|
assert s1["plan"]["feasible"] is True
|
||||||
|
assert s1["css_override"] and '[data-zone-position="bottom_l"]' in s1["css_override"]
|
||||||
|
# render_slide was invoked exactly once (only the glue branch emitted CSS).
|
||||||
|
assert render_counter["n"] == 1
|
||||||
|
# out_path was overwritten with the glue candidate.
|
||||||
|
promoted = out_path.read_text(encoding="utf-8")
|
||||||
|
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
||||||
|
"""(c) All three cascade actions are infeasible (no CSS emitted by any
|
||||||
|
planner) → run_overflow_check is never invoked, salvage_passed=False,
|
||||||
|
salvage_steps has three failed entries, and out_path is unchanged
|
||||||
|
(original final.html intact — (b)-revert preserved)."""
|
||||||
|
out_path = project_tmp / "final.html"
|
||||||
|
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||||
|
|
||||||
|
cascade_inputs = {
|
||||||
|
# cross_zone: fit_analysis missing → plan returns feasible=False with reason
|
||||||
|
# `cascade_inputs.fit_analysis missing` (see _attempt_salvage_chain branch).
|
||||||
|
"fit_analysis": None,
|
||||||
|
"containers": {},
|
||||||
|
"min_margin_px": 10,
|
||||||
|
# glue: excess_px (200) > envelope max at block_count=1 (28) → infeasible.
|
||||||
|
"excess_px": 200.0, "excess_after_glue_px": 200.0,
|
||||||
|
"block_count": 1, "zone_position": "top",
|
||||||
|
# font_step: current_font_px=15.2 cannot absorb 200px even at 8px floor
|
||||||
|
# → find_fitting_font_size returns None → feasible=False.
|
||||||
|
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
render_counter = _patch_render(monkeypatch)
|
||||||
|
# Guard: if run_overflow_check is ever called, the test fails loudly.
|
||||||
|
def _must_not_call(_p): # pragma: no cover — intentional sentinel
|
||||||
|
raise AssertionError("run_overflow_check must not run when no CSS is emitted")
|
||||||
|
monkeypatch.setattr(_pz_pipeline, "run_overflow_check", _must_not_call)
|
||||||
|
|
||||||
|
trace = _attempt_salvage_chain(
|
||||||
|
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trace["salvage_attempted"] is True
|
||||||
|
assert trace["salvage_passed"] is False
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
for step in trace["salvage_steps"]:
|
||||||
|
assert step["passed"] is False
|
||||||
|
assert step["css_override"] is None
|
||||||
|
assert step["failure_reason"]
|
||||||
|
# No CSS emitted anywhere → no render_slide calls either.
|
||||||
|
assert render_counter["n"] == 0
|
||||||
|
# (b) revert: out_path is untouched.
|
||||||
|
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""IMP-14 A-4 — slide_base.html embedded_mode contract tests.
|
||||||
|
|
||||||
|
Asserts the three-valued enum (auto / embedded / standalone) round-trips
|
||||||
|
through render_slide -> slide_base.html, that the additive html.embedded
|
||||||
|
CSS reset and the auto-mode detection <script> are emitted under the
|
||||||
|
correct modes, that the invalid-mode guard raises ValueError, and that
|
||||||
|
Jinja2 rendering is byte-deterministic across calls.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.phase_z2_pipeline import render_slide
|
||||||
|
|
||||||
|
|
||||||
|
def _zone() -> dict:
|
||||||
|
return {"position": "primary", "template_id": "__empty__", "slot_payload": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _layout_css() -> dict:
|
||||||
|
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||||
|
|
||||||
|
|
||||||
|
def _render(embedded_mode: str = "auto") -> str:
|
||||||
|
return render_slide(
|
||||||
|
slide_title="t",
|
||||||
|
slide_footer=None,
|
||||||
|
zones_data=[_zone()],
|
||||||
|
layout_preset="single",
|
||||||
|
layout_css=_layout_css(),
|
||||||
|
gap_px=14,
|
||||||
|
embedded_mode=embedded_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_script_present():
|
||||||
|
html = _render("auto")
|
||||||
|
assert "params.get('embedded')" in html
|
||||||
|
assert "window.self !== window.top" in html
|
||||||
|
assert "classList.add('embedded')" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_rules_present():
|
||||||
|
html = _render("auto")
|
||||||
|
assert "html.embedded body" in html
|
||||||
|
assert "html.embedded .slide" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedded_mode_explicit():
|
||||||
|
html = _render("embedded")
|
||||||
|
assert '<html lang="ko" class="embedded">' in html
|
||||||
|
assert "params.get('embedded')" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_standalone_mode_explicit():
|
||||||
|
html = _render("standalone")
|
||||||
|
assert '<html lang="ko">' in html
|
||||||
|
assert 'class="embedded"' not in html.split("</head>")[0]
|
||||||
|
assert "params.get('embedded')" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_deterministic():
|
||||||
|
assert _render("embedded") == _render("embedded")
|
||||||
|
assert _render("auto") == _render("auto")
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_mode_raises():
|
||||||
|
with pytest.raises(ValueError, match="invalid embedded_mode"):
|
||||||
|
_render("bogus")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""IMP-13 u7 smoke — discovery, source invariants, dry-run, idempotency, manifest schema."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json, os, re, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCRIPT_PATH = REPO_ROOT / "scripts" / "generate_frame_previews.py"
|
||||||
|
sys.path.insert(0, str(SCRIPT_PATH.parent))
|
||||||
|
import generate_frame_previews as gfp # noqa: E402
|
||||||
|
|
||||||
|
def _fixture(root: Path) -> Path:
|
||||||
|
blocks = root / "blocks"
|
||||||
|
(blocks / "FRAME_A").mkdir(parents=True)
|
||||||
|
(blocks / "FRAME_A" / "index.html").write_text("<html><body class=slide></body></html>", encoding="utf-8")
|
||||||
|
(blocks / "FRAME_A" / "preview.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||||
|
(blocks / "FRAME_B").mkdir()
|
||||||
|
(blocks / "ORPHAN").mkdir()
|
||||||
|
(blocks / "ORPHAN" / "preview.png").write_bytes(b"x")
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def test_discover_counts(tmp_path: Path) -> None:
|
||||||
|
rows = gfp.discover(_fixture(tmp_path))
|
||||||
|
assert [r.frame_id for r in rows] == ["FRAME_A", "FRAME_B", "ORPHAN"]
|
||||||
|
assert sum(r.has_index for r in rows) == 1 and sum(r.has_preview for r in rows) == 2
|
||||||
|
|
||||||
|
def test_source_invariants() -> None:
|
||||||
|
src = SCRIPT_PATH.read_text(encoding="utf-8")
|
||||||
|
for t in ("anthropic", "openai", "jinja", "phase_z2", "slide_measurer"): assert t not in src, t
|
||||||
|
for lit in ("1280", "720", "1400", "900"): assert not re.search(rf"(?<!\d){lit}(?!\d)", src), lit
|
||||||
|
|
||||||
|
def test_dry_run_prints_counts(tmp_path: Path, capsys) -> None:
|
||||||
|
rc = gfp.main(["--blocks-dir", str(_fixture(tmp_path)), "--manifest", str(tmp_path / "m.json"), "--dry-run"])
|
||||||
|
assert rc == 0 and "discovered: total=3 with_index_html=1 with_preview_png=2" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_idempotency_unchanged(tmp_path: Path) -> None:
|
||||||
|
row = gfp.discover(_fixture(tmp_path))[0]
|
||||||
|
mt = row.index_html_path.stat().st_mtime
|
||||||
|
os.utime(row.preview_png_path, (mt + 1, mt + 1))
|
||||||
|
sha = gfp._sha256_file(row.index_html_path)
|
||||||
|
assert gfp.is_unchanged(row, {"index_sha256": sha}) is True
|
||||||
|
assert gfp.is_unchanged(row, {"index_sha256": "x"}) is False
|
||||||
|
assert gfp.is_unchanged(row, None) is False
|
||||||
|
|
||||||
|
def test_manifest_schema(tmp_path: Path) -> None:
|
||||||
|
blocks = tmp_path / "blocks"; (blocks / "F").mkdir(parents=True); (blocks / "F" / "preview.png").write_bytes(b"x")
|
||||||
|
mf = tmp_path / "m.json"
|
||||||
|
assert gfp.main(["--blocks-dir", str(blocks), "--manifest", str(mf)]) == 0
|
||||||
|
data = json.loads(mf.read_text(encoding="utf-8"))
|
||||||
|
assert set(data) >= {"schema", "generated_at", "blocks_dir", "summary", "frames"} and data["schema"] == 1
|
||||||
|
assert set(data["summary"]) >= {"total", "renderable", "missing_index_html", "orphan", "rendered", "skipped_unchanged", "error"} and data["summary"]["orphan"] == 1 and data["frames"]["F"]["status"] == "orphan"
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""IMP-11 D-2 (u1) — Step 9 v4_all_judgments[] min_height_px field tests.
|
||||||
|
|
||||||
|
u1 contract:
|
||||||
|
Each v4_all_judgments[] entry MUST expose `min_height_px` sourced from
|
||||||
|
catalog `frame_contracts[template_id].visual_hints.min_height_px`
|
||||||
|
(logical 1280×720 px), with `None` fallback when contract is unregistered.
|
||||||
|
A single `get_contract(c.template_id)` lookup binds both
|
||||||
|
`catalog_registered` and `min_height_px` (no double-lookup cost).
|
||||||
|
|
||||||
|
Production code = inline list builder in `run_phase_z2_mvp1`
|
||||||
|
(`src/phase_z2_pipeline.py`, near v4_all_for_unit loop). These tests follow
|
||||||
|
the same source-string + catalog-shape guard pattern as the existing
|
||||||
|
`test_step9_production_emits_candidate_evidence_and_alias` in
|
||||||
|
`tests/test_phase_z2_v4_fallback.py`, kept until a helper is extracted.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from src import phase_z2_pipeline
|
||||||
|
from phase_z2_mapper import get_contract, load_frame_contracts
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Case 1 : u1 production-source guard ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_all_judgments_emits_min_height_px_with_none_fallback():
|
||||||
|
"""Source guard — single get_contract bound to `_contract`, then both
|
||||||
|
`catalog_registered` and `min_height_px` derived from that binding.
|
||||||
|
`min_height_px` uses the `(_contract or {})` chain so unregistered
|
||||||
|
contracts propagate `None` (frontend tolerates undefined).
|
||||||
|
"""
|
||||||
|
source = inspect.getsource(phase_z2_pipeline)
|
||||||
|
|
||||||
|
# u1 marker present (locates the builder)
|
||||||
|
assert "IMP-11 D-2 (u1)" in source
|
||||||
|
|
||||||
|
# Single get_contract lookup bound to local var
|
||||||
|
assert "_contract = get_contract(c.template_id)" in source
|
||||||
|
|
||||||
|
# catalog_registered reuses the local binding (no second lookup)
|
||||||
|
assert '"catalog_registered": _contract is not None' in source
|
||||||
|
|
||||||
|
# min_height_px source = visual_hints chain; None when contract is None
|
||||||
|
assert (
|
||||||
|
'"min_height_px": (_contract or {})'
|
||||||
|
'.get("visual_hints", {})'
|
||||||
|
'.get("min_height_px")'
|
||||||
|
) in source
|
||||||
|
|
||||||
|
# v4_all_judgments wires the new builder list
|
||||||
|
assert '"v4_all_judgments": v4_all_judgments_list' in source
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Case 2 : additive guarantee — existing 7 fields preserved ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_all_judgments_preserves_existing_fields():
|
||||||
|
"""u1 is additive only — the existing 7 keys must remain in the per-entry
|
||||||
|
dict alongside the new `min_height_px`.
|
||||||
|
"""
|
||||||
|
source = inspect.getsource(phase_z2_pipeline)
|
||||||
|
|
||||||
|
builder_start = source.find("IMP-11 D-2 (u1)")
|
||||||
|
assert builder_start != -1
|
||||||
|
builder_end = source.find("application_plan_units.append", builder_start)
|
||||||
|
assert builder_end != -1
|
||||||
|
builder = source[builder_start:builder_end]
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
'"template_id": c.template_id',
|
||||||
|
'"frame_id": c.frame_id',
|
||||||
|
'"frame_number": c.frame_number',
|
||||||
|
'"v4_rank": c.v4_rank',
|
||||||
|
'"confidence": c.confidence',
|
||||||
|
'"label": c.label',
|
||||||
|
'"catalog_registered": _contract is not None',
|
||||||
|
'"min_height_px":',
|
||||||
|
):
|
||||||
|
assert field in builder, f"missing field in u1 builder: {field!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Case 3 : catalog reality — visual_hints.min_height_px shape is real ────
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_visual_hints_min_height_px_path_is_real():
|
||||||
|
"""The source-string guard depends on the actual catalog shape having
|
||||||
|
`visual_hints.min_height_px` as a positive int on registered contracts
|
||||||
|
whose `visual_hints` block declares it. Verify against the real
|
||||||
|
`frame_contracts.yaml` so a future catalog schema change cannot silently
|
||||||
|
invalidate the `.get("visual_hints", {}).get("min_height_px")` chain.
|
||||||
|
"""
|
||||||
|
load_frame_contracts()
|
||||||
|
|
||||||
|
# Real registered template_ids that ship with visual_hints.min_height_px
|
||||||
|
# (verified via load_frame_contracts() — see frame_contracts.yaml).
|
||||||
|
sample_template_ids = (
|
||||||
|
"three_parallel_requirements",
|
||||||
|
"process_product_two_way",
|
||||||
|
"construction_goals_three_circle_intersection",
|
||||||
|
"bim_dx_comparison_table",
|
||||||
|
)
|
||||||
|
|
||||||
|
found = 0
|
||||||
|
for tid in sample_template_ids:
|
||||||
|
contract = get_contract(tid)
|
||||||
|
if contract is None:
|
||||||
|
continue # tolerate catalog rename — at least one must remain
|
||||||
|
# The exact .get chain used by the u1 builder
|
||||||
|
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||||
|
assert isinstance(min_h, int), (
|
||||||
|
f"{tid}: visual_hints.min_height_px must be int, "
|
||||||
|
f"got {type(min_h).__name__}={min_h!r}"
|
||||||
|
)
|
||||||
|
assert min_h > 0, f"{tid}: min_height_px must be positive, got {min_h}"
|
||||||
|
found += 1
|
||||||
|
|
||||||
|
assert found > 0, (
|
||||||
|
"no sample registered contract present — catalog audit drift; "
|
||||||
|
"update sample_template_ids to match current frame_contracts.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_contract_without_min_height_px_propagates_none():
|
||||||
|
"""Registered contract whose `visual_hints` block omits `min_height_px`
|
||||||
|
(or sets it to `null`) must also propagate `None` through the u1 chain.
|
||||||
|
Real example in current catalog: `bim_issues_quadrant_four`.
|
||||||
|
"""
|
||||||
|
load_frame_contracts()
|
||||||
|
|
||||||
|
tid = "bim_issues_quadrant_four"
|
||||||
|
contract = get_contract(tid)
|
||||||
|
if contract is None:
|
||||||
|
import pytest # noqa: PLC0415 — runtime skip only when catalog drifts
|
||||||
|
pytest.skip(f"sample template {tid!r} no longer registered")
|
||||||
|
|
||||||
|
# Exact chain used by u1 builder
|
||||||
|
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||||
|
assert min_h is None, (
|
||||||
|
f"{tid}: expected None when visual_hints.min_height_px is absent/null, "
|
||||||
|
f"got {min_h!r} — chain semantics changed"
|
||||||
|
)
|
||||||
|
# catalog_registered must still be True (additive, independent of value)
|
||||||
|
assert (contract is not None) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Case 4 : None propagation for unregistered template_id ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_unregistered_template_id_propagates_none():
|
||||||
|
"""When `get_contract(template_id)` returns `None`, the u1 chain
|
||||||
|
`(_contract or {}).get("visual_hints", {}).get("min_height_px")` must
|
||||||
|
yield `None` (frontend tolerates undefined; no KeyError).
|
||||||
|
"""
|
||||||
|
load_frame_contracts()
|
||||||
|
|
||||||
|
# Synthetic template_id guaranteed not to be in the catalog
|
||||||
|
unregistered = "MOCK_template_unregistered_for_u1_test"
|
||||||
|
assert get_contract(unregistered) is None, (
|
||||||
|
"test precondition broken — synthetic template_id leaked into catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replicate the u1 chain exactly
|
||||||
|
_contract = get_contract(unregistered)
|
||||||
|
min_height = (_contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||||
|
catalog_registered = _contract is not None
|
||||||
|
|
||||||
|
assert min_height is None
|
||||||
|
assert catalog_registered is False
|
||||||
Reference in New Issue
Block a user