4d39596a7d
Phase 1 complete. Mastery scoring + competency rubrics + VC issuer shipped. 9 slices, 5 waves, 238 tests passing, 13/13 REQ-IDs covered. 4/4 grill MUST conditions satisfied. VERIFY: APPROVE_WITH_NOTES. ---ci--- project: praxis phase: 1 milestone: v0.3 status: complete requirements: covered: [REQ-MAST-01, REQ-MAST-02, REQ-MAST-03, REQ-SCEN-02, REQ-SCEN-03, REQ-SCEN-04, REQ-PATH-02, REQ-NFR-MAST-01, REQ-NFR-MAST-02, REQ-NFR-VC-01, REQ-NFR-VC-02, REQ-NFR-IRT-01] partial: [] ---/ci---
246 lines
7.5 KiB
Python
246 lines
7.5 KiB
Python
"""Unit tests for the path engine (SLICE-05, TASK-05-04)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path as FsPath
|
|
|
|
import pytest
|
|
import yaml
|
|
from pydantic import ValidationError
|
|
|
|
from server.paths.engine import PathEngine, clear_cache
|
|
from server.paths.schema import Path, PathWeek, WeekGate
|
|
|
|
_REPO_PATHS_DIR = FsPath(__file__).resolve().parent.parent / "paths"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_path_cache():
|
|
clear_cache()
|
|
yield
|
|
clear_cache()
|
|
|
|
|
|
def _passing_progress(week: int, distinct_passed: int = 3, mastery_score: float = 3.5) -> dict:
|
|
return {
|
|
"current_week": week,
|
|
"distinct_passed": distinct_passed,
|
|
"mastery_score": mastery_score,
|
|
}
|
|
|
|
|
|
def test_load_customer_service_path_has_six_weeks():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
assert path.slug == "customer_service"
|
|
assert path.skill == "customer_service"
|
|
assert len(path.weeks) == 6
|
|
assert [w.week for w in path.weeks] == [1, 2, 3, 4, 5, 6]
|
|
titles = [w.title for w in path.weeks]
|
|
assert "Foundations" in titles[0]
|
|
assert "De-escalation" in titles[1]
|
|
assert "Policy Exceptions" in titles[2]
|
|
assert "Multi-Issue Resolution" in titles[3]
|
|
assert "Recovery" in titles[4]
|
|
assert "Mastery Demonstration" in titles[5]
|
|
|
|
|
|
def test_each_week_gate_defaults_match_d032():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
for w in path.weeks:
|
|
assert w.gate.required_scenarios == 3
|
|
assert w.gate.required_score == 3.5
|
|
|
|
|
|
def test_path_scenario_ids_reference_expected_set():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
expected = [
|
|
"cs_refund_ca_v01",
|
|
"cs_escalation_ca_v02",
|
|
"cs_policy_exception_ca_v03",
|
|
"cs_multi_issue_ca_v04",
|
|
"cs_recovery_ca_v05",
|
|
"cs_mastery_demonstration_ca_v06",
|
|
]
|
|
assert path.all_scenario_ids() == expected
|
|
|
|
|
|
def test_gate_open_when_three_passed_and_score_3_5():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
progress = _passing_progress(week=1, distinct_passed=3, mastery_score=3.5)
|
|
assert engine.check_gate(progress, 1, path) is True
|
|
|
|
|
|
def test_gate_open_above_threshold():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
progress = _passing_progress(week=2, distinct_passed=4, mastery_score=4.0)
|
|
assert engine.check_gate(progress, 2, path) is True
|
|
|
|
|
|
def test_gate_closed_when_only_two_passed():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
progress = _passing_progress(week=1, distinct_passed=2, mastery_score=4.0)
|
|
assert engine.check_gate(progress, 1, path) is False
|
|
|
|
|
|
def test_gate_closed_when_score_below_threshold():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
progress = _passing_progress(week=1, distinct_passed=3, mastery_score=3.0)
|
|
assert engine.check_gate(progress, 1, path) is False
|
|
|
|
|
|
def test_advance_week_increments_current_week():
|
|
engine = PathEngine()
|
|
progress = _passing_progress(week=1)
|
|
advanced = engine.advance_week(progress)
|
|
assert advanced["current_week"] == 2
|
|
assert progress["current_week"] == 1
|
|
|
|
|
|
def test_advance_week_caps_at_six():
|
|
engine = PathEngine()
|
|
progress = _passing_progress(week=6)
|
|
advanced = engine.advance_week(progress)
|
|
assert advanced["current_week"] == 6
|
|
|
|
|
|
def test_current_week_defaults_to_one():
|
|
engine = PathEngine()
|
|
assert engine.current_week({}) == 1
|
|
assert engine.current_week({"current_week": 99}) == 6
|
|
assert engine.current_week({"current_week": 0}) == 1
|
|
|
|
|
|
def test_is_path_complete_true_when_week6_gate_open():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
progress = _passing_progress(week=6, distinct_passed=3, mastery_score=3.5)
|
|
assert engine.is_path_complete(progress, path) is True
|
|
|
|
|
|
def test_is_path_complete_false_when_week6_gate_closed():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
progress = _passing_progress(week=6, distinct_passed=2, mastery_score=4.0)
|
|
assert engine.is_path_complete(progress, path) is False
|
|
|
|
|
|
def test_check_gate_rejects_unknown_week():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
progress = _passing_progress(week=1)
|
|
with pytest.raises(ValueError):
|
|
engine.check_gate(progress, 7, path)
|
|
|
|
|
|
def test_reject_five_weeks(tmp_path: FsPath):
|
|
slug = "five_week_path"
|
|
data = {
|
|
"slug": slug,
|
|
"name": "Five Week Path",
|
|
"skill": "customer_service",
|
|
"weeks": [
|
|
{"week": i, "title": f"Week {i}", "scenario_ids": [f"s{i}"], "gate": {"required_scenarios": 3, "required_score": 3.5}}
|
|
for i in range(1, 6)
|
|
],
|
|
}
|
|
p = tmp_path / f"{slug}.yaml"
|
|
p.write_text(yaml.safe_dump(data), encoding="utf-8")
|
|
engine = PathEngine(paths_dir=tmp_path)
|
|
with pytest.raises(ValidationError):
|
|
engine.load_path(slug)
|
|
|
|
|
|
def test_reject_seven_weeks(tmp_path: FsPath):
|
|
slug = "seven_week_path"
|
|
data = {
|
|
"slug": slug,
|
|
"name": "Seven Week Path",
|
|
"skill": "customer_service",
|
|
"weeks": [
|
|
{"week": i, "title": f"Week {i}", "scenario_ids": [f"s{i}"], "gate": {"required_scenarios": 3, "required_score": 3.5}}
|
|
for i in range(1, 8)
|
|
],
|
|
}
|
|
p = tmp_path / f"{slug}.yaml"
|
|
p.write_text(yaml.safe_dump(data), encoding="utf-8")
|
|
engine = PathEngine(paths_dir=tmp_path)
|
|
with pytest.raises(ValidationError):
|
|
engine.load_path(slug)
|
|
|
|
|
|
def test_reject_non_sequential_week_numbers(tmp_path: FsPath):
|
|
slug = "nonseq_path"
|
|
data = {
|
|
"slug": slug,
|
|
"name": "Non-Sequential Path",
|
|
"skill": "customer_service",
|
|
"weeks": [
|
|
{"week": i, "title": f"W{i}", "scenario_ids": [f"s{i}"], "gate": {"required_scenarios": 3, "required_score": 3.5}}
|
|
for i in [1, 2, 3, 4, 5, 5]
|
|
],
|
|
}
|
|
p = tmp_path / f"{slug}.yaml"
|
|
p.write_text(yaml.safe_dump(data), encoding="utf-8")
|
|
engine = PathEngine(paths_dir=tmp_path)
|
|
with pytest.raises(ValidationError):
|
|
engine.load_path(slug)
|
|
|
|
|
|
def test_reject_duplicate_scenario_ids_in_week():
|
|
with pytest.raises(ValidationError):
|
|
PathWeek(week=1, title="W", scenario_ids=["s1", "s1"])
|
|
|
|
|
|
def test_week_gate_defaults():
|
|
g = WeekGate()
|
|
assert g.required_scenarios == 3
|
|
assert g.required_score == 3.5
|
|
|
|
|
|
def test_validate_scenarios_exist_passes_with_stub_library():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
|
|
class _StubLib:
|
|
def __init__(self) -> None:
|
|
self._ids = set(path.all_scenario_ids())
|
|
|
|
def get(self, sid: str):
|
|
if sid not in self._ids:
|
|
raise KeyError(sid)
|
|
return object()
|
|
|
|
refs = engine.validate_scenarios_exist(path, _StubLib())
|
|
assert set(refs) == set(path.all_scenario_ids())
|
|
|
|
|
|
def test_validate_scenarios_exist_reports_missing():
|
|
engine = PathEngine()
|
|
path = engine.load_path("customer_service")
|
|
|
|
class _EmptyLib:
|
|
def get(self, sid: str):
|
|
raise KeyError(sid)
|
|
|
|
with pytest.raises(ValueError):
|
|
engine.validate_scenarios_exist(path, _EmptyLib())
|
|
|
|
|
|
def test_load_path_caches():
|
|
engine = PathEngine()
|
|
p1 = engine.load_path("customer_service")
|
|
p2 = engine.load_path("customer_service")
|
|
assert p1 is p2
|
|
|
|
|
|
def test_load_path_missing_raises():
|
|
engine = PathEngine(paths_dir=FsPath("/nonexistent_paths_dir_xyz"))
|
|
with pytest.raises(FileNotFoundError):
|
|
engine.load_path("no_such_path") |