diff --git a/tests/test_gui_2_result.py b/tests/test_gui_2_result.py new file mode 100644 index 00000000..4b489059 --- /dev/null +++ b/tests/test_gui_2_result.py @@ -0,0 +1,119 @@ +""" +Tests for the Phase 1 invariant contract of result_migration_gui_2_20260619. + +This file locks the Phase 1 site inventory contract: there are exactly 42 +migration-target error-handling sites in src/gui_2.py. Both tests are static +invariants that must pass before Phase 1 closes and Phase 2 begins: + +- test_phase_1_inventory_has_42_rows: parses the markdown inventory table + (tests/artifacts/PHASE1_SITE_INVENTORY.md) and asserts the Site Inventory + table contains exactly 42 rows. +- test_phase_1_audit_has_42_migration_target_sites: invokes the audit script + (scripts/audit_exception_handling.py --src src --json), finds the + src/gui_2.py file record, and counts the sites whose category is in the + migration-target set (i.e., NOT INTERNAL_COMPLIANT, NOT + INTERNAL_PROGRAMMER_RAISE, NOT BOUNDARY_*). + +The migration-target category set is defined per +conductor/code_styleguides/error_handling.md as: any category that is not one +of the 5 "leave-as-is" categories. The migration-target sites are the ones +the Phase 2-N migration will touch; the leave-as-is categories are legitimate +non-migration patterns (compliant internal try/except, programmer raises, +and the 3 boundary categories). +""" +import json +import re +import subprocess +from pathlib import Path + + +INVENTORY_PATH = Path("tests/artifacts/PHASE1_SITE_INVENTORY.md") +EXPECTED_SITE_COUNT = 42 +MIGRATION_EXCLUDE_CATEGORIES = frozenset({ + "INTERNAL_COMPLIANT", + "INTERNAL_PROGRAMMER_RAISE", + "BOUNDARY_FASTAPI", + "BOUNDARY_SDK", + "BOUNDARY_CONVERSION", +}) + + +def test_phase_1_inventory_has_42_rows(): + """ + Parse tests/artifacts/PHASE1_SITE_INVENTORY.md and verify the "Site Inventory" + markdown table contains exactly 42 rows. + + The Site Inventory table begins with a header row of the form + "| L# | Category | Phase | ..." and a separator row "|---...". Each data row + has the form "| | | | ...". The test + locates the header by its leading "| L#" sentinel and counts subsequent rows + that match the data-row pattern until the first non-table line. + """ + text = INVENTORY_PATH.read_text(encoding="utf-8") + lines = text.splitlines() + header_idx = None + for i, line in enumerate(lines): + if line.startswith("| L#"): + header_idx = i + break + assert header_idx is not None, ( + f"Could not find '| L#' header in {INVENTORY_PATH}. " + f"The inventory file format may have changed." + ) + rows = [] + for line in lines[header_idx + 2:]: + if not line.startswith("|"): + break + if re.match(r"^\|\s*\d+\s*\|", line): + rows.append(line) + assert len(rows) == EXPECTED_SITE_COUNT, ( + f"PHASE1_SITE_INVENTORY.md has {len(rows)} site rows; expected " + f"{EXPECTED_SITE_COUNT}. The inventory must list exactly 42 migration-target " + f"sites in src/gui_2.py." + ) + + +def test_phase_1_audit_has_42_migration_target_sites(): + """ + Invoke scripts/audit_exception_handling.py --src src --json, parse the JSON + output, and verify that the src/gui_2.py file record contains exactly 42 + sites in the migration-target category set. + + A site is "migration-target" when its category is NOT one of: + - INTERNAL_COMPLIANT (legitimate compliant internal try/except) + - INTERNAL_PROGRAMMER_RAISE (raise for impossible/programmer states) + - BOUNDARY_FASTAPI (FastAPI HTTPException boundary) + - BOUNDARY_SDK (SDK call boundary conversion) + - BOUNDARY_CONVERSION (broad except used as conversion boundary) + + The migration-target set is therefore: + INTERNAL_BROAD_CATCH | INTERNAL_SILENT_SWALLOW | INTERNAL_RETHROW | + INTERNAL_OPTIONAL_RETURN | UNCLEAR. + + This test pins the audit output to the same 42 the inventory declares, so a + future audit-script regression or inventory drift will surface here. + """ + result = subprocess.run( + ["uv", "run", "python", "scripts/audit_exception_handling.py", "--src", "src", "--json"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"audit_exception_handling.py exited {result.returncode}; stderr:\n" + f"{result.stderr[:2000]}" + ) + data = json.loads(result.stdout) + gui2_files = [f for f in data.get("files", []) if "gui_2" in f.get("filename", "")] + assert gui2_files, ( + "audit JSON contained no file record matching 'gui_2' in filename. " + f"Filenames seen: {[f.get('filename') for f in data.get('files', [])][:10]}" + ) + gui2 = gui2_files[0] + findings = gui2.get("findings", []) + migration_sites = [f for f in findings if f.get("category") not in MIGRATION_EXCLUDE_CATEGORIES] + assert len(migration_sites) == EXPECTED_SITE_COUNT, ( + f"src/gui_2.py has {len(migration_sites)} migration-target sites in the audit; " + f"expected {EXPECTED_SITE_COUNT}. Categories seen: " + f"{sorted({f.get('category') for f in migration_sites})}. " + f"This must match the 42 sites declared in PHASE1_SITE_INVENTORY.md." + ) \ No newline at end of file