| """Tests for Course Builder and Exam Coach Session services.""" |
| from __future__ import annotations |
|
|
| from app.services.course_builder import ( |
| build_course_plan, |
| course_plan_to_output, |
| _detect_course_type, |
| ) |
| from app.services.exam_coach_session import ( |
| build_exam_coach_session, |
| exam_coach_session_to_output, |
| _parse_daily_time, |
| _compute_exam_countdown, |
| ) |
| from app.services.pyq_pattern_analyzer import PatternAnalysisResult, TopicPattern |
|
|
|
|
| def test_learning_engine_routes_registered(client): |
| course_response = client.post( |
| "/generate/course-plan", |
| json={"raw_request": "Teach me machine learning"}, |
| ) |
| assert course_response.status_code == 200, course_response.text |
| assert course_response.json()["lessons_total"] > 0 |
|
|
| session_response = client.post( |
| "/generate/exam-coach-session", |
| json={ |
| "subject": "Physics", |
| "topic": "Electric Charges", |
| "daily_time": "30 min", |
| }, |
| ) |
| assert session_response.status_code == 200, session_response.text |
| assert session_response.json()["study_blocks"] |
|
|
|
|
| def test_learning_engine_routes_require_auth(auth_client): |
| response = auth_client.post( |
| "/generate/course-plan", |
| json={"raw_request": "Teach me machine learning"}, |
| ) |
| assert response.status_code in {401, 403} |
|
|
|
|
| |
|
|
| def test_course_plan_disaster_management_semester(): |
| result = build_course_plan( |
| raw_request="Teach me disaster management 4th semester degree", |
| semester="4", |
| degree="degree", |
| ) |
| assert result.title != "" |
| assert result.lessons_total > 0 |
| assert result.estimated_total_minutes > 0 |
| assert len(result.modules) > 0 |
| assert result.data_source_label in {"Starter seed", "Uploaded source"} |
| assert result.confidence > 0 |
|
|
|
|
| def test_course_plan_machine_learning(): |
| result = build_course_plan( |
| raw_request="Teach me machine learning", |
| ) |
| output = course_plan_to_output(result) |
| assert output["title"] != "" |
| assert output["lessons_total"] > 0 |
| assert len(output["modules"]) > 0 |
| assert "Machine Learning" in output["subject_area"] or "Machine Learning" in output["title"] |
| assert len(output["prerequisites"]) > 0 |
| assert len(output["practice_tasks"]) > 0 |
| assert len(output["quizzes"]) > 0 |
|
|
|
|
| def test_course_plan_with_source_context(): |
| result = build_course_plan( |
| raw_request="Teach me Physics chapter 1", |
| source_context="Electric charges and Coulomb's law are fundamental concepts...", |
| subject="Physics", |
| topic="Electric Charges and Fields", |
| ) |
| assert result.source_basis == "source_upload" |
| assert result.data_source_label == "Uploaded source" |
|
|
|
|
| def test_course_plan_source_ids_without_context_not_marked_uploaded(): |
| result = build_course_plan( |
| raw_request="Teach me Physics chapter 1", |
| source_ids=["missing-or-not-ready-source"], |
| subject="Physics", |
| topic="Electric Charges and Fields", |
| ) |
| assert result.source_basis == "catalog_seed" |
| assert result.data_source_label == "Starter seed" |
| assert any("no usable source text" in note.lower() for note in result.trust_notes) |
|
|
|
|
| def test_course_plan_no_source_uses_starter_seed(): |
| result = build_course_plan( |
| raw_request="Teach me Chemistry mole concept", |
| subject="Chemistry", |
| topic="Mole Concept", |
| ) |
| assert result.source_basis == "catalog_seed" |
| assert result.data_source_label == "Starter seed" |
| assert any("No source" in note for note in result.trust_notes) |
|
|
|
|
| def test_course_plan_degree_semester(): |
| result = build_course_plan( |
| raw_request="Teach me disaster management for 4th semester B.Tech", |
| semester="4", |
| degree="B.Tech", |
| ) |
| assert len(result.modules) >= 3 |
| assert result.lessons_total >= 8 |
|
|
|
|
| def test_course_plan_skill_course(): |
| result = build_course_plan( |
| raw_request="Learn Python programming from scratch", |
| ) |
| assert len(result.modules) >= 3 |
| assert any("Python" in m.title for m in result.modules) |
|
|
|
|
| def test_course_plan_playlist_metadata(): |
| result = build_course_plan( |
| raw_request="Turn this ML playlist into a course", |
| playlist_metadata={ |
| "topics": [ |
| "Linear Regression", |
| {"title": "Gradient Descent"}, |
| "Model Evaluation", |
| ], |
| }, |
| ) |
| assert result.learner_level == "Playlist" |
| assert result.lessons_total > 0 |
| assert any("Linear Regression" in module.title for module in result.modules) |
|
|
|
|
| def test_course_plan_confidence_increases_with_info(): |
| low = build_course_plan(raw_request="Teach something") |
| high = build_course_plan( |
| raw_request="Teach me +2 Physics chapter 1", |
| subject="Physics", |
| topic="Electric Charges", |
| class_level="+2", |
| ) |
| assert high.confidence > low.confidence |
|
|
|
|
| def test_course_plan_output_json_serializable(): |
| result = build_course_plan( |
| raw_request="Teach me Physics Ray Optics", |
| subject="Physics", |
| topic="Ray Optics", |
| ) |
| output = course_plan_to_output(result) |
| assert isinstance(output, dict) |
| assert "modules" in output |
| assert "quizzes" in output |
| assert "practice_tasks" in output |
| assert isinstance(output["modules"], list) |
|
|
|
|
| def test_course_plan_school_physics_chapter(): |
| result = build_course_plan( |
| raw_request="Teach me +2 Physics Ray Optics", |
| subject="Physics", |
| topic="Ray Optics", |
| ) |
| assert result.subject_area == "Physics" |
| assert len(result.modules) > 0 |
| assert result.lessons_total > 0 |
|
|
|
|
| def test_course_plan_school_tuition_contract_contains_required_sections(): |
| result = build_course_plan( |
| raw_request="Teach me lens maker formula", |
| class_level="Plus Two / Grade 12", |
| syllabus="Kerala HSE", |
| subject="Physics", |
| chapter="Ray Optics", |
| exam_date="2026-07-10", |
| daily_study_time="1 hour", |
| goal="board-exam", |
| ) |
| output = course_plan_to_output(result) |
|
|
| assert output["student_context"]["class_level"] == "Plus Two / Grade 12" |
| assert output["student_context"]["syllabus"] == "Kerala HSE" |
| assert output["student_context"]["subject"] == "Physics" |
| assert output["student_context"]["chapter"] == "Ray Optics" |
| assert output["student_context"]["exam_date"] == "2026-07-10" |
| assert output["student_context"]["daily_study_time"] == "1 hour" |
| assert output["lesson_outline"] |
| assert output["prerequisite_check"] |
| assert output["concept_explanation"]["explanation"] |
| assert output["derivation_or_problem_steps"][0]["steps"] |
| assert output["practice_questions"] |
| assert output["pyq_style_questions"] |
| assert output["weak_topic_repairs"] |
| assert output["revision_plan"] |
| assert output["estimated_study_time"] |
|
|
|
|
| def test_course_plan_route_accepts_full_school_context(client): |
| response = client.post( |
| "/generate/course-plan", |
| json={ |
| "raw_request": "Teach me Boolean logic", |
| "class_level": "Class 11", |
| "syllabus": "Kerala HSE", |
| "subject": "Computer Science", |
| "chapter": "Boolean Logic", |
| "exam_date": "2026-07-10", |
| "daily_study_time": "30 minutes", |
| "current_level": "basic", |
| "weak_topics": ["logic gates"], |
| "goal": "concept-clarity", |
| }, |
| ) |
|
|
| assert response.status_code == 200, response.text |
| data = response.json() |
| assert data["student_context"]["syllabus"] == "Kerala HSE" |
| assert data["student_context"]["chapter"] == "Boolean Logic" |
| assert data["student_context"]["daily_study_time"] == "30 minutes" |
| assert data["lesson_outline"] |
| assert data["practice_questions"] |
| assert data["revision_plan"] |
| assert "Student self-reported level: basic." in data["trust_notes"] |
| assert data["weak_topic_repairs"][0]["topic"] == "logic gates" |
|
|
|
|
| |
|
|
| def test_detect_course_type_degree(): |
| assert _detect_course_type("4th semester degree") == "degree" |
| assert _detect_course_type("B.Tech computer science") == "degree" |
| assert _detect_course_type("MCA 2nd sem") == "degree" |
|
|
|
|
| def test_detect_course_type_skill(): |
| assert _detect_course_type("Learn machine learning") == "skill" |
| assert _detect_course_type("Course on Python") == "skill" |
|
|
|
|
| def test_detect_course_type_topic(): |
| assert _detect_course_type("Teach me Physics chapter 1") == "topic" |
|
|
|
|
| |
|
|
| def test_exam_coach_session_plus2_physics_1hour(): |
| result = build_exam_coach_session( |
| class_level="+2", |
| board="Kerala HSE", |
| subject="Physics", |
| chapter="Electric Charges and Fields", |
| daily_time="1 hour", |
| ) |
| assert result.session_title != "" |
| assert result.time_budget == "1 hour" |
| assert len(result.study_blocks) > 0 |
| assert result.today_mission != "" |
| assert result.concept_teaching_task != {} |
| assert result.answer_writing_task != {} |
| assert result.revision_task != {} |
| assert result.quick_test != {} |
|
|
|
|
| def test_exam_coach_session_1_5_hour_derivation(): |
| result = build_exam_coach_session( |
| class_level="+2", |
| subject="Physics", |
| topic="Equations of Motion", |
| daily_time="1.5 hour", |
| ) |
| assert result.time_budget == "1.5 hour" |
| assert len(result.study_blocks) == 3 |
| assert result.derivation_or_numerical_task.get("task_type") == "derivation" |
|
|
|
|
| def test_exam_coach_session_no_pyq_no_fake(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="1 hour", |
| ) |
| pyq_task = result.pyq_task |
| assert pyq_task["has_evidence"] is False |
| assert "No uploaded PYQ" in pyq_task["description"] or "No PYQ" in pyq_task["data_source_label"] |
| assert any("No PYQ" in note for note in result.trust_notes) |
|
|
|
|
| def test_exam_coach_session_plus2_motion_does_not_leak_unrelated_seed(): |
| result = build_exam_coach_session( |
| class_level="+2", |
| subject="Physics", |
| topic="+2 Physics motion in a straight line", |
| daily_time="1 hour", |
| exam_date="in 20 days", |
| ) |
| concept_output = result.concept_teaching_task["output"] |
| rendered = str(concept_output).lower() |
|
|
| assert concept_output["chapter_title"] == "+2 Physics motion in a straight line" |
| assert concept_output["data_source_label"] == "Starter seed" |
| assert "electric charges" not in rendered |
| assert "coulomb" not in rendered |
| assert "gauss" not in rendered |
| assert result.pyq_task["has_evidence"] is False |
| assert result.data_source_label == "Starter seed" |
|
|
|
|
| def test_exam_coach_session_chemistry_numerical_focus(): |
| result = build_exam_coach_session( |
| subject="Chemistry", |
| topic="Chemistry mole concept numericals", |
| daily_time="1 hour", |
| ) |
| task = result.derivation_or_numerical_task |
| concept_output = result.concept_teaching_task["output"] |
|
|
| assert task["task_type"] == "numerical" |
| assert "numerical" in task["description"].lower() |
| assert "Chemistry mole concept numericals" == concept_output["chapter_title"] |
| assert "given data" in concept_output["board_answer_keywords"] |
| assert result.data_source_label == "Starter seed" |
|
|
|
|
| def test_exam_coach_session_maths_proof_focus(): |
| result = build_exam_coach_session( |
| subject="Maths", |
| topic="Maths trigonometry proof revision", |
| daily_time="1 hour", |
| ) |
| task = result.derivation_or_numerical_task |
| concept_output = result.concept_teaching_task["output"] |
|
|
| assert task["task_type"] == "proof" |
| assert "proof" in task["title"].lower() |
| assert "given" in concept_output["board_answer_keywords"] |
| assert "to prove" in concept_output["board_answer_keywords"] |
| assert "reason" in concept_output["board_answer_keywords"] |
|
|
|
|
| def test_exam_coach_session_unknown_topic_uses_requested_starter_seed(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Quantum banana curves", |
| daily_time="30 min", |
| ) |
| concept_output = result.concept_teaching_task["output"] |
| rendered = str(concept_output).lower() |
|
|
| assert concept_output["chapter_title"] == "Quantum banana curves" |
| assert concept_output["data_source_label"] == "Starter seed" |
| assert "electric charges" not in rendered |
| assert "ray optics" not in rendered |
| assert "coulomb" not in rendered |
| assert result.pyq_task["has_evidence"] is False |
|
|
|
|
| def test_exam_coach_session_with_pyq_evidence(): |
| pyq_data = [ |
| {"question_text": "State Coulomb's law", "marks": 3, "year": "2023"}, |
| {"question_text": "Derive electric field", "marks": 5, "year": "2022"}, |
| ] |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="1 hour", |
| pyq_data=pyq_data, |
| ) |
| pyq_task = result.pyq_task |
| assert pyq_task["has_evidence"] is True |
| assert "Uploaded PYQs" in pyq_task["data_source_label"] |
|
|
|
|
| def test_exam_coach_session_unknown_exam_date(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="1 hour", |
| exam_date=None, |
| ) |
| assert result.exam_countdown == "unknown" |
| assert any("Exam date not provided" in note for note in result.trust_notes) |
|
|
|
|
| def test_exam_coach_session_exam_tomorrow(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="1 hour", |
| exam_date="tomorrow", |
| ) |
| assert "tomorrow" in result.exam_countdown.lower() |
|
|
|
|
| def test_exam_coach_session_30_min(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="30 min", |
| ) |
| assert result.time_budget == "30 min" |
| assert len(result.study_blocks) == 1 |
| assert result.study_blocks[0].duration_minutes == 30 |
|
|
|
|
| def test_exam_coach_session_weak_topics(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="1 hour", |
| weak_topics=["Electromagnetic Induction", "Current Electricity"], |
| ) |
| revision = result.revision_task |
| assert "Electromagnetic Induction" in revision.get("revision_focus", []) |
| assert "Current Electricity" in revision.get("revision_focus", []) |
|
|
|
|
| def test_exam_coach_session_with_source(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="1 hour", |
| source_context="Electric charge is a fundamental property of matter...", |
| ) |
| assert result.data_source_label == "Uploaded source" |
| assert not any("No source" in note for note in result.trust_notes) |
|
|
|
|
| def test_exam_coach_session_source_ids_without_context_not_marked_uploaded(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="1 hour", |
| source_ids=["missing-or-not-ready-source"], |
| ) |
| assert result.data_source_label == "Starter seed" |
| assert any("no usable source text" in note.lower() for note in result.trust_notes) |
|
|
|
|
| def test_exam_coach_session_last_night_mode(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="last-night", |
| ) |
| assert result.time_budget == "last-night" |
| assert "Last-night revision" in result.today_mission |
| assert len(result.study_blocks) == 3 |
|
|
|
|
| def test_exam_coach_session_avoids_unsupported_high_yield_claims(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="30 min", |
| exam_date="tomorrow", |
| ) |
| rendered = str(exam_coach_session_to_output(result)).lower() |
| assert "high-yield" not in rendered |
| assert "sure shot" not in rendered |
| assert "guaranteed" not in rendered |
|
|
|
|
| def test_exam_coach_session_output_json_serializable(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="1 hour", |
| ) |
| output = exam_coach_session_to_output(result) |
| assert isinstance(output, dict) |
| assert "study_blocks" in output |
| assert "concept_teaching_task" in output |
| assert "quick_test" in output |
|
|
|
|
| def test_exam_coach_session_next_day_plan(): |
| result = build_exam_coach_session( |
| subject="Physics", |
| topic="Electric Charges", |
| daily_time="1 hour", |
| weak_topics=["Magnetism"], |
| ) |
| next_plan = result.next_day_plan |
| assert "Magnetism" in str(next_plan.get("suggested_topics", [])) |
|
|
|
|
| |
|
|
| def test_parse_daily_time_30_min(): |
| label, minutes = _parse_daily_time("30 min") |
| assert minutes == 30 |
|
|
|
|
| def test_parse_daily_time_1_hour(): |
| label, minutes = _parse_daily_time("1 hour") |
| assert minutes == 60 |
|
|
|
|
| def test_parse_daily_time_1_5_hour(): |
| label, minutes = _parse_daily_time("1.5 hour") |
| assert minutes == 90 |
|
|
|
|
| def test_parse_daily_time_last_night(): |
| label, minutes = _parse_daily_time("last-night") |
| assert label == "last-night" |
| assert minutes == 90 |
|
|
|
|
| def test_parse_daily_time_2_hours(): |
| label, minutes = _parse_daily_time("2 hours") |
| assert minutes == 120 |
|
|
|
|
| |
|
|
| def test_compute_exam_countdown_days(): |
| label, days = _compute_exam_countdown("3 days") |
| assert days == 3 |
| assert "3" in label |
|
|
|
|
| def test_compute_exam_countdown_weeks(): |
| label, days = _compute_exam_countdown("2 weeks") |
| assert days == 14 |
|
|
|
|
| def test_compute_exam_countdown_unknown(): |
| label, days = _compute_exam_countdown(None) |
| assert label == "unknown" |
| assert days is None |
|
|
|
|
| def test_compute_exam_countdown_tomorrow(): |
| label, days = _compute_exam_countdown("tomorrow") |
| assert days == 1 |
|
|
|
|
| |
|
|
| def test_course_plan_and_exam_session_same_subject(): |
| course = build_course_plan( |
| raw_request="Teach me +2 Physics", |
| subject="Physics", |
| ) |
| session = build_exam_coach_session( |
| subject="Physics", |
| daily_time="1 hour", |
| ) |
| assert course.subject_area == session.concept_teaching_task.get("subject", "Physics") |
|
|