diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index ec4b586..ab3c2a1 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -128,6 +128,11 @@ GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07) # token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이. 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 + # content_weight 계산 가중치 CONTENT_WEIGHT_COEFFS = { "text_per_chars": 800, # text_len / 800 = score @@ -2208,7 +2213,53 @@ 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 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), + }, + }); + }); + + return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events }; """) screenshot_path = html_path.parent / "preview.png" @@ -2248,6 +2299,25 @@ def run_overflow_check(html_path: Path) -> dict: 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})" + ) + result["passed"] = len(fail_reasons) == 0 result["fail_reasons"] = fail_reasons return result @@ -4355,7 +4425,8 @@ def run_phase_z2_mvp1( note=( "Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. " "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)." ), ) diff --git a/tests/phase_z2/test_phase_z2_step14_image_check.py b/tests/phase_z2/test_phase_z2_step14_image_check.py new file mode 100644 index 0000000..9377e67 --- /dev/null +++ b/tests/phase_z2/test_phase_z2_step14_image_check.py @@ -0,0 +1,196 @@ +"""IMP-15 실행-1 (Gitea issue #45) — Step 14 image_aspect_mismatch detection. + +Tests Selenium-driven `` 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 — ```` 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 = ( + "" + f"" + '
' + f"{body_inner}" + "
" + ) + 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 = ( + '
' + f'' + "
" + ) + 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 = ( + '
' + f'' + "
" + ) + 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 — attached directly under .slide → zone_position == 'unknown'.""" + png = _write_png(tmp_path / "loose.png", 200, 100, colour=(80, 200, 120)) + body = f'' + 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 == []