fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
455
backend/klausur/tests/test_magic_onboarding.py
Normal file
455
backend/klausur/tests/test_magic_onboarding.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user