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>
456 lines
16 KiB
Python
456 lines
16 KiB
Python
"""
|
|
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
|