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>
101 lines
3.3 KiB
Python
101 lines
3.3 KiB
Python
"""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}"
|
|
)
|