Files
Vijay Janapa Reddi f1ffe5b6fa Rename interviews/ to staffml/ and drop the repeated prefix inside it
interviews/ held StaffML and nothing else, so the directory now carries the
product's name. Three names inside it repeated that prefix, and now line up
with vault/, vault-cli/, and paper/:

  interviews/staffml/               -> staffml/app/
  interviews/staffml-vault-worker/  -> staffml/vault-worker/
  interviews/staffml-vault-types/   -> staffml/vault-types/

Every repository path moved with it: workflow paths: triggers and variable
fallbacks, pre-commit hooks, .gitignore, contributor tooling, vault-cli path
joins, the app's tsconfig mapping to the shared types, and the docs.

Left alone on purpose:
- the published /interviews/ redirect to /staffml/ and the gh-pages subsite
  lists that protect it; those are URLs, not repository paths
- package and worker names (staffml-vault-worker, @staffml/vault-types) and
  the vitest job name, which branch protection may key on
- question YAMLs and generated track JSON, which are content-addressed.
  Three questions carry an older corruption, "recall @interviews/vault/
  questions/...", that predates this change
- the applied D1 migration 0001 and the vault CHANGELOG, which are history

codegen-hashes.txt is re-recorded; the three pinned artifacts changed only in
path strings. vault-cli: 97 passed, codegen --check clean.

interviewer.py's __main__ loader was one parent short and pointed at a
corpus.json that never existed; it now resolves staffml/app/src/data/
corpus.json. tools/validate_playbook.py and generate_interview_flashcard.py
still expect per-track directories that no longer exist; they point at the
new root but remain dead.

The GitHub repository variables STAFFML_ROOT, VAULT_DIR, and VAULT_CLI_DIR
still name the old paths and must change when this reaches origin.
2026-09-10 14:48:04 +02: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 staffml/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())