feat(IMP-15): 실행-1 — Step 14 image_aspect_mismatch detection
Issue: #45 (IMP-15 실행-1, image axis only). Adds Selenium-based <img> aspect ratio measurement to Step 14 run_overflow_check + numeric tolerance gate. Tolerance lives as module-scope constant so tests can import it. src/phase_z2_pipeline.py (+73/-2): - L131-L135 IMAGE_ASPECT_DELTA_TOL = 0.05 (module scope, importable) - L2216-L2261 JS payload extension: image_events[] per <img> (src, zone_position via closest('.zone') with 'unknown' fallback, zone_template_id, natural/rendered w+h+ratio, delta, slide-rel bbox) - L2262 run_overflow_check return extended with image_events - L2302-L2320 Python aggregation: abs(delta) > TOL ⇒ fail_reasons append 'image aspect mismatch in zone--<pos>: natural=<n> rendered=<r> delta=<+d> (template=<tid>, tol=0.05, src=<src>)'. Null-delta entries (image not loaded) are skipped — no false positive. Branch placed AFTER existing non-image branches; ordering & strings for slide/slide-body/zone/clipped_inner unchanged. - L4425-L4429 Step 14 note: image half closed, table half deferred to 실행-2. tests/phase_z2/test_phase_z2_step14_image_check.py (+196, new): - 3-tier chromedriver resolver mirroring pipeline (PROJECT_ROOT/ chromedriver{,.exe} → PATH → Selenium Manager probe). - pytestmark: skip when chromedriver unresolvable AND PHASE_Z_REQUIRE_SELENIUM != '1'; xfail(strict=True) opt-in when =='1'. - Fixture A: 200×100 img rendered 200×100 → aspect_delta < 0.05, passed. - Fixture B: 200×100 intrinsic forced to 200×200 → delta > 0.30, fail_reason present. - Fixture C: <img> with no .zone ancestor → zone_position == 'unknown'. Verification (Stage 4 PASS, Claude + Codex independent): - pytest -q tests/phase_z2/test_phase_z2_step14_image_check.py → 3 passed - PHASE_Z_REQUIRE_SELENIUM=1 same suite → 3 passed (strict opt-in) - pytest -q tests/phase_z2 → 90 passed (no regression) - pytest -q --ignore=tests/matching → 174 passed Scope-locked: no slide_base.html / catalog / classifier / debug.json / spec-doc changes. table_events (실행-2), visual_check_passed flip (실행-3), debug.json image_events surfacing + PHASE-Z spec doc row (실행-4) remain queued as separate IMP-15 child execution issues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -128,6 +128,11 @@ 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
|
||||||
|
|
||||||
# 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
|
||||||
@@ -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 <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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events };
|
||||||
""")
|
""")
|
||||||
|
|
||||||
screenshot_path = html_path.parent / "preview.png"
|
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']})"
|
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["passed"] = len(fail_reasons) == 0
|
||||||
result["fail_reasons"] = fail_reasons
|
result["fail_reasons"] = fail_reasons
|
||||||
return result
|
return result
|
||||||
@@ -4355,7 +4425,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)."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
196
tests/phase_z2/test_phase_z2_step14_image_check.py
Normal file
196
tests/phase_z2/test_phase_z2_step14_image_check.py
Normal file
@@ -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 == []
|
||||||
Reference in New Issue
Block a user