From 201099e53b0048f3040e17ee9b0394ba9ad6e940 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Sat, 16 May 2026 12:03:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(IMP-09):=20PR=201=20=E2=80=94=20col-axis?= =?UTF-8?q?=20solver=20+=20per-zone=20geometry=20mapper=20+=20retry=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 round 4 lock implementation: extend build_layout_css beyond the horizontal-2-only dynamic path. Every layout_css return now carries length-locked col-axis keys (widths_px, width_ratios, dynamic_cols) matching the parsed css_areas grid (R rows, C cols), so 2-D layouts (T / 2x2 in PR 2) and the unified _compute_per_zone_geometry mapper can plug in without further contract churn. PR 1 scope: - _parse_css_areas + _parse_fr_string + _compute_per_zone_geometry (unified — 1-D and 2-D from the same code path) - compute_zone_layout_cols (vertical-2 weight-only solver) - _build_fr_default / _build_rows_dynamic / _build_cols_dynamic (populate widths_px/heights_px on every return path) - build_layout_css override branch keeps the warn-and-fallthrough legacy for unsupported presets (PR 2 promotes to strict raise) - retry gate in _attempt_zone_ratio_retry skips when dynamic_cols=True or dynamic_rows=False, with explicit retry_skipped_reason - Step 8 artifact gains zone_widths_px_planned / zone_col_ratios_planned (top-level) + zone_width_px_planned / zone_col_ratio_planned (per-zone) - debug_zones width injection via _compute_per_zone_geometry (replaces the legacy row-only zip) Tests: tests/phase_z2/ — 47 new cases (parse / fr-string / cols solver / per-zone geometry / build_layout_css contract / retry gate + 6 build_layout_css YAML fixtures + 3 retry_gate fixtures). Verification: python -m pytest -q tests = 89 passed (was 42). horizontal-2 grid CSS strings (areas/cols/rows) byte-identical to legacy; only additive col-axis keys are introduced. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/phase_z2_pipeline.py | 392 ++++++++++++++++-- tests/phase_z2/__init__.py | 0 .../horizontal-2_default.yaml | 31 ++ .../horizontal-2_override.yaml | 41 ++ .../horizontal-2_weighted.yaml | 31 ++ .../build_layout_css/vertical-2_default.yaml | 31 ++ .../build_layout_css/vertical-2_override.yaml | 41 ++ .../build_layout_css/vertical-2_weighted.yaml | 31 ++ .../retry_gate/horizontal2_dynamic_rows.yaml | 24 ++ .../retry_gate/single_fr_default.yaml | 23 + .../retry_gate/vertical2_dynamic_cols.yaml | 24 ++ tests/phase_z2/test_build_layout_css_pr1.py | 158 +++++++ .../test_compute_per_zone_geometry.py | 101 +++++ .../phase_z2/test_compute_zone_layout_cols.py | 76 ++++ tests/phase_z2/test_fixtures_loader.py | 100 +++++ .../test_parse_css_areas_validation.py | 71 ++++ tests/phase_z2/test_parse_fr_string.py | 49 +++ tests/phase_z2/test_retry_gate.py | 129 ++++++ 18 files changed, 1318 insertions(+), 35 deletions(-) create mode 100644 tests/phase_z2/__init__.py create mode 100644 tests/phase_z2/fixtures/build_layout_css/horizontal-2_default.yaml create mode 100644 tests/phase_z2/fixtures/build_layout_css/horizontal-2_override.yaml create mode 100644 tests/phase_z2/fixtures/build_layout_css/horizontal-2_weighted.yaml create mode 100644 tests/phase_z2/fixtures/build_layout_css/vertical-2_default.yaml create mode 100644 tests/phase_z2/fixtures/build_layout_css/vertical-2_override.yaml create mode 100644 tests/phase_z2/fixtures/build_layout_css/vertical-2_weighted.yaml create mode 100644 tests/phase_z2/fixtures/retry_gate/horizontal2_dynamic_rows.yaml create mode 100644 tests/phase_z2/fixtures/retry_gate/single_fr_default.yaml create mode 100644 tests/phase_z2/fixtures/retry_gate/vertical2_dynamic_cols.yaml create mode 100644 tests/phase_z2/test_build_layout_css_pr1.py create mode 100644 tests/phase_z2/test_compute_per_zone_geometry.py create mode 100644 tests/phase_z2/test_compute_zone_layout_cols.py create mode 100644 tests/phase_z2/test_fixtures_loader.py create mode 100644 tests/phase_z2/test_parse_css_areas_validation.py create mode 100644 tests/phase_z2/test_parse_fr_string.py create mode 100644 tests/phase_z2/test_retry_gate.py diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 4748756..5bc031b 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -834,6 +834,285 @@ def compute_zone_layout(zones_data: list[dict], } +# ─── IMP-09 PR 1 helpers (8-preset layout vocabulary) ──────────────── +# Catalog css_areas / css_cols / css_rows parsing + per-zone aggregation +# + col-axis solver. Symmetric counterparts to compute_zone_layout (row-axis). + + +def _parse_css_areas(css_areas: str) -> tuple[list[list[str]], list[str]]: + """Parse CSS grid-template-areas string into (row x col) cell grid. + + Input : '"top top" "bottom-left bottom-right"' + Output : ( + [["top", "top"], ["bottom-left", "bottom-right"]], + ["top", "bottom-left", "bottom-right"], + ) + + Raises ValueError on empty input, missing quotes, empty row, or + non-rectangular layout (rows with mismatched column counts). + """ + rows: list[list[str]] = [] + seen: list[str] = [] + quoted = re.findall(r'"([^"]+)"', css_areas) + if not quoted: + raise ValueError( + f"_parse_css_areas: no quoted row strings found in {css_areas!r}" + ) + for row_str in quoted: + tokens = row_str.split() + if not tokens: + raise ValueError( + f"_parse_css_areas: empty row in {css_areas!r}" + ) + rows.append(tokens) + for token in tokens: + if token not in seen: + seen.append(token) + col_counts = {len(r) for r in rows} + if len(col_counts) > 1: + raise ValueError( + f"_parse_css_areas: non-rectangular grid, row column counts = " + f"{col_counts} in {css_areas!r}" + ) + return rows, seen + + +def _parse_fr_string(spec: str, total: int) -> list[int]: + """Parse '1fr' / '1fr 1fr' / 'Nfr Mfr' into integer px lengths. + + Catalog presets (templates/phase_z2/layouts/layouts.yaml) only use + 1fr-only specs; mixed px/fr is out of scope. Raises ValueError on + non-fr tokens or zero total. + """ + fractions: list[float] = [] + for token in spec.split(): + m = re.fullmatch(r"(\d+(?:\.\d+)?)fr", token) + if not m: + raise ValueError( + f"_parse_fr_string: non-fr token {token!r} in {spec!r}" + ) + fractions.append(float(m.group(1))) + if not fractions: + raise ValueError(f"_parse_fr_string: empty spec {spec!r}") + total_fr = sum(fractions) + if total_fr <= 0: + raise ValueError(f"_parse_fr_string: total fr = 0 in {spec!r}") + sizes = [int(round(total * (f / total_fr))) for f in fractions] + sizes[-1] += total - sum(sizes) + return sizes + + +def compute_zone_layout_cols(zones_data: list[dict], + total_width: int = SLIDE_BODY_WIDTH, + gap: int = GRID_GAP) -> dict: + """Per-zone column width allocation — weight-only distribution. + + Symmetric counterpart of compute_zone_layout for the column axis. + No min_width_px contract exists in frame_contracts.yaml (verified + empty as of IMP-09), so column allocation is purely content_weight + score based. + """ + n = len(zones_data) + if n == 0: + return {"widths_px": [], "width_ratios": [], "zones": []} + + available = total_width - gap * (n - 1) + weights = [z["content_weight"]["score"] for z in zones_data] + total_w = sum(weights) + + if total_w <= 0: + # Zero-weight guard (override-empty zone where score=0). + widths_px = [available // n] * n + widths_px[-1] += available - sum(widths_px) + weight_shares = [round(1.0 / n, 3)] * n + else: + widths_px = [ + int(round(available * (w / total_w))) for w in weights + ] + diff = available - sum(widths_px) + if diff != 0: + widths_px[-1] += diff + weight_shares = [round(w / total_w, 3) for w in weights] + + width_ratios = [round(w / total_width, 3) for w in widths_px] + + return { + "computation": "content_weight_distribution_cols", + "slide_body_width": total_width, + "gap": gap, + "available_after_gap": available, + "content_weights": [ + {"position": z["position"], + "template_id": z["template_id"], + "score": w} + for z, w in zip(zones_data, weights) + ], + "weight_shares": weight_shares, + "widths_px": widths_px, + "width_ratios": width_ratios, + } + + +def _compute_per_zone_geometry( + layout_css: dict, + debug_zones: list[dict], + gap: int = GRID_GAP, +) -> list[dict]: + """Aggregate grid-track sizes into per-zone dimensions for ALL layouts. + + Parses layout_css["areas"] (catalog css_areas) into an R x C cell + grid, then for each zone in debug_zones sums the heights_px of its + occupied rows and widths_px of its occupied columns, including the + inter-track gap absorbed by a spanning zone. + + Length contract: layout_css["heights_px"] MUST have length R, and + layout_css["widths_px"] MUST have length C. Mismatch raises + ValueError because that indicates a broken build path, not a + runtime input issue. + """ + rows_grid, _ = _parse_css_areas(layout_css["areas"]) + R = len(rows_grid) + C = len(rows_grid[0]) + heights_px = layout_css.get("heights_px") or [] + widths_px = layout_css.get("widths_px") or [] + + if len(heights_px) != R: + raise ValueError( + f"_compute_per_zone_geometry: heights_px length " + f"{len(heights_px)} != grid rows R={R} " + f"(css_areas={layout_css.get('areas')!r})" + ) + if len(widths_px) != C: + raise ValueError( + f"_compute_per_zone_geometry: widths_px length " + f"{len(widths_px)} != grid cols C={C} " + f"(css_areas={layout_css.get('areas')!r})" + ) + + per_zone: list[dict] = [] + for dz in debug_zones: + pos = dz["position"] + occupied_rows = sorted( + {r for r, row in enumerate(rows_grid) if pos in row} + ) + occupied_cols = sorted( + {c for r, row in enumerate(rows_grid) + for c, tok in enumerate(row) if tok == pos} + ) + if not occupied_rows or not occupied_cols: + raise ValueError( + f"_compute_per_zone_geometry: zone position {pos!r} " + f"not present in css_areas {rows_grid}" + ) + zh = ( + sum(heights_px[r] for r in occupied_rows) + + gap * (len(occupied_rows) - 1) + ) + zw = ( + sum(widths_px[c] for c in occupied_cols) + + gap * (len(occupied_cols) - 1) + ) + per_zone.append({ + "position": pos, + "zone_height_px": zh, + "zone_width_px": zw, + "zone_height_ratio": round(zh / SLIDE_BODY_HEIGHT, 3), + "zone_width_ratio": round(zw / SLIDE_BODY_WIDTH, 3), + }) + return per_zone + + +def _build_fr_default(preset: dict) -> dict: + """fr-default sink — populate widths_px / heights_px from catalog fr ratios. + + Replaces the legacy empty-array sink so that downstream consumers + (Step 7/8 trace, _compute_per_zone_geometry) always receive + length-locked arrays matching the catalog grid dimensions. + """ + rows_grid, _ = _parse_css_areas(preset["css_areas"]) + R = len(rows_grid) + C = len(rows_grid[0]) + + avail_h = SLIDE_BODY_HEIGHT - GRID_GAP * (R - 1) + avail_w = SLIDE_BODY_WIDTH - GRID_GAP * (C - 1) + + heights_px = _parse_fr_string(preset["css_rows"], avail_h) + widths_px = _parse_fr_string(preset["css_cols"], avail_w) + return { + "areas": preset["css_areas"], + "cols": preset["css_cols"], + "rows": preset["css_rows"], + "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": "fr_default_from_preset", + "dynamic_rows": False, + "dynamic_cols": False, + "raw_zone_layout": None, + } + + +def _build_rows_dynamic(preset: dict, zones_data: list[dict], + gap: int = GRID_GAP) -> dict: + """horizontal-2 path — dynamic row heights, static fr column widths. + + Preserves the legacy compute_zone_layout output (heights_px / ratios / + computation / raw_zone_layout) byte-for-byte; only adds the new + col-axis keys (widths_px from css_cols fr, width_ratios, dynamic_cols=False). + """ + rows_grid, _ = _parse_css_areas(preset["css_areas"]) + C = len(rows_grid[0]) + avail_w = SLIDE_BODY_WIDTH - gap * (C - 1) + widths_px = _parse_fr_string(preset["css_cols"], avail_w) + + zl = compute_zone_layout(zones_data, gap=gap) + rows_str = " ".join(f"{h}px" for h in zl["heights_px"]) + return { + "areas": preset["css_areas"], + "cols": preset["css_cols"], + "rows": rows_str, + "heights_px": zl["heights_px"], + "widths_px": widths_px, + "ratios": zl["ratios"], + "width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px], + "computation": zl["computation"], + "dynamic_rows": True, + "dynamic_cols": False, + "raw_zone_layout": zl, + } + + +def _build_cols_dynamic(preset: dict, zones_data: list[dict], + gap: int = GRID_GAP) -> dict: + """vertical-2 path — dynamic column widths, static fr row heights. + + Mirror of _build_rows_dynamic. Returns a pixel grid-template-columns + string. PR 2 promotes vertical-2 override to dynamic_rows=True; in + PR 1 dynamic_rows stays False (legacy). + """ + rows_grid, _ = _parse_css_areas(preset["css_areas"]) + R = len(rows_grid) + avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1) + heights_px = _parse_fr_string(preset["css_rows"], avail_h) + + zl = compute_zone_layout_cols(zones_data, gap=gap) + cols_str = " ".join(f"{w}px" for w in zl["widths_px"]) + return { + "areas": preset["css_areas"], + "cols": cols_str, + "rows": preset["css_rows"], + "heights_px": heights_px, + "widths_px": zl["widths_px"], + "ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px], + "width_ratios": zl["width_ratios"], + "computation": zl["computation"], + "dynamic_rows": False, + "dynamic_cols": True, + "raw_zone_layout": zl, + } + + # Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용. # 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29). @@ -843,15 +1122,28 @@ def build_layout_css(layout_preset: str, zones_data: list[dict], override_zone_geometries: Optional[dict[str, dict]] = None) -> dict: """Composition v0 layout preset → CSS grid 문자열. - horizontal-2 (= old type-b, 2-zone vertical stack) 만 dynamic heights 유지 - (MDX 03 회귀 보존 — content_weight 기반). 다른 preset 은 fr default. + IMP-09 PR 1 contract — every layout_css return path carries + matching-length heights_px (= grid rows R) and widths_px (= grid cols C), + plus ratios / width_ratios / dynamic_rows / dynamic_cols. The + horizontal-2 grid CSS strings (areas/cols/rows) remain byte-identical + to the legacy path. - Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id → {x,y,w,h} - slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제. horizontal-2 / vertical-2 - 만 처리. 다른 preset 은 일단 무시 + warning. 비율 합 != 1 이면 normalize. + Dynamic dispatch: + - topology="rows" -> _build_rows_dynamic (horizontal-2: row heights) + - topology="cols" -> _build_cols_dynamic (vertical-2: col widths) + - other topologies (single / T / inverted-T / side-T / 2x2) fall + through to _build_fr_default in PR 1; PR 2 enables the 2-D + dispatcher. + + Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id -> + {x,y,w,h} slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제. + PR 1 lock: horizontal-2 / vertical-2 만 처리 (legacy inline preserve). + 다른 preset 은 warn-and-fallthrough (PR 2 가 unified _override_to_grid_tracks + 로 promote). """ preset = LAYOUT_PRESETS[layout_preset] positions = preset["positions"] + topology = preset.get("topology") # ── Step D-ext : user override 처리 ── if override_zone_geometries: @@ -870,13 +1162,18 @@ def build_layout_css(layout_preset: str, zones_data: list[dict], "cols": preset["css_cols"], "rows": rows, "heights_px": heights_px, + "widths_px": [SLIDE_BODY_WIDTH], "ratios": [round(r / total, 3) for r in ratios], + "width_ratios": [1.0], "computation": "user_override_geometry", "dynamic_rows": True, + "dynamic_cols": False, "raw_zone_layout": {"override_applied": True, "source": override_zone_geometries}, } elif layout_preset == "vertical-2": - # cols override — zone 의 w 비율로 fr 분배. + # cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols). + # PR 1 keeps fr-string cols for legacy preserve; widths_px is + # populated in pixels for _compute_per_zone_geometry length contract. ratios = [] for pos in positions: geom = override_zone_geometries.get(pos) @@ -884,47 +1181,44 @@ def build_layout_css(layout_preset: str, zones_data: list[dict], total = sum(ratios) if total > 0: cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios) + normalized = [r / total for r in ratios] + widths_px = [ + int(round(rr * (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)))) + for rr in normalized + ] + diff = (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)) - sum(widths_px) + if diff != 0 and widths_px: + widths_px[-1] += diff return { "areas": preset["css_areas"], "cols": cols, "rows": preset["css_rows"], - "heights_px": [], - "ratios": [round(r / total, 3) for r in ratios], + "heights_px": [SLIDE_BODY_HEIGHT], + "widths_px": widths_px, + "ratios": [1.0], + "width_ratios": [round(rr, 3) for rr in normalized], "computation": "user_override_geometry", "dynamic_rows": False, + "dynamic_cols": True, "raw_zone_layout": {"override_applied": True, "source": override_zone_geometries}, } else: + # PR 1 lock — warn-and-fallthrough preserved. + # PR 2 promotes this to strict ValueError via _override_to_grid_tracks. print( f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 " f"(현재 horizontal-2 / vertical-2 만). default layout_css 사용.", file=sys.stderr, ) - if layout_preset == "horizontal-2": - zl = compute_zone_layout(zones_data, gap=gap) - rows = " ".join(f"{h}px" for h in zl["heights_px"]) - return { - "areas": preset["css_areas"], - "cols": preset["css_cols"], - "rows": rows, - "heights_px": zl["heights_px"], - "ratios": zl["ratios"], - "computation": zl["computation"], - "dynamic_rows": True, - "raw_zone_layout": zl, - } - - return { - "areas": preset["css_areas"], - "cols": preset["css_cols"], - "rows": preset["css_rows"], - "heights_px": [], - "ratios": [], - "computation": "fr_default_from_preset", - "dynamic_rows": False, - "raw_zone_layout": None, - } + # ── Dynamic branch — topology dispatch (PR 1: rows / cols only) ── + if topology == "rows": + return _build_rows_dynamic(preset, zones_data, gap) + if topology == "cols": + return _build_cols_dynamic(preset, zones_data, gap) + # PR 2 will dispatch T / inverted-T / side-T-{left,right} / 2x2 here. + # PR 3 will dispatch single here. + return _build_fr_default(preset) # ─── Abort ────────────────────────────────────────────────────── @@ -1321,6 +1615,21 @@ def _attempt_zone_ratio_retry( ) return base_trace + # IMP-09 PR 1 retry gate — row-axis retry is only valid for layouts whose + # row geometry is dynamic. 2-D / dynamic_cols layouts and fr_default sinks + # would either misapply row-only redistribution or produce a no-op trace. + if layout_css.get("dynamic_cols", False): + base_trace["retry_skipped_reason"] = ( + "layout has dynamic_cols (2-D topology) — " + "row-axis retry not applicable to 2-D layouts (IMP-09 lock)" + ) + return base_trace + if not layout_css.get("dynamic_rows", False): + base_trace["retry_skipped_reason"] = ( + "layout is fr_default_from_preset (no dynamic geometry) — retry no-op" + ) + return base_trace + # 2. plan base_trace["retry_attempted"] = True base_trace["retry_action"] = "zone_ratio_retry" @@ -3064,11 +3373,20 @@ def run_phase_z2_mvp1( layout_css = build_layout_css( layout_preset, zones_data, override_zone_geometries=override_zone_geometries ) + # IMP-09 PR 1 — unified per-zone geometry aggregation across all + # layouts. Spanning zones in 2-D layouts (T / 2x2 from PR 2 onward) + # are handled by _compute_per_zone_geometry; in PR 1 the helper + # operates on row/col-static or row-dynamic / col-dynamic outputs. + per_zone_geo = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP) + for dz, geo in zip(debug_zones, per_zone_geo): + dz["height_px"] = geo["zone_height_px"] + dz["ratio"] = geo["zone_height_ratio"] + dz["width_px"] = geo["zone_width_px"] + dz["width_ratio"] = geo["zone_width_ratio"] if layout_css["dynamic_rows"]: - for dz, h, r in zip(debug_zones, layout_css["heights_px"], layout_css["ratios"]): - dz["height_px"] = h - dz["ratio"] = r print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}") + elif layout_css.get("dynamic_cols"): + print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}") else: print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})") @@ -3229,6 +3547,8 @@ def run_phase_z2_mvp1( "position": dz["position"], "zone_height_px_planned": dz.get("height_px"), "zone_ratio_planned": dz.get("ratio"), + "zone_width_px_planned": dz.get("width_px"), + "zone_col_ratio_planned": dz.get("width_ratio"), "min_height_px": visual_hints.get("min_height_px"), "frame_cardinality_strict": cardinality.get("strict"), "sub_zones_planned": [ @@ -3253,7 +3573,9 @@ def run_phase_z2_mvp1( run_dir, 8, "zone_region_ratios", data={ "zone_heights_px_planned": layout_css.get("heights_px"), + "zone_widths_px_planned": layout_css.get("widths_px"), "zone_ratios_planned": layout_css.get("ratios"), + "zone_col_ratios_planned": layout_css.get("width_ratios"), "per_zone_plan": zone_region_plans, # Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인) "step8_conn_placeholder_signals": _step8_placeholder_signals, diff --git a/tests/phase_z2/__init__.py b/tests/phase_z2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/phase_z2/fixtures/build_layout_css/horizontal-2_default.yaml b/tests/phase_z2/fixtures/build_layout_css/horizontal-2_default.yaml new file mode 100644 index 0000000..9de0a2a --- /dev/null +++ b/tests/phase_z2/fixtures/build_layout_css/horizontal-2_default.yaml @@ -0,0 +1,31 @@ +input: + layout_preset: horizontal-2 + zones_data: + - position: top + template_id: MOCK_top + content_weight: + score: 0.5 + 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" "bottom"' + cols: 1fr + rows: 286px 285px + heights_px: + - 286 + - 285 + widths_px: + - 1180 + ratios: + - 0.489 + - 0.487 + width_ratios: + - 1.0 + computation: min_height_first + content_weight_distribution + dynamic_rows: true + dynamic_cols: false diff --git a/tests/phase_z2/fixtures/build_layout_css/horizontal-2_override.yaml b/tests/phase_z2/fixtures/build_layout_css/horizontal-2_override.yaml new file mode 100644 index 0000000..f4814f6 --- /dev/null +++ b/tests/phase_z2/fixtures/build_layout_css/horizontal-2_override.yaml @@ -0,0 +1,41 @@ +input: + layout_preset: horizontal-2 + zones_data: + - position: top + template_id: MOCK_top + content_weight: + score: 0.5 + min_height_px: 200 + - position: bottom + template_id: MOCK_bottom + content_weight: + score: 0.5 + min_height_px: 200 + override_zone_geometries: + top: + x: 0 + y: 0 + w: 1.0 + h: 0.3 + bottom: + x: 0 + y: 0.3 + w: 1.0 + h: 0.7 +expected_layout_css: + areas: '"top" "bottom"' + cols: 1fr + rows: 176px 410px + heights_px: + - 176 + - 410 + widths_px: + - 1180 + ratios: + - 0.3 + - 0.7 + width_ratios: + - 1.0 + computation: user_override_geometry + dynamic_rows: true + dynamic_cols: false diff --git a/tests/phase_z2/fixtures/build_layout_css/horizontal-2_weighted.yaml b/tests/phase_z2/fixtures/build_layout_css/horizontal-2_weighted.yaml new file mode 100644 index 0000000..0d4cfff --- /dev/null +++ b/tests/phase_z2/fixtures/build_layout_css/horizontal-2_weighted.yaml @@ -0,0 +1,31 @@ +input: + layout_preset: horizontal-2 + zones_data: + - position: top + template_id: MOCK_top + content_weight: + score: 0.8 + min_height_px: 200 + - position: bottom + template_id: MOCK_bottom + content_weight: + score: 0.2 + min_height_px: 150 + override_zone_geometries: null +expected_layout_css: + areas: '"top" "bottom"' + cols: 1fr + rows: 377px 194px + heights_px: + - 377 + - 194 + widths_px: + - 1180 + ratios: + - 0.644 + - 0.332 + width_ratios: + - 1.0 + computation: min_height_first + content_weight_distribution + dynamic_rows: true + dynamic_cols: false diff --git a/tests/phase_z2/fixtures/build_layout_css/vertical-2_default.yaml b/tests/phase_z2/fixtures/build_layout_css/vertical-2_default.yaml new file mode 100644 index 0000000..770d552 --- /dev/null +++ b/tests/phase_z2/fixtures/build_layout_css/vertical-2_default.yaml @@ -0,0 +1,31 @@ +input: + layout_preset: vertical-2 + zones_data: + - position: left + template_id: MOCK_left + content_weight: + score: 0.5 + 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 right"' + cols: 583px 583px + rows: 1fr + heights_px: + - 585 + widths_px: + - 583 + - 583 + ratios: + - 1.0 + width_ratios: + - 0.494 + - 0.494 + computation: content_weight_distribution_cols + dynamic_rows: false + dynamic_cols: true diff --git a/tests/phase_z2/fixtures/build_layout_css/vertical-2_override.yaml b/tests/phase_z2/fixtures/build_layout_css/vertical-2_override.yaml new file mode 100644 index 0000000..aa1f999 --- /dev/null +++ b/tests/phase_z2/fixtures/build_layout_css/vertical-2_override.yaml @@ -0,0 +1,41 @@ +input: + layout_preset: vertical-2 + zones_data: + - position: left + template_id: MOCK_left + content_weight: + score: 0.5 + min_height_px: 200 + - position: right + template_id: MOCK_right + content_weight: + score: 0.5 + min_height_px: 200 + override_zone_geometries: + left: + x: 0 + y: 0 + w: 0.4 + h: 1.0 + right: + x: 0.4 + y: 0 + w: 0.6 + h: 1.0 +expected_layout_css: + areas: '"left right"' + cols: 40.0fr 60.0fr + rows: 1fr + heights_px: + - 585 + widths_px: + - 466 + - 700 + ratios: + - 1.0 + width_ratios: + - 0.4 + - 0.6 + computation: user_override_geometry + dynamic_rows: false + dynamic_cols: true diff --git a/tests/phase_z2/fixtures/build_layout_css/vertical-2_weighted.yaml b/tests/phase_z2/fixtures/build_layout_css/vertical-2_weighted.yaml new file mode 100644 index 0000000..a146315 --- /dev/null +++ b/tests/phase_z2/fixtures/build_layout_css/vertical-2_weighted.yaml @@ -0,0 +1,31 @@ +input: + layout_preset: vertical-2 + zones_data: + - position: left + template_id: MOCK_left + content_weight: + score: 0.7 + min_height_px: 200 + - position: right + template_id: MOCK_right + content_weight: + score: 0.3 + min_height_px: 200 + override_zone_geometries: null +expected_layout_css: + areas: '"left right"' + cols: 816px 350px + rows: 1fr + heights_px: + - 585 + widths_px: + - 816 + - 350 + ratios: + - 1.0 + width_ratios: + - 0.692 + - 0.297 + computation: content_weight_distribution_cols + dynamic_rows: false + dynamic_cols: true diff --git a/tests/phase_z2/fixtures/retry_gate/horizontal2_dynamic_rows.yaml b/tests/phase_z2/fixtures/retry_gate/horizontal2_dynamic_rows.yaml new file mode 100644 index 0000000..5f51f38 --- /dev/null +++ b/tests/phase_z2/fixtures/retry_gate/horizontal2_dynamic_rows.yaml @@ -0,0 +1,24 @@ +case_id: horizontal2_dynamic_rows +description: | + horizontal-2 layout with dynamic_rows=True must pass the IMP-09 retry + gate. The base trace should record retry_attempted=True (legacy + plan/rerender path continues). retry_skipped_reason MUST NOT contain + either of the IMP-09 gate skip strings. +input_layout_css: + areas: '"top" "bottom"' + cols: 1fr + rows: 333px 238px + heights_px: [333, 238] + widths_px: [1180] + ratios: [0.569, 0.407] + width_ratios: [1.0] + dynamic_rows: true + dynamic_cols: false +router_decision: + router_active: true + proposed_actions_summary: [zone_ratio_retry] +expected_gate: + retry_attempted: true + retry_skipped_reason_excludes: + - "dynamic_cols" + - "fr_default_from_preset" diff --git a/tests/phase_z2/fixtures/retry_gate/single_fr_default.yaml b/tests/phase_z2/fixtures/retry_gate/single_fr_default.yaml new file mode 100644 index 0000000..bcf838c --- /dev/null +++ b/tests/phase_z2/fixtures/retry_gate/single_fr_default.yaml @@ -0,0 +1,23 @@ +case_id: single_fr_default +description: | + Any layout that fell through to fr_default_from_preset (single, + T-shape, 2x2 in PR 1) has neither dynamic_rows nor dynamic_cols. + Row-axis retry is a no-op and must be skipped by the IMP-09 gate + with a fr_default_from_preset skip reason. +input_layout_css: + areas: '"top top" "bottom-left bottom-right"' + cols: 1fr 1fr + rows: 1fr 1fr + heights_px: [285, 286] + widths_px: [583, 583] + ratios: [0.487, 0.489] + width_ratios: [0.494, 0.494] + dynamic_rows: false + dynamic_cols: false +router_decision: + router_active: true + proposed_actions_summary: [zone_ratio_retry] +expected_gate: + retry_attempted: false + retry_skipped_reason_contains: + - "fr_default_from_preset" diff --git a/tests/phase_z2/fixtures/retry_gate/vertical2_dynamic_cols.yaml b/tests/phase_z2/fixtures/retry_gate/vertical2_dynamic_cols.yaml new file mode 100644 index 0000000..383736f --- /dev/null +++ b/tests/phase_z2/fixtures/retry_gate/vertical2_dynamic_cols.yaml @@ -0,0 +1,24 @@ +case_id: vertical2_dynamic_cols +description: | + vertical-2 layout with dynamic_cols=True must be skipped by the + IMP-09 retry gate before plan/rerender, because the existing + apply_retry_to_layout_css mutates only row-axis fields and would + produce a misleading trace if it ran on a column-dynamic layout. +input_layout_css: + areas: '"left right"' + cols: 583px 583px + rows: 1fr + heights_px: [585] + widths_px: [583, 583] + ratios: [1.0] + width_ratios: [0.494, 0.494] + dynamic_rows: false + 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" + - "IMP-09" diff --git a/tests/phase_z2/test_build_layout_css_pr1.py b/tests/phase_z2/test_build_layout_css_pr1.py new file mode 100644 index 0000000..079d110 --- /dev/null +++ b/tests/phase_z2/test_build_layout_css_pr1.py @@ -0,0 +1,158 @@ +"""IMP-09 PR 1 — build_layout_css contract tests. + +Verifies horizontal-2 byte-identity for the legacy grid strings +(areas / cols / rows) and that every return path now carries the new +length-locked col-axis keys (widths_px / width_ratios / dynamic_cols). +""" +from __future__ import annotations + +import pytest + +from src.phase_z2_pipeline import ( + GRID_GAP, + SLIDE_BODY_HEIGHT, + SLIDE_BODY_WIDTH, + build_layout_css, +) + + +def _zone(position: str, score: float, min_h: int = 100) -> dict: + return { + "position": position, + "template_id": f"MOCK_{position}", + "content_weight": {"score": score}, + "min_height_px": min_h, + } + + +# ────────────────────── new-key contract ────────────────────── + + +NEW_KEYS = {"widths_px", "width_ratios", "dynamic_cols"} + + +def test_all_presets_carry_new_col_axis_keys(): + """Every PR 1 return path must include widths_px / width_ratios / + dynamic_cols, and heights_px / widths_px must be length-locked to + the catalog grid (R rows, C cols).""" + cases = [ + ("single", [_zone("primary", 1.0)]), + ("horizontal-2", [_zone("top", 0.6), _zone("bottom", 0.4)]), + ("vertical-2", [_zone("left", 0.5), _zone("right", 0.5)]), + ("top-1-bottom-2", [ + _zone("top", 0.5), + _zone("bottom-left", 0.25), + _zone("bottom-right", 0.25), + ]), + ("grid-2x2", [ + _zone("top-left", 0.25), + _zone("top-right", 0.25), + _zone("bottom-left", 0.25), + _zone("bottom-right", 0.25), + ]), + ] + for preset, zones in cases: + result = build_layout_css(preset, zones) + missing = NEW_KEYS - set(result) + assert not missing, f"{preset} missing new keys: {missing}" + # heights_px / widths_px never empty in PR 1 (length-locked). + assert len(result["heights_px"]) > 0, f"{preset} empty heights_px" + assert len(result["widths_px"]) > 0, f"{preset} empty widths_px" + + +# ────────────────────── horizontal-2 byte-identity ────────────────────── + + +def test_horizontal_2_grid_strings_match_legacy(): + zones = [_zone("top", 0.6), _zone("bottom", 0.4)] + result = build_layout_css("horizontal-2", zones) + + # Legacy contract: areas / cols / rows strings preserved. + assert result["areas"] == '"top" "bottom"' + assert result["cols"] == "1fr" + assert result["rows"].count("px") == 2 + + # heights_px sum to body height; ratios consistent. + assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP + assert result["dynamic_rows"] is True + assert result["dynamic_cols"] is False + + # New col-axis defaults: full body width, ratio 1.0. + assert result["widths_px"] == [SLIDE_BODY_WIDTH] + assert result["width_ratios"] == [1.0] + + +def test_horizontal_2_override_preserves_rows(): + zones = [_zone("top", 0.6), _zone("bottom", 0.4)] + override = { + "top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3}, + "bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7}, + } + result = build_layout_css( + "horizontal-2", zones, override_zone_geometries=override + ) + assert result["computation"] == "user_override_geometry" + assert result["dynamic_rows"] is True + assert result["dynamic_cols"] is False + assert result["heights_px"][0] < result["heights_px"][1] + assert result["widths_px"] == [SLIDE_BODY_WIDTH] + # Override ratio target. + assert result["ratios"] == [0.3, 0.7] + + +# ────────────────────── vertical-2 new dynamic ────────────────────── + + +def test_vertical_2_normal_produces_dynamic_cols(): + zones = [_zone("left", 0.7), _zone("right", 0.3)] + result = build_layout_css("vertical-2", zones) + assert result["dynamic_rows"] is False + assert result["dynamic_cols"] is True + # cols string is px-based (no fr). + assert "fr" not in result["cols"] + assert result["cols"].count("px") == 2 + # Heights span full body in a single row. + assert result["heights_px"] == [SLIDE_BODY_HEIGHT] + # Widths reflect 70/30 weight split. + assert result["widths_px"][0] > result["widths_px"][1] + + +def test_vertical_2_override_keeps_fr_cols_legacy(): + """PR 1 v-2 override path keeps legacy fr-string cols but now + populates widths_px in pixels for downstream consumers.""" + zones = [_zone("left", 0.5), _zone("right", 0.5)] + override = { + "left": {"x": 0, "y": 0, "w": 0.4, "h": 1.0}, + "right": {"x": 0.4, "y": 0, "w": 0.6, "h": 1.0}, + } + result = build_layout_css( + "vertical-2", zones, override_zone_geometries=override + ) + assert result["computation"] == "user_override_geometry" + assert "fr" in result["cols"] + assert result["dynamic_cols"] is True + assert result["dynamic_rows"] is False + # widths_px now populated. + assert len(result["widths_px"]) == 2 + assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP + assert result["width_ratios"] == [0.4, 0.6] + + +# ────────────────────── fr_default sink (PR 1) ────────────────────── + + +def test_top_1_bottom_2_fr_default_populates_geometry(): + """T-shape (top-1-bottom-2) falls through to fr_default in PR 1 + but heights_px / widths_px must be populated (length-locked to + grid R=2, C=2).""" + zones = [ + _zone("top", 0.5), + _zone("bottom-left", 0.25), + _zone("bottom-right", 0.25), + ] + result = build_layout_css("top-1-bottom-2", 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"]) == 2 # R rows + assert len(result["widths_px"]) == 2 # C cols diff --git a/tests/phase_z2/test_compute_per_zone_geometry.py b/tests/phase_z2/test_compute_per_zone_geometry.py new file mode 100644 index 0000000..f14709d --- /dev/null +++ b/tests/phase_z2/test_compute_per_zone_geometry.py @@ -0,0 +1,101 @@ +"""IMP-09 PR 1 — _compute_per_zone_geometry tests (1-D paths). + +Verifies the unified per-zone geometry aggregator on horizontal-2 and +vertical-2 (the two 1-D presets active in PR 1). 2-D spanning zone +cases (T / 2x2) are exercised in PR 2. + +The helper aggregates grid-track sizes into per-zone dimensions and +must produce length-locked outputs: + - layout_css["heights_px"] length == R (parsed css_areas rows) + - layout_css["widths_px"] length == C (parsed css_areas cols) +""" +from __future__ import annotations + +import pytest + +from src.phase_z2_pipeline import ( + GRID_GAP, + SLIDE_BODY_HEIGHT, + SLIDE_BODY_WIDTH, + _compute_per_zone_geometry, + build_layout_css, +) + + +def _zone(position: str, score: float) -> dict: + return { + "position": position, + "template_id": f"MOCK_{position}", + "content_weight": {"score": score}, + "min_height_px": 100, + } + + +def test_horizontal_2_per_zone_widths_match_slide_body(): + zones = [_zone("top", 0.6), _zone("bottom", 0.4)] + layout_css = build_layout_css("horizontal-2", zones) + debug_zones = [{"position": "top"}, {"position": "bottom"}] + per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP) + + # Both zones share the single column => width == SLIDE_BODY_WIDTH. + assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH + assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH + # Heights mirror layout_css.heights_px. + assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0] + assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1] + + +def test_vertical_2_per_zone_heights_match_slide_body(): + zones = [_zone("left", 0.5), _zone("right", 0.5)] + layout_css = build_layout_css("vertical-2", zones) + debug_zones = [{"position": "left"}, {"position": "right"}] + per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP) + + # Both zones share the single row => height == SLIDE_BODY_HEIGHT. + assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT + assert per_zone[1]["zone_height_px"] == SLIDE_BODY_HEIGHT + # Widths mirror layout_css.widths_px. + assert per_zone[0]["zone_width_px"] == layout_css["widths_px"][0] + assert per_zone[1]["zone_width_px"] == layout_css["widths_px"][1] + + +def test_heights_px_length_mismatch_raises(): + layout_css = { + "areas": '"top" "bottom"', + "heights_px": [300], # wrong length, expected 2 + "widths_px": [SLIDE_BODY_WIDTH], + } + with pytest.raises(ValueError, match="heights_px length"): + _compute_per_zone_geometry( + layout_css, [{"position": "top"}], GRID_GAP + ) + + +def test_widths_px_length_mismatch_raises(): + layout_css = { + "areas": '"left right"', + "heights_px": [SLIDE_BODY_HEIGHT], + "widths_px": [600], # wrong length, expected 2 + } + with pytest.raises(ValueError, match="widths_px length"): + _compute_per_zone_geometry( + layout_css, [{"position": "left"}], GRID_GAP + ) + + +def test_unknown_position_raises(): + zones = [_zone("top", 0.5), _zone("bottom", 0.5)] + layout_css = build_layout_css("horizontal-2", zones) + debug_zones = [{"position": "ghost"}] + with pytest.raises(ValueError, match="not present in css_areas"): + _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP) + + +def test_fr_default_single_returns_full_body(): + # 'single' is the fr_default sink in PR 1; widths_px / heights_px + # must still be populated (length 1 each). + layout_css = build_layout_css("single", [_zone("primary", 1.0)]) + debug_zones = [{"position": "primary"}] + per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP) + assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT + assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH diff --git a/tests/phase_z2/test_compute_zone_layout_cols.py b/tests/phase_z2/test_compute_zone_layout_cols.py new file mode 100644 index 0000000..d487b5b --- /dev/null +++ b/tests/phase_z2/test_compute_zone_layout_cols.py @@ -0,0 +1,76 @@ +"""IMP-09 PR 1 — compute_zone_layout_cols tests. + +Column-axis weight-only solver. Mirrors compute_zone_layout for rows. +No min_width_px contract exists in frame_contracts.yaml (verified +during Stage 2), so column distribution is purely content_weight. +""" +from __future__ import annotations + +from src.phase_z2_pipeline import ( + GRID_GAP, + SLIDE_BODY_WIDTH, + compute_zone_layout_cols, +) + + +def _zone(position: str, score: float) -> dict: + return { + "position": position, + "template_id": f"MOCK_{position}", + "content_weight": {"score": score}, + } + + +def test_empty_zones_returns_empty_result(): + result = compute_zone_layout_cols([]) + assert result["widths_px"] == [] + assert result["width_ratios"] == [] + + +def test_two_equal_zones_split_evenly(): + zones = [_zone("left", 0.5), _zone("right", 0.5)] + result = compute_zone_layout_cols(zones) + available = SLIDE_BODY_WIDTH - GRID_GAP # one gap between two zones + assert sum(result["widths_px"]) == available + assert result["widths_px"][0] == result["widths_px"][1] + assert result["computation"] == "content_weight_distribution_cols" + + +def test_asymmetric_weights_distribute_by_ratio(): + zones = [_zone("left", 0.8), _zone("right", 0.2)] + result = compute_zone_layout_cols(zones) + available = SLIDE_BODY_WIDTH - GRID_GAP + assert sum(result["widths_px"]) == available + # left should be ~4x right + assert result["widths_px"][0] > result["widths_px"][1] * 3 + + +def test_zero_weight_guard_equal_split(): + zones = [_zone("left", 0.0), _zone("right", 0.0)] + result = compute_zone_layout_cols(zones) + available = SLIDE_BODY_WIDTH - GRID_GAP + assert sum(result["widths_px"]) == available + assert result["widths_px"][0] == result["widths_px"][1] + # weight_shares fallback to equal share. + assert result["weight_shares"] == [0.5, 0.5] + + +def test_integer_rounding_absorbed_by_last_zone(): + # Three zones with weights that don't divide evenly. + zones = [ + _zone("a", 0.333333), + _zone("b", 0.333333), + _zone("c", 0.333334), + ] + result = compute_zone_layout_cols(zones) + available = SLIDE_BODY_WIDTH - 2 * GRID_GAP + assert sum(result["widths_px"]) == available + + +def test_width_ratios_match_total_width(): + zones = [_zone("left", 0.6), _zone("right", 0.4)] + result = compute_zone_layout_cols(zones) + # width_ratios should be widths_px / SLIDE_BODY_WIDTH (not / available) + assert abs( + result["width_ratios"][0] - result["widths_px"][0] / SLIDE_BODY_WIDTH + ) < 1e-3 diff --git a/tests/phase_z2/test_fixtures_loader.py b/tests/phase_z2/test_fixtures_loader.py new file mode 100644 index 0000000..1fccbd7 --- /dev/null +++ b/tests/phase_z2/test_fixtures_loader.py @@ -0,0 +1,100 @@ +"""IMP-09 PR 1 — fixture-driven regression checks. + +Loads the YAML snapshots under tests/phase_z2/fixtures/ and exercises +build_layout_css + _attempt_zone_ratio_retry against them. Any drift +in IMP-09 output forces a fixture refresh, which is the lock surface +called out in Stage 3 round 4 §5. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from src.phase_z2_pipeline import _attempt_zone_ratio_retry, build_layout_css + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +def _load_yaml(path: Path) -> dict: + with path.open(encoding="utf-8") as f: + return yaml.safe_load(f) + + +# ──────────────────────── build_layout_css fixtures ──────────────────────── + + +_BUILD_DIR = FIXTURES_DIR / "build_layout_css" +_BUILD_FIXTURES = sorted(_BUILD_DIR.glob("*.yaml")) if _BUILD_DIR.exists() else [] + + +@pytest.mark.parametrize( + "fixture_path", + _BUILD_FIXTURES, + ids=[p.stem for p in _BUILD_FIXTURES], +) +def test_build_layout_css_matches_fixture(fixture_path: Path): + payload = _load_yaml(fixture_path) + inp = payload["input"] + expected = payload["expected_layout_css"] + + result = build_layout_css( + inp["layout_preset"], + inp["zones_data"], + override_zone_geometries=inp.get("override_zone_geometries"), + ) + # raw_zone_layout is intentionally not snapshotted (contains + # solver internals); compare the rest. + actual = {k: v for k, v in result.items() if k != "raw_zone_layout"} + assert actual == expected, ( + f"layout_css drift in fixture {fixture_path.name}:\n" + f" expected={expected}\n actual={actual}" + ) + + +# ────────────────────────── retry_gate fixtures ────────────────────────── + + +_RETRY_DIR = FIXTURES_DIR / "retry_gate" +_RETRY_FIXTURES = sorted(_RETRY_DIR.glob("*.yaml")) if _RETRY_DIR.exists() else [] + + +@pytest.mark.parametrize( + "fixture_path", + _RETRY_FIXTURES, + ids=[p.stem for p in _RETRY_FIXTURES], +) +def test_retry_gate_matches_fixture(fixture_path: Path, tmp_path: Path): + payload = _load_yaml(fixture_path) + layout_css = payload["input_layout_css"] + router_decision = payload["router_decision"] + expected = payload["expected_gate"] + + trace = _attempt_zone_ratio_retry( + run_dir=tmp_path, + out_path=tmp_path / "final.html", + slide_title="fixture", + slide_footer=None, + zones_data=[], + debug_zones=[], + layout_preset="fixture", + layout_css=layout_css, + overflow={}, + fit_classification={}, + router_decision=router_decision, + gap_px=14, + ) + + assert trace["retry_attempted"] == expected["retry_attempted"] + skip_reason = trace.get("retry_skipped_reason") + for needle in expected.get("retry_skipped_reason_contains", []): + assert skip_reason is not None and needle in skip_reason, ( + f"expected {needle!r} in retry_skipped_reason, got {skip_reason!r}" + ) + for forbidden in expected.get("retry_skipped_reason_excludes", []): + if skip_reason is not None: + assert forbidden not in skip_reason, ( + f"forbidden {forbidden!r} found in retry_skipped_reason {skip_reason!r}" + ) diff --git a/tests/phase_z2/test_parse_css_areas_validation.py b/tests/phase_z2/test_parse_css_areas_validation.py new file mode 100644 index 0000000..60e7632 --- /dev/null +++ b/tests/phase_z2/test_parse_css_areas_validation.py @@ -0,0 +1,71 @@ +"""IMP-09 PR 1 — _parse_css_areas strict validation tests. + +Covers the four ValueError cases declared in the Stage 3 round 4 lock +(plan §2-D): empty input, no quoted rows, empty row tokens, and +non-rectangular grids. Also exercises positive parsing on all 8 +catalog presets so any future catalog drift in row/col counts is +caught here. +""" +from __future__ import annotations + +import pytest + +from src.phase_z2_pipeline import _parse_css_areas + + +def test_parse_empty_string_raises(): + with pytest.raises(ValueError, match="no quoted row strings"): + _parse_css_areas("") + + +def test_parse_no_quotes_raises(): + with pytest.raises(ValueError, match="no quoted row strings"): + _parse_css_areas("top top bottom-left bottom-right") + + +def test_parse_empty_row_raises(): + # Whitespace-only quoted row -> tokens list is empty. + with pytest.raises(ValueError, match="empty row"): + _parse_css_areas('" "') + + +def test_parse_non_rectangular_raises(): + # First row has 1 token, second row has 2 tokens. + with pytest.raises(ValueError, match="non-rectangular"): + _parse_css_areas('"top" "bottom-left bottom-right"') + + +def test_parse_single_zone(): + rows, seen = _parse_css_areas('"primary"') + assert rows == [["primary"]] + assert seen == ["primary"] + + +def test_parse_horizontal_2(): + rows, seen = _parse_css_areas('"top" "bottom"') + assert rows == [["top"], ["bottom"]] + assert seen == ["top", "bottom"] + + +def test_parse_vertical_2(): + rows, seen = _parse_css_areas('"left right"') + assert rows == [["left", "right"]] + assert seen == ["left", "right"] + + +def test_parse_top_1_bottom_2_span(): + rows, seen = _parse_css_areas('"top top" "bottom-left bottom-right"') + assert rows == [["top", "top"], ["bottom-left", "bottom-right"]] + # 'top' should appear once in seen even though it occupies two cells. + assert seen == ["top", "bottom-left", "bottom-right"] + + +def test_parse_grid_2x2_four_zones(): + rows, seen = _parse_css_areas( + '"top-left top-right" "bottom-left bottom-right"' + ) + assert rows == [ + ["top-left", "top-right"], + ["bottom-left", "bottom-right"], + ] + assert seen == ["top-left", "top-right", "bottom-left", "bottom-right"] diff --git a/tests/phase_z2/test_parse_fr_string.py b/tests/phase_z2/test_parse_fr_string.py new file mode 100644 index 0000000..30fbae5 --- /dev/null +++ b/tests/phase_z2/test_parse_fr_string.py @@ -0,0 +1,49 @@ +"""IMP-09 PR 1 — _parse_fr_string tests. + +Catalog presets only use `1fr` / `1fr 1fr` specs (verified +templates/phase_z2/layouts/layouts.yaml). The helper must reject +non-fr tokens and round to integer pixel sizes summing to `total`. +""" +from __future__ import annotations + +import pytest + +from src.phase_z2_pipeline import _parse_fr_string + + +def test_single_fr_returns_full_total(): + assert _parse_fr_string("1fr", 585) == [585] + + +def test_two_equal_fr_splits_evenly(): + result = _parse_fr_string("1fr 1fr", 1180) + assert result == [590, 590] + assert sum(result) == 1180 + + +def test_unequal_fr_distributes_by_ratio(): + result = _parse_fr_string("2fr 1fr", 300) + assert sum(result) == 300 + assert result[0] > result[1] + + +def test_rounding_absorbed_by_last_track(): + # 1fr 1fr 1fr / total=100 -> 33,33,33 + diff 1 absorbed by last. + result = _parse_fr_string("1fr 1fr 1fr", 100) + assert sum(result) == 100 + assert result == [33, 33, 34] + + +def test_non_fr_token_raises(): + with pytest.raises(ValueError, match="non-fr token"): + _parse_fr_string("200px 1fr", 1000) + + +def test_empty_spec_raises(): + with pytest.raises(ValueError, match="empty spec"): + _parse_fr_string("", 1000) + + +def test_zero_fr_raises(): + with pytest.raises(ValueError, match="total fr"): + _parse_fr_string("0fr 0fr", 1000) diff --git a/tests/phase_z2/test_retry_gate.py b/tests/phase_z2/test_retry_gate.py new file mode 100644 index 0000000..6eb30d0 --- /dev/null +++ b/tests/phase_z2/test_retry_gate.py @@ -0,0 +1,129 @@ +"""IMP-09 PR 1 — retry gate tests (_attempt_zone_ratio_retry early exit). + +Stage 3 round 4 lock §2-A: row-axis retry must skip when layout has +dynamic_cols=True (2-D topology) OR dynamic_rows=False (fr_default +sink). The horizontal-2 path (dynamic_rows=True, dynamic_cols=False) +must still proceed through the gate. + +These tests exercise the gate by routing the request through +_attempt_zone_ratio_retry with router_active=True + proposed +zone_ratio_retry — but with layout_css fields that should trip the +gate. We confirm the early skip by asserting retry_attempted==False +and retry_skipped_reason content. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from src.phase_z2_pipeline import _attempt_zone_ratio_retry + + +_ROUTER_ACTIVE = { + "router_active": True, + "proposed_actions_summary": ["zone_ratio_retry"], +} + + +def _dummy_kwargs(layout_css: dict, tmp_path: Path) -> dict: + """All params required by _attempt_zone_ratio_retry. Only + `layout_css` and `router_decision` matter pre-gate.""" + return { + "run_dir": tmp_path, + "out_path": tmp_path / "final.html", + "slide_title": "test", + "slide_footer": None, + "zones_data": [], + "debug_zones": [], + "layout_preset": "horizontal-2", + "layout_css": layout_css, + "overflow": {}, + "fit_classification": {}, + "router_decision": _ROUTER_ACTIVE, + "gap_px": 14, + } + + +def test_vertical_2_dynamic_cols_skips_retry(tmp_path): + layout_css = { + "areas": '"left right"', + "cols": "583px 583px", + "rows": "1fr", + "heights_px": [585], + "widths_px": [583, 583], + "ratios": [1.0], + "width_ratios": [0.494, 0.494], + "dynamic_rows": False, + "dynamic_cols": True, + } + trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path)) + assert trace["retry_attempted"] is False + assert "dynamic_cols" in trace["retry_skipped_reason"] + assert "IMP-09" in trace["retry_skipped_reason"] + + +def test_fr_default_sink_skips_retry(tmp_path): + # PR 1 single / T-shape / 2x2 fall through to fr_default and must + # not enter row-only retry plan. + layout_css = { + "areas": '"top top" "bottom-left bottom-right"', + "cols": "1fr 1fr", + "rows": "1fr 1fr", + "heights_px": [285, 286], + "widths_px": [583, 583], + "ratios": [0.487, 0.489], + "width_ratios": [0.494, 0.494], + "dynamic_rows": False, + "dynamic_cols": False, + } + trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path)) + assert trace["retry_attempted"] is False + assert "fr_default_from_preset" in trace["retry_skipped_reason"] + + +def test_horizontal_2_dynamic_rows_passes_gate(tmp_path): + """horizontal-2 with dynamic_rows=True must pass the gate. The + test does not need plan_zone_ratio_retry to succeed; it only + asserts the gate did not early-skip with one of the new + skip reasons.""" + layout_css = { + "areas": '"top" "bottom"', + "cols": "1fr", + "rows": "333px 238px", + "heights_px": [333, 238], + "widths_px": [1180], + "ratios": [0.569, 0.407], + "width_ratios": [1.0], + "dynamic_rows": True, + "dynamic_cols": False, + } + # plan_zone_ratio_retry will return None because debug_zones is + # empty, so retry_attempted=True but plan==None. + trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path)) + assert trace["retry_attempted"] is True + # The gate was passed; skip reason (if any) is the legacy + # plan-failure reason, not the new gate reasons. + skip_reason = trace.get("retry_skipped_reason") + if skip_reason is not None: + assert "dynamic_cols" not in skip_reason + assert "fr_default_from_preset" not in skip_reason + + +def test_router_inactive_skips_before_gate(tmp_path): + """When router_active=False, the early skip happens before the + new IMP-09 gate. Verify the existing behavior is unchanged.""" + layout_css = { + "areas": '"left right"', + "dynamic_rows": False, + "dynamic_cols": True, + "heights_px": [585], + "widths_px": [583, 583], + "ratios": [1.0], + "width_ratios": [0.5, 0.5], + } + kwargs = _dummy_kwargs(layout_css, tmp_path) + kwargs["router_decision"] = {"router_active": False} + trace = _attempt_zone_ratio_retry(**kwargs) + assert trace["retry_attempted"] is False + assert "router_active=False" in trace["retry_skipped_reason"]