Files
cs249r_book/interviews/vault-cli/tests/test_models.py
Vijay Janapa Reddi bc26a0bf37 feat(vault): Phase 6 schema tightening — markers + Details forbid + invariant
Three coordinated edits to lift the marker convention from a soft
draft-validation gate to a published-corpus invariant:

1. interviews/vault/schema/question_schema.yaml (LinkML, source of truth):
   common_mistake and napkin_math gain regex patterns matching the
   AUTHORING.md Pitfall/Rationale/Consequence and Assumptions/
   Calculations/Conclusion conventions. Documents the spec; enforced
   in the validator below.

2. interviews/vault-cli/src/vault_cli/models.py (Pydantic, derived):
   Details flips from extra='allow' to extra='forbid'. A pre-flight
   survey on 2026-05-04 across all 10,711 YAMLs found 0 unknown keys
   on Details, so the historical 'imported legacy fields' risk no
   longer applies.

3. interviews/vault-cli/src/vault_cli/validator.py:
   structural_tier gains _check_format_markers (invariant #19), which
   flags published YAMLs whose non-empty cm/nm doesn't match the
   AUTHORING.md markers. Drafts are exempt — author-in-progress drafts
   may still have malformed markers. Lifts gate_format from
   validate_drafts.py / _judges.py from a CI-time gate to a
   vault-check-strict invariant.

Tests: 4 new cases in test_models covering Details forbid, marker-
compliant pass, malformed cm fail, and draft-exempt skip. Total
88 passing (was 84). codegen-hashes.txt updated for the models.py
edit; vault codegen --check passes.

The on-disk corpus is fully clean post-Phase-5+drain: vault check
--strict reports 10,711 loaded, 0 invariant failures, 0 format-
marker violations on published YAMLs.
2026-05-04 08:41:08 -04:00

298 lines
9.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the v0.1.2 Pydantic validators.
Three validators added in the 2026-04-25 release-readiness push:
1. Visual class hardening — kind enum, path regex, alt/caption min lengths.
2. Question._zone_bloom_compatible — zone × bloom_level matrix.
3. Question._visual_path_resolves — visual.path must point at a real SVG
file under interviews/vault/visuals/<track>/. Skipped when the working
tree is absent (production deploys).
Each test below is a small fixture that constructs a Question (or Visual)
and asserts validation passes or fails as expected. Runnable as
``python3 tests/test_models.py`` if pytest isn't available.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Make vault_cli importable.
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1] / "src"))
from vault_cli.models import Question, Visual # noqa: E402
# ─── Visual.kind ─────────────────────────────────────────────────────────────
def test_visual_kind_svg_accepted():
v = Visual(
kind="svg",
path="cloud-1.svg",
alt="A diagram of a CPU cache hierarchy.",
caption="Cache Hierarchy",
)
assert v.kind == "svg"
def test_visual_kind_mermaid_rejected():
"""v0.1.2: mermaid was reserved but never shipped — retired."""
try:
Visual(kind="mermaid", path="x.svg", alt="long enough alt", caption="cap1")
except Exception as e:
assert "mermaid was reserved but never implemented" in str(e)
return
raise AssertionError("Visual.kind=mermaid should have been rejected")
# ─── Visual.path regex ───────────────────────────────────────────────────────
def test_visual_path_uppercase_rejected():
try:
Visual(kind="svg", path="Cloud-1.svg", alt="long enough alt", caption="cap1")
except Exception as e:
assert "lowercase + dash + dot only" in str(e)
return
raise AssertionError("uppercase path should have been rejected")
def test_visual_path_underscore_rejected():
try:
Visual(kind="svg", path="cloud_1.svg", alt="long enough alt", caption="cap1")
except Exception as e:
assert "must match" in str(e)
return
raise AssertionError("underscore path should have been rejected")
def test_visual_path_traversal_rejected():
try:
Visual(
kind="svg", path="../etc/passwd.svg",
alt="long enough alt", caption="cap1",
)
except Exception as e:
assert "safe relative filename" in str(e)
return
raise AssertionError("path traversal should have been rejected")
def test_visual_path_no_extension_rejected():
try:
Visual(kind="svg", path="cloud-1", alt="long enough alt", caption="cap1")
except Exception as e:
assert "must match" in str(e)
return
raise AssertionError("no-extension path should have been rejected")
# ─── Visual.alt and caption min lengths ──────────────────────────────────────
def test_visual_alt_too_short_rejected():
try:
Visual(kind="svg", path="cloud-1.svg", alt="short", caption="cap1")
except Exception as e:
assert "≥10 chars" in str(e)
return
raise AssertionError("short alt should have been rejected")
def test_visual_caption_too_short_rejected():
try:
Visual(
kind="svg", path="cloud-1.svg",
alt="A long enough alt text.", caption="x",
)
except Exception as e:
assert "≥5 chars" in str(e)
return
raise AssertionError("short caption should have been rejected")
def test_visual_caption_required():
try:
Visual(kind="svg", path="cloud-1.svg", alt="A long enough alt.")
except Exception:
return # caption missing → pydantic field-required error is fine
raise AssertionError("missing caption should have been rejected")
# ─── Question._zone_bloom_compatible ─────────────────────────────────────────
def _question(zone: str, bloom: str) -> dict:
return {
"schema_version": "1.0",
"id": "cloud-9999",
"track": "cloud",
"level": "L4",
"zone": zone,
"topic": "memory-hierarchy-design",
"competency_area": "memory",
"bloom_level": bloom,
"title": "Test",
"scenario": "A scenario long enough to be realistic.",
"details": {"realistic_solution": "x"},
}
def test_zone_bloom_recall_remember_accepted():
Question(**_question("recall", "remember"))
def test_zone_bloom_recall_evaluate_rejected():
try:
Question(**_question("recall", "evaluate"))
except Exception as e:
assert "incompatible" in str(e) and "recall" in str(e)
return
raise AssertionError("recall+evaluate should have been rejected")
# ─── Phase 6: Details extra="forbid" + format-marker structural check ────────
def test_details_extra_forbidden():
"""Phase 6: Details rejects unknown keys."""
q = _question("recall", "remember")
q["details"] = {"realistic_solution": "x", "made_up_field": "boom"}
try:
Question(**q)
except Exception as e:
assert "made_up_field" in str(e) or "extra" in str(e).lower()
return
raise AssertionError("Details should reject unknown keys")
def test_format_markers_invariant_passes_on_compliant_cm_nm():
"""The structural check accepts marker-compliant cm/nm on published."""
from vault_cli.loader import LoadedQuestion
from vault_cli.validator import _check_format_markers
base = {**_question("analyze", "analyze"), "status": "published"}
q = Question(**{
**base,
"details": {
"realistic_solution": "x",
"common_mistake": (
"**The Pitfall:** wrong intuition.\n"
"**The Rationale:** because reasons.\n"
"**The Consequence:** bad outcome."
),
"napkin_math": (
"**Assumptions:** ok.\n\n"
"**Calculations:**\n- step 1\n\n"
"**Conclusion:** done."
),
},
})
lq = LoadedQuestion(question=q, path=Path("/tmp/x.yaml"))
assert _check_format_markers([lq]) == []
def test_format_markers_invariant_flags_malformed_cm_on_published():
"""A published YAML with malformed common_mistake fails the structural check."""
from vault_cli.loader import LoadedQuestion
from vault_cli.validator import _check_format_markers
base = {**_question("analyze", "analyze"), "status": "published"}
q = Question(**{
**base,
"details": {
"realistic_solution": "x",
"common_mistake": "Just a sentence with no markers at all.",
},
})
lq = LoadedQuestion(question=q, path=Path("/tmp/x.yaml"))
failures = _check_format_markers([lq])
assert len(failures) == 1
assert failures[0].check == "format-markers"
assert "common_mistake" in failures[0].message
def test_format_markers_invariant_skips_drafts():
"""Drafts are exempt — author-in-progress malformed markers are fine."""
from vault_cli.loader import LoadedQuestion
from vault_cli.validator import _check_format_markers
base = _question("analyze", "analyze")
base["status"] = "draft"
q = Question(**{
**base,
"details": {
"realistic_solution": "x",
"common_mistake": "no markers here",
"napkin_math": "no markers here either",
},
})
lq = LoadedQuestion(question=q, path=Path("/tmp/x.yaml"))
assert _check_format_markers([lq]) == []
def test_zone_bloom_mastery_remember_rejected():
try:
Question(**_question("mastery", "remember"))
except Exception as e:
assert "incompatible" in str(e) and "mastery" in str(e)
return
raise AssertionError("mastery+remember should have been rejected")
def test_zone_bloom_evaluation_evaluate_accepted():
q = _question("evaluation", "evaluate")
q["competency_area"] = "cross-cutting"
Question(**q)
def test_zone_bloom_design_create_accepted():
q = _question("design", "create")
q["competency_area"] = "cross-cutting"
Question(**q)
# ─── Question._visual_path_resolves ──────────────────────────────────────────
def test_visual_path_must_resolve():
"""A visual whose path doesn't resolve to a real SVG should fail."""
q = _question("evaluation", "evaluate")
q["competency_area"] = "memory"
q["visual"] = {
"kind": "svg",
"path": "cloud-doesnotexist-9999.svg",
"alt": "A fake visual that does not exist on disk.",
"caption": "Will fail",
}
try:
Question(**q)
except Exception as e:
assert "does not resolve to a real file" in str(e)
return
raise AssertionError("missing visual.path should have been rejected")
# ─── Test runner (no pytest dependency) ──────────────────────────────────────
def _run() -> int:
tests = [
(n, fn) for n, fn in globals().items()
if n.startswith("test_") and callable(fn)
]
passed = failed = 0
for name, fn in tests:
try:
fn()
print(f"{name}")
passed += 1
except Exception as e:
print(f"{name}: {type(e).__name__}: {e}")
failed += 1
print(f"\n{passed}/{len(tests)} passed; {failed} failed")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(_run())