""" Tests for Magic Onboarding functionality. Tests cover: - OnboardingSession lifecycle - Student detection and confirmation - Roster parsing - School resolution - Module linking """ import pytest from unittest.mock import AsyncMock, MagicMock, patch from datetime import datetime # Import models from klausur.db_models import ( OnboardingSession, DetectedStudent, ModuleLink, OnboardingStatus, ModuleLinkType ) # Import services from klausur.services.roster_parser import RosterParser, RosterEntry, NameMatch from klausur.services.school_resolver import SchoolResolver, BUNDESLAENDER, SCHULFORMEN from klausur.services.module_linker import ( ModuleLinker, CorrectionResult, MeetingUrgency, ParentMeetingSuggestion ) # ============================================================================= # ROSTER PARSER TESTS # ============================================================================= class TestRosterParser: """Tests for RosterParser service.""" def test_match_first_names_exact_match(self): """Test exact name matching.""" parser = RosterParser() roster = [ RosterEntry(first_name="Max", last_name="Mueller"), RosterEntry(first_name="Anna", last_name="Schmidt"), RosterEntry(first_name="Tim", last_name="Weber"), ] detected = ["Max", "Anna", "Tim"] matches = parser.match_first_names(detected, roster) # Check all names matched assert len(matches) == 3 # Find Max match max_match = next(m for m in matches if m.detected_name == "Max") assert max_match.matched_entry is not None assert max_match.matched_entry.last_name == "Mueller" assert max_match.match_type == "exact" assert max_match.confidence == 1.0 def test_match_first_names_fuzzy_match(self): """Test fuzzy matching for similar names.""" parser = RosterParser() roster = [ RosterEntry(first_name="Maximilian", last_name="Mueller"), RosterEntry(first_name="Anna-Lena", last_name="Schmidt"), ] # "Max" should fuzzy-match "Maximilian" (starts with) detected = ["Max"] matches = parser.match_first_names(detected, roster) assert len(matches) == 1 max_match = matches[0] # Should match to Maximilian via first_name matching if max_match.matched_entry is not None: assert max_match.match_type in ["first_name", "fuzzy"] def test_match_first_names_no_match(self): """Test handling of unmatched names.""" parser = RosterParser() roster = [ RosterEntry(first_name="Max", last_name="Mueller"), ] detected = ["Sophie", "Lisa"] matches = parser.match_first_names(detected, roster) # Both should be unmatched assert len(matches) == 2 for match in matches: assert match.matched_entry is None assert match.match_type == "none" def test_roster_entry_creation(self): """Test RosterEntry dataclass creation.""" entry = RosterEntry( first_name="Max", last_name="Mueller", student_number="12345", parent_email="eltern@example.com", parent_phone="+49123456789" ) assert entry.first_name == "Max" assert entry.last_name == "Mueller" assert entry.parent_email == "eltern@example.com" def test_name_match_dataclass(self): """Test NameMatch dataclass creation.""" entry = RosterEntry(first_name="Max", last_name="Mueller") match = NameMatch( detected_name="Max", matched_entry=entry, confidence=1.0, match_type="exact" ) assert match.detected_name == "Max" assert match.matched_entry.last_name == "Mueller" assert match.confidence == 1.0 # ============================================================================= # SCHOOL RESOLVER TESTS # ============================================================================= class TestSchoolResolver: """Tests for SchoolResolver service.""" def test_bundeslaender_completeness(self): """Test that all 16 German states are included.""" assert len(BUNDESLAENDER) == 16 # BUNDESLAENDER is a dict with codes as keys assert "NI" in BUNDESLAENDER # Niedersachsen assert "BY" in BUNDESLAENDER # Bayern assert "BE" in BUNDESLAENDER # Berlin # Check values too assert BUNDESLAENDER["NI"] == "Niedersachsen" def test_schulformen_have_grades(self): """Test that each Schulform has grade ranges.""" for schulform, info in SCHULFORMEN.items(): assert "grades" in info assert isinstance(info["grades"], list) assert len(info["grades"]) > 0 def test_detect_grade_from_class_name(self): """Test grade detection from class names.""" resolver = SchoolResolver() # Test various formats assert resolver.detect_grade_from_class_name("3a") == 3 assert resolver.detect_grade_from_class_name("10b") == 10 assert resolver.detect_grade_from_class_name("Q1") == 11 assert resolver.detect_grade_from_class_name("Q2") == 12 assert resolver.detect_grade_from_class_name("12") == 12 def test_detect_grade_returns_none_for_invalid(self): """Test grade detection returns None for invalid input.""" resolver = SchoolResolver() assert resolver.detect_grade_from_class_name("abc") is None assert resolver.detect_grade_from_class_name("") is None def test_local_storage_initialization(self): """Test that local storage starts empty.""" resolver = SchoolResolver() assert resolver._local_schools == {} assert resolver._local_classes == {} # ============================================================================= # MODULE LINKER TESTS # ============================================================================= class TestModuleLinker: """Tests for ModuleLinker service.""" def test_suggest_elternabend_for_weak_students(self): """Test parent meeting suggestions for failing grades.""" linker = ModuleLinker() results = [ CorrectionResult( doc_token="token1", score=25, max_score=100, grade="5", feedback="" ), CorrectionResult( doc_token="token2", score=85, max_score=100, grade="2", feedback="" ), CorrectionResult( doc_token="token3", score=30, max_score=100, grade="5-", feedback="" ), CorrectionResult( doc_token="token4", score=20, max_score=100, grade="6", feedback="" ), ] suggestions = linker.suggest_elternabend( results, subject="Mathematik", threshold_grade="4" ) # Should suggest meetings for students with grades 4 or worse # Grades 5, 5-, and 6 should trigger meetings assert len(suggestions) == 3 # Verify suggestions use doc_tokens (privacy) for suggestion in suggestions: assert suggestion.doc_token in ["token1", "token3", "token4"] def test_suggest_elternabend_empty_for_good_class(self): """Test no suggestions for good performers.""" linker = ModuleLinker() results = [ CorrectionResult( doc_token="token1", score=95, max_score=100, grade="1", feedback="" ), CorrectionResult( doc_token="token2", score=85, max_score=100, grade="2", feedback="" ), CorrectionResult( doc_token="token3", score=78, max_score=100, grade="3", feedback="" ), ] suggestions = linker.suggest_elternabend( results, subject="Deutsch", threshold_grade="4" ) assert len(suggestions) == 0 def test_calculate_grade_statistics(self): """Test grade distribution calculation.""" linker = ModuleLinker() results = [ CorrectionResult(doc_token="t1", score=95, max_score=100, grade="1", feedback=""), CorrectionResult(doc_token="t2", score=85, max_score=100, grade="2", feedback=""), CorrectionResult(doc_token="t3", score=85, max_score=100, grade="2", feedback=""), CorrectionResult(doc_token="t4", score=75, max_score=100, grade="3", feedback=""), CorrectionResult(doc_token="t5", score=55, max_score=100, grade="4", feedback=""), CorrectionResult(doc_token="t6", score=25, max_score=100, grade="5", feedback=""), ] stats = linker.calculate_grade_statistics(results) assert isinstance(stats, dict) assert stats["count"] == 6 # Check grade distribution assert stats["distribution"].get("1", 0) == 1 assert stats["distribution"].get("2", 0) == 2 assert stats["distribution"].get("3", 0) == 1 # Check passing/failing counts assert stats["passing_count"] == 5 # Grades 1-4 pass assert stats["failing_count"] == 1 # Grade 5 fails def test_calculate_statistics_empty_results(self): """Test statistics with no results.""" linker = ModuleLinker() stats = linker.calculate_grade_statistics([]) assert stats == {} def test_correction_result_creation(self): """Test CorrectionResult dataclass.""" result = CorrectionResult( doc_token="abc-123", score=87, max_score=100, grade="2+", feedback="Gut geloest", question_results=[{"aufgabe": 1, "punkte": 10}] ) assert result.doc_token == "abc-123" assert result.score == 87 assert result.grade == "2+" # ============================================================================= # DB MODEL TESTS # ============================================================================= class TestOnboardingModels: """Tests for Magic Onboarding database models.""" def test_onboarding_status_enum_values(self): """Test OnboardingStatus enum has all required values.""" assert OnboardingStatus.ANALYZING.value == "analyzing" assert OnboardingStatus.CONFIRMING.value == "confirming" assert OnboardingStatus.PROCESSING.value == "processing" assert OnboardingStatus.LINKING.value == "linking" assert OnboardingStatus.COMPLETE.value == "complete" def test_module_link_type_enum_values(self): """Test ModuleLinkType enum has all required values.""" assert ModuleLinkType.NOTENBUCH.value == "notenbuch" assert ModuleLinkType.ELTERNABEND.value == "elternabend" assert ModuleLinkType.ZEUGNIS.value == "zeugnis" assert ModuleLinkType.CALENDAR.value == "calendar" assert ModuleLinkType.KLASSENBUCH.value == "klassenbuch" def test_onboarding_session_repr(self): """Test OnboardingSession string representation.""" session = OnboardingSession( id="12345678-1234-1234-1234-123456789abc", teacher_id="teacher-1", detected_class="3a", status=OnboardingStatus.ANALYZING ) repr_str = repr(session) assert "12345678" in repr_str assert "3a" in repr_str assert "analyzing" in repr_str def test_detected_student_repr(self): """Test DetectedStudent string representation.""" student = DetectedStudent( id="12345678-1234-1234-1234-123456789abc", detected_first_name="Max" ) repr_str = repr(student) assert "Max" in repr_str def test_module_link_repr(self): """Test ModuleLink string representation.""" link = ModuleLink( id="12345678-1234-1234-1234-123456789abc", klausur_session_id="session-1", link_type=ModuleLinkType.NOTENBUCH, target_module="school" ) repr_str = repr(link) assert "notenbuch" in repr_str assert "school" in repr_str # ============================================================================= # PRIVACY TESTS # ============================================================================= class TestPrivacyInMagicOnboarding: """Tests ensuring privacy is maintained in Magic Onboarding.""" def test_detected_student_no_full_last_name_in_detection(self): """Test that detection only captures hints, not full last names.""" student = DetectedStudent( id="12345678-1234-1234-1234-123456789abc", detected_first_name="Max", detected_last_name_hint="M." # Only initial/hint, not full name ) # The detection phase should only have hints assert student.detected_last_name_hint == "M." # Full name is only set after teacher confirmation assert student.confirmed_last_name is None def test_module_link_uses_doc_tokens_not_names(self): """Test that module links use pseudonymized tokens.""" linker = ModuleLinker() # Results should only contain doc_tokens, not student names results = [ CorrectionResult( doc_token="uuid-token-1", score=45, max_score=100, grade="4", feedback="" ), ] suggestions = linker.suggest_elternabend( results, subject="Deutsch", threshold_grade="4" ) # Suggestions reference doc_tokens, not names for suggestion in suggestions: assert hasattr(suggestion, 'doc_token') # Verify doc_token is the pseudonymized one assert suggestion.doc_token == "uuid-token-1" # ============================================================================= # INTEGRATION FLOW TESTS # ============================================================================= class TestMagicOnboardingFlow: """Tests for the complete Magic Onboarding flow.""" def test_onboarding_status_progression(self): """Test that status progresses correctly through the flow.""" statuses = list(OnboardingStatus) # Verify correct order assert statuses[0] == OnboardingStatus.ANALYZING assert statuses[1] == OnboardingStatus.CONFIRMING assert statuses[2] == OnboardingStatus.PROCESSING assert statuses[3] == OnboardingStatus.LINKING assert statuses[4] == OnboardingStatus.COMPLETE def test_grade_conversion_german_scale(self): """Test that German grading scale (1-6) is used correctly.""" linker = ModuleLinker() # Test the internal grade checking # Grades 1-4 are passing, 5-6 are failing results = [ CorrectionResult(doc_token="t1", score=95, max_score=100, grade="1", feedback=""), CorrectionResult(doc_token="t2", score=80, max_score=100, grade="2", feedback=""), CorrectionResult(doc_token="t3", score=65, max_score=100, grade="3", feedback=""), CorrectionResult(doc_token="t4", score=50, max_score=100, grade="4", feedback=""), CorrectionResult(doc_token="t5", score=30, max_score=100, grade="5", feedback=""), CorrectionResult(doc_token="t6", score=15, max_score=100, grade="6", feedback=""), ] stats = linker.calculate_grade_statistics(results) # 4 passing (grades 1-4), 2 failing (grades 5, 6) assert stats["passing_count"] == 4 assert stats["failing_count"] == 2 def test_meeting_urgency_levels(self): """Test meeting urgency assignment based on grades.""" linker = ModuleLinker() results = [ CorrectionResult(doc_token="t1", score=55, max_score=100, grade="4", feedback=""), CorrectionResult(doc_token="t2", score=30, max_score=100, grade="5", feedback=""), CorrectionResult(doc_token="t3", score=15, max_score=100, grade="6", feedback=""), ] suggestions = linker.suggest_elternabend( results, subject="Mathe", threshold_grade="4" ) # Verify urgency levels exist and are meaningful urgencies = [s.urgency for s in suggestions] assert len(urgencies) == 3 # Grade 6 should be high urgency grade_6_suggestion = next(s for s in suggestions if s.grade == "6") assert grade_6_suggestion.urgency == MeetingUrgency.HIGH