"""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} # === Course Builder Tests === 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" # === Detect Course Type Tests === 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" # === Exam Coach Session Tests === 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", [])) # === Parse Daily Time Tests === 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 # === Compute Exam Countdown Tests === 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 # === Integration: Course + Exam Session === 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")