feat(IMP-09): PR 1 — col-axis solver + per-zone geometry mapper + retry gate
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) <noreply@anthropic.com>
This commit is contained in:
100
tests/phase_z2/test_fixtures_loader.py
Normal file
100
tests/phase_z2/test_fixtures_loader.py
Normal file
@@ -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}"
|
||||
)
|
||||
Reference in New Issue
Block a user