ratandeep
Professor Pip — kids learning avatar (courses, make-your-own, safety gate, browser lesson engine)
912d04e | """ | |
| 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) | |