"""IMP-#85 u3a — Audit CLI invariants I1-I3. Scope (Stage 2 lock): I1 partial existence — `templates/phase_z2/families/{template_id}.html` must exist for live (non-VP) contracts. I2 builder declared — live contracts must declare non-empty `payload.builder`. I3 builder registered — declared builders must be in PAYLOAD_BUILDERS. `visual_pending: true` skipped for all of I1-I3 (data-driven from catalog, no hard-coded frame allow-list; matches u2 invariant scope). Out of scope (별 axis): - I4 slot_payload references (u3b). - V4 runtime VP filter (u4). - Implementing the 17 missing VP builders. """ from __future__ import annotations import subprocess import sys import textwrap from pathlib import Path import pytest import yaml REPO_ROOT = Path(__file__).resolve().parent.parent SCRIPT_PATH = REPO_ROOT / "scripts" / "audit_frame_invariants.py" def _write_yaml(path: Path, payload: dict) -> Path: path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8") return path def _run_cli(catalog: Path, partials: Path) -> subprocess.CompletedProcess: return subprocess.run( [ sys.executable, str(SCRIPT_PATH), "--catalog", str(catalog), "--partials-dir", str(partials), ], cwd=str(REPO_ROOT), capture_output=True, text=True, ) def test_prod_catalog_audit_passes(tmp_path): """Prod catalog + prod partials dir → I1-I3 PASS (live contracts clean).""" from scripts.audit_frame_invariants import ( DEFAULT_CATALOG_PATH, DEFAULT_PARTIALS_DIR, run_audit, ) violations = run_audit(DEFAULT_CATALOG_PATH, DEFAULT_PARTIALS_DIR) assert violations == [], ( "Prod live contracts (non-VP) must satisfy I1-I3 invariants. " f"Got: {violations}" ) def test_i1_partial_missing_for_live_contract(tmp_path): """Live contract without families/{template_id}.html → I1 violation.""" from src.phase_z2_mapper import PAYLOAD_BUILDERS from scripts.audit_frame_invariants import check_i1_partial_existence sample_builder = next(iter(PAYLOAD_BUILDERS.keys())) catalog = { "missing_partial_frame": { "template_id": "missing_partial_frame", "payload": {"builder": sample_builder}, }, } partials_dir = tmp_path / "families" partials_dir.mkdir() violations = check_i1_partial_existence(catalog, partials_dir) assert len(violations) == 1 assert "I1 partial-missing" in violations[0] assert "missing_partial_frame" in violations[0] def test_i1_partial_present_no_violation(tmp_path): """Live contract with partial on disk → no I1 violation.""" from scripts.audit_frame_invariants import check_i1_partial_existence catalog = { "ok_frame": { "template_id": "ok_frame", "payload": {"builder": "items_with_role"}, }, } partials_dir = tmp_path / "families" partials_dir.mkdir() (partials_dir / "ok_frame.html").write_text("
", encoding="utf-8") assert check_i1_partial_existence(catalog, partials_dir) == [] def test_i1_skips_visual_pending(tmp_path): """visual_pending: true with no partial → I1 skip (no violation).""" from scripts.audit_frame_invariants import check_i1_partial_existence catalog = { "vp_frame": { "template_id": "vp_frame", "visual_pending": True, "payload": {"builder": "definitely_not_registered"}, }, } partials_dir = tmp_path / "families" partials_dir.mkdir() assert check_i1_partial_existence(catalog, partials_dir) == [] def test_i2_missing_builder_field(): """Live contract without payload.builder → I2 violation.""" from scripts.audit_frame_invariants import check_i2_builder_declared catalog = { "no_builder_frame": { "template_id": "no_builder_frame", "payload": {}, }, } violations = check_i2_builder_declared(catalog) assert len(violations) == 1 assert "I2 builder-undeclared" in violations[0] assert "no_builder_frame" in violations[0] def test_i2_skips_visual_pending(): """visual_pending: true without builder → I2 skip.""" from scripts.audit_frame_invariants import check_i2_builder_declared catalog = { "vp_frame": { "template_id": "vp_frame", "visual_pending": True, "payload": {}, }, } assert check_i2_builder_declared(catalog) == [] def test_i3_unregistered_builder(): """Live contract with unknown builder → I3 violation.""" from scripts.audit_frame_invariants import check_i3_builder_registered catalog = { "ghost_frame": { "template_id": "ghost_frame", "payload": {"builder": "ghost_builder_xyz"}, }, } violations = check_i3_builder_registered( catalog, registered_builders={"items_with_role"} ) assert len(violations) == 1 assert "I3 builder-unregistered" in violations[0] assert "ghost_frame" in violations[0] assert "ghost_builder_xyz" in violations[0] def test_i3_registered_builder_passes(): """Live contract with registered builder → no I3 violation.""" from scripts.audit_frame_invariants import check_i3_builder_registered catalog = { "ok_frame": { "template_id": "ok_frame", "payload": {"builder": "items_with_role"}, }, } assert check_i3_builder_registered( catalog, registered_builders={"items_with_role"} ) == [] def test_i3_skips_visual_pending(): """visual_pending: true with unregistered builder → I3 skip.""" from scripts.audit_frame_invariants import check_i3_builder_registered catalog = { "vp_frame": { "template_id": "vp_frame", "visual_pending": True, "payload": {"builder": "vp_only_builder"}, }, } assert check_i3_builder_registered( catalog, registered_builders={"items_with_role"} ) == [] def test_cli_exit_zero_on_clean_catalog(tmp_path): """CLI exit code 0 + PASS line on clean (live) catalog.""" catalog_path = tmp_path / "catalog.yaml" partials_dir = tmp_path / "families" partials_dir.mkdir() (partials_dir / "ok_frame.html").write_text("", encoding="utf-8") _write_yaml( catalog_path, { "ok_frame": { "template_id": "ok_frame", "payload": {"builder": "items_with_role"}, }, "vp_frame": { "template_id": "vp_frame", "visual_pending": True, "payload": {"builder": "unregistered_xyz"}, }, }, ) result = _run_cli(catalog_path, partials_dir) assert result.returncode == 0, result.stdout + result.stderr assert "PASS" in result.stdout def test_cli_exit_one_on_violations(tmp_path): """CLI exit code 1 + aggregated violations listed on drift.""" catalog_path = tmp_path / "catalog.yaml" partials_dir = tmp_path / "families" partials_dir.mkdir() _write_yaml( catalog_path, { "frame_a": { "template_id": "frame_a", "payload": {"builder": "ghost_builder"}, }, "frame_b": { "template_id": "frame_b", "payload": {}, }, }, ) result = _run_cli(catalog_path, partials_dir) assert result.returncode == 1, result.stdout + result.stderr assert "FAIL" in result.stdout assert "frame_a" in result.stdout assert "frame_b" in result.stdout assert "I1" in result.stdout assert "I2" in result.stdout or "I3" in result.stdout