trainlog/tools/validate_import_contract.py

71 lines
1.9 KiB
Python
Executable file

#!/usr/bin/env python3
"""Validate Trainlog v1 catalog reconciliation contract cases."""
from __future__ import annotations
import json
import sys
import unicodedata
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
CASES_PATH = ROOT / "tests" / "contract" / "catalog-import-cases.json"
def normalize_exercise_name(name: str) -> str:
"""Return the v1 normalized comparison form for an exercise name."""
nfc = unicodedata.normalize("NFC", name)
collapsed = " ".join(nfc.strip().split())
return collapsed.casefold()
def classify(local: dict[str, Any], incoming: dict[str, Any]) -> str:
"""Classify one incoming catalog entry against one existing local entry.
This function is executable specification for the Gate 1 import contract.
It does not mutate either object.
"""
same_id = local["exercise_id"] == incoming["exercise_id"]
same_name = normalize_exercise_name(local["name"]) == normalize_exercise_name(
incoming["name"]
)
same_mode = local["tracking_mode"] == incoming["tracking_mode"]
if same_id:
if not same_mode:
return "reject_mode_conflict"
if same_name:
return "reuse"
return "reuse_with_name_warning"
if same_name:
return "reject_name_identity_conflict"
return "create"
def main() -> int:
"""Run every canonical catalog reconciliation case."""
with CASES_PATH.open("r", encoding="utf-8") as handle:
cases = json.load(handle)
failed = False
for case in cases:
actual = classify(case["local"], case["incoming"])
expected = case["expected"]
if actual != expected:
print(
f"FAIL {case['name']}: expected {expected!r}, got {actual!r}"
)
failed = True
else:
print(f"PASS {case['name']}: {actual}")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())