""" 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)