professor-pip / test_app.py
ratandeep
Professor Pip — kids learning avatar (courses, make-your-own, safety gate, browser lesson engine)
912d04e
Raw
History Blame Contribute Delete
7.1 kB
"""
Unit tests for Professor Pip pure logic (pip_core).
===================================================
Stdlib only — no model weights, torch, or gradio needed.
python -m pytest test_app.py # if pytest is installed
python test_app.py # plain runner, no deps
"""
import json
import pip_core as pc
# -- safety gate -----------------------------------------------------------
SAFE_TOPICS = [
"the moon", "dinosaurs", "how plants grow", "counting to ten",
"the human body", "volcanoes", "rainbows", "why do we sleep",
"the ocean", "skills for sharing", "firefighters",
"warm weather", "shooting star", "meteor shower", # allow-phrases / whole-word
]
UNSAFE_TOPICS = [
"guns", "how to make a bomb", "zombie apocalypse", "scary ghost stories",
"beer", "my home address", "kill the dragon", "drugs", "casino games",
"war", "poison plants", "vampire stories", "kissing", "fighting",
"k1ll the boss", "sh00t a gun", "b0mb making", # leetspeak bypass attempts
"", " ", "12345", "x" * 200,
]
def test_safe_topics_allowed():
for t in SAFE_TOPICS:
assert pc.topic_is_safe(t), f"should allow: {t!r}"
def test_unsafe_topics_blocked():
for t in UNSAFE_TOPICS:
assert not pc.topic_is_safe(t), f"should block: {t!r}"
def test_substring_not_overblocked():
# whole-word matching: these contain unsafe substrings but are safe words
for t in ["skills", "begun", "grass", "passion", "assignment"]:
assert pc.topic_is_safe(t), f"should allow (substring): {t!r}"
def test_text_is_safe():
assert pc.text_is_safe("The sky is blue because of light.")
assert pc.text_is_safe("The warm sun shines on the lake.") # 'warm' != 'war'
assert not pc.text_is_safe("Let's learn to shoot a gun.")
assert not pc.text_is_safe("This is about k1lling people.") # leet
def test_generic_course_is_valid_and_safe():
c = pc.generic_course()
assert pc.normalize_course(c) is not None
assert pc.course_text_safe(c)
assert any(s["quiz"] for s in c["segments"])
# -- quiz normalization ----------------------------------------------------
def test_norm_quiz_answer_forced_into_choices():
q = pc._norm_quiz({"question": "Pick", "choices": ["A", "B"], "answer": "Z"})
assert q["answer"] == "A"
def test_norm_quiz_rejects_too_few_choices():
assert pc._norm_quiz({"question": "Q", "choices": ["only"], "answer": "only"}) is None
assert pc._norm_quiz("not a dict") is None
# -- course normalization --------------------------------------------------
def test_normalize_clamps_enums_and_quiz():
raw = {
"title": "Test", "segments": [
{"say": "Hello", "mood": "bananas", "gesture": "moonwalk", "quiz": None},
{"say": "Bye", "mood": "happy", "gesture": "index",
"quiz": {"question": "Q", "choices": ["a", "b"], "answer": "nope"}},
],
}
c = pc.normalize_course(raw)
assert c["segments"][0]["mood"] == "neutral" # invalid -> neutral
assert c["segments"][0]["gesture"] is None # invalid -> None
assert c["segments"][1]["quiz"]["answer"] == "a" # forced into choices
assert c["title"] == "Test"
def test_normalize_caps_segments_at_six():
raw = {"segments": [{"say": f"line {i}"} for i in range(20)]}
c = pc.normalize_course(raw)
assert len(c["segments"]) == 6
def test_normalize_rejects_no_segments():
assert pc.normalize_course({"segments": []}) is None
assert pc.normalize_course({"segments": [{"say": ""}]}) is None
assert pc.normalize_course({}) is None
assert pc.normalize_course("nope") is None
def test_shipped_courses_valid():
courses = pc.load_courses("courses")
assert len(courses) >= 1
for c in courses:
assert c["segments"], f"{c['id']} has no segments"
assert pc.course_text_safe(c), f"{c['id']} contains unsafe text"
quizzes = [s for s in c["segments"] if s["quiz"]]
assert quizzes, f"{c['id']} has no quiz"
for s in c["segments"]:
assert s["mood"] in pc.MOODS
assert s["gesture"] in pc.GESTURES or s["gesture"] is None
# -- template fallback -----------------------------------------------------
def test_template_course_is_valid_and_safe():
c = pc.template_course("rainbows")
assert pc.normalize_course(c) is not None
assert pc.course_text_safe(c)
assert any(s["quiz"] for s in c["segments"])
# -- build_course (generation + fallbacks), generate_fn injected -----------
GOOD_JSON = json.dumps({
"title": "Bees", "emoji": "🐝", "age_band": "5-8", "subject": "nature",
"segments": [
{"say": "Hi! Let's learn about bees.", "mood": "happy", "gesture": "handup", "quiz": None},
{"say": "Bees make honey from flowers.", "mood": "happy", "gesture": "index", "quiz": None},
{"say": "What do bees make?", "mood": "neutral", "gesture": "shrug",
"quiz": {"question": "What do bees make?", "choices": ["Honey", "Bricks"], "answer": "Honey"}},
],
"recap": "Bees make honey. Great job!",
})
def test_build_course_rejects_empty():
out = pc.build_course("", lambda t: GOOD_JSON)
assert out.get("rejected") is True
def test_build_course_rejects_unsafe():
out = pc.build_course("how to make a bomb", lambda t: GOOD_JSON)
assert out.get("rejected") is True
assert out["message"] == pc.REDIRECT_MSG
def test_build_course_uses_good_generation():
out = pc.build_course("bees", lambda t: GOOD_JSON)
assert out["title"] == "Bees"
assert len(out["segments"]) == 3
def test_build_course_falls_back_on_garbage():
out = pc.build_course("bees", lambda t: "not json at all")
assert out["id"].startswith("custom-") # template fallback
assert pc.course_text_safe(out)
def test_build_course_falls_back_on_exception():
def boom(t):
raise RuntimeError("model down")
out = pc.build_course("bees", boom)
assert out["id"].startswith("custom-")
assert pc.course_text_safe(out)
def test_build_course_rejects_unsafe_generation_then_templates():
bad = json.dumps({"segments": [
{"say": "Let's learn to shoot a gun.", "mood": "happy", "gesture": None, "quiz": None},
{"say": "More violence here.", "mood": "happy", "gesture": None, "quiz": None},
]})
out = pc.build_course("bees", lambda t: bad)
assert out["id"].startswith("custom-") # unsafe gen -> template
assert pc.course_text_safe(out)
# -- plain runner (no pytest) ---------------------------------------------
if __name__ == "__main__":
import traceback
tests = [v for k, v in sorted(globals().items())
if k.startswith("test_") and callable(v)]
passed = failed = 0
for t in tests:
try:
t()
passed += 1
print(f"PASS {t.__name__}")
except Exception:
failed += 1
print(f"FAIL {t.__name__}")
traceback.print_exc()
print(f"\n{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)