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>
464 lines
14 KiB
Python
464 lines
14 KiB
Python
"""
|
|
Tests für State Engine.
|
|
|
|
Testet:
|
|
- Phasen-Management
|
|
- Antizipations-Regeln
|
|
- API Endpoints
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime, timedelta
|
|
from fastapi.testclient import TestClient
|
|
|
|
import sys
|
|
import os
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from main import app
|
|
from state_engine import (
|
|
AnticipationEngine,
|
|
PhaseService,
|
|
TeacherContext,
|
|
SchoolYearPhase,
|
|
ClassSummary,
|
|
Event,
|
|
TeacherStats,
|
|
Suggestion,
|
|
SuggestionPriority,
|
|
RULES
|
|
)
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
class TestSchoolYearPhase:
|
|
"""Tests für SchoolYearPhase."""
|
|
|
|
def test_all_phases_defined(self):
|
|
"""Testet dass alle 9 Phasen definiert sind."""
|
|
phases = list(SchoolYearPhase)
|
|
assert len(phases) == 9
|
|
assert SchoolYearPhase.ONBOARDING in phases
|
|
assert SchoolYearPhase.ARCHIVED in phases
|
|
|
|
def test_phase_values(self):
|
|
"""Testet Phase-Werte."""
|
|
assert SchoolYearPhase.ONBOARDING.value == "onboarding"
|
|
assert SchoolYearPhase.SEMESTER_END.value == "semester_end"
|
|
|
|
|
|
class TestTeacherContext:
|
|
"""Tests für TeacherContext."""
|
|
|
|
def test_create_context(self):
|
|
"""Testet Erstellung eines Kontexts."""
|
|
ctx = TeacherContext(
|
|
teacher_id="test-teacher",
|
|
school_id="test-school",
|
|
school_year_id="test-year"
|
|
)
|
|
|
|
assert ctx.teacher_id == "test-teacher"
|
|
assert ctx.current_phase == SchoolYearPhase.ONBOARDING
|
|
|
|
def test_has_completed_milestone(self):
|
|
"""Testet Meilenstein-Prüfung."""
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
completed_milestones=["consent_accept", "profile_complete"]
|
|
)
|
|
|
|
assert ctx.has_completed_milestone("consent_accept")
|
|
assert not ctx.has_completed_milestone("school_select")
|
|
|
|
def test_has_learning_units(self):
|
|
"""Testet Lerneinheiten-Prüfung."""
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
stats=TeacherStats(learning_units_created=0)
|
|
)
|
|
assert not ctx.has_learning_units()
|
|
|
|
ctx.stats.learning_units_created = 5
|
|
assert ctx.has_learning_units()
|
|
|
|
def test_to_dict(self):
|
|
"""Testet Konvertierung zu Dict."""
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test"
|
|
)
|
|
d = ctx.to_dict()
|
|
|
|
assert "teacher_id" in d
|
|
assert "current_phase" in d
|
|
assert d["current_phase"] == "onboarding"
|
|
|
|
|
|
class TestPhaseService:
|
|
"""Tests für PhaseService."""
|
|
|
|
def test_get_next_phase(self):
|
|
"""Testet nächste Phase."""
|
|
service = PhaseService()
|
|
|
|
assert service.get_next_phase(SchoolYearPhase.ONBOARDING) == SchoolYearPhase.SCHOOL_YEAR_START
|
|
assert service.get_next_phase(SchoolYearPhase.SCHOOL_YEAR_START) == SchoolYearPhase.TEACHING_SETUP
|
|
assert service.get_next_phase(SchoolYearPhase.ARCHIVED) is None
|
|
|
|
def test_check_and_transition_onboarding(self):
|
|
"""Testet automatischen Übergang von Onboarding."""
|
|
service = PhaseService()
|
|
|
|
# Ohne alle Meilensteine
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
current_phase=SchoolYearPhase.ONBOARDING,
|
|
completed_milestones=["school_select", "consent_accept"]
|
|
)
|
|
assert service.check_and_transition(ctx) is None
|
|
|
|
# Mit allen Meilensteinen
|
|
ctx.completed_milestones.append("profile_complete")
|
|
new_phase = service.check_and_transition(ctx)
|
|
assert new_phase == SchoolYearPhase.SCHOOL_YEAR_START
|
|
|
|
def test_can_transition_to(self):
|
|
"""Testet Übergangs-Prüfung."""
|
|
service = PhaseService()
|
|
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
current_phase=SchoolYearPhase.ONBOARDING,
|
|
completed_milestones=["school_select", "consent_accept", "profile_complete"]
|
|
)
|
|
|
|
# Erlaubter Übergang
|
|
assert service.can_transition_to(ctx, SchoolYearPhase.SCHOOL_YEAR_START)
|
|
|
|
# Nicht erlaubter Übergang (falsche Reihenfolge)
|
|
assert not service.can_transition_to(ctx, SchoolYearPhase.PERFORMANCE_1)
|
|
|
|
|
|
class TestAnticipationEngine:
|
|
"""Tests für AnticipationEngine."""
|
|
|
|
def test_get_suggestions_no_classes(self):
|
|
"""Testet Vorschläge ohne Klassen."""
|
|
engine = AnticipationEngine()
|
|
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
current_phase=SchoolYearPhase.SCHOOL_YEAR_START,
|
|
classes=[]
|
|
)
|
|
|
|
suggestions = engine.get_suggestions(ctx)
|
|
|
|
# Sollte "Erste Klasse anlegen" Vorschlag haben
|
|
assert any(s.id == "create_first_class" for s in suggestions)
|
|
|
|
def test_get_suggestions_priority_order(self):
|
|
"""Testet Priorisierung der Vorschläge."""
|
|
engine = AnticipationEngine()
|
|
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
current_phase=SchoolYearPhase.ONBOARDING,
|
|
completed_milestones=[]
|
|
)
|
|
|
|
suggestions = engine.get_suggestions(ctx)
|
|
|
|
# Vorschläge sollten nach Priorität sortiert sein
|
|
if len(suggestions) >= 2:
|
|
assert suggestions[0].priority.value <= suggestions[1].priority.value
|
|
|
|
def test_get_suggestions_max_limit(self):
|
|
"""Testet Limit von max. 5 Vorschlägen."""
|
|
engine = AnticipationEngine(max_suggestions=5)
|
|
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
current_phase=SchoolYearPhase.PERFORMANCE_1,
|
|
stats=TeacherStats(
|
|
exams_scheduled=5,
|
|
exams_graded=0,
|
|
unanswered_messages=10
|
|
),
|
|
upcoming_events=[
|
|
Event(
|
|
type="exam",
|
|
title="Test",
|
|
date=datetime.now() + timedelta(days=3),
|
|
in_days=3
|
|
)
|
|
]
|
|
)
|
|
|
|
suggestions = engine.get_suggestions(ctx)
|
|
assert len(suggestions) <= 5
|
|
|
|
def test_get_top_suggestion(self):
|
|
"""Testet Top-Vorschlag."""
|
|
engine = AnticipationEngine()
|
|
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
current_phase=SchoolYearPhase.SCHOOL_YEAR_START,
|
|
classes=[]
|
|
)
|
|
|
|
top = engine.get_top_suggestion(ctx)
|
|
assert top is not None
|
|
assert top.priority == SuggestionPriority.URGENT
|
|
|
|
def test_suggestions_by_category(self):
|
|
"""Testet Gruppierung nach Kategorie."""
|
|
engine = AnticipationEngine()
|
|
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
current_phase=SchoolYearPhase.ONBOARDING
|
|
)
|
|
|
|
by_category = engine.get_suggestions_by_category(ctx)
|
|
assert isinstance(by_category, dict)
|
|
|
|
|
|
class TestRules:
|
|
"""Tests für vordefinierte Regeln."""
|
|
|
|
def test_rules_count(self):
|
|
"""Testet Anzahl der Regeln."""
|
|
assert len(RULES) >= 15 # Mindestens 15 Regeln
|
|
|
|
def test_rule_no_classes(self):
|
|
"""Testet 'no_classes' Regel."""
|
|
rule = next(r for r in RULES if r.id == "no_classes")
|
|
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
current_phase=SchoolYearPhase.SCHOOL_YEAR_START,
|
|
classes=[]
|
|
)
|
|
|
|
suggestion = rule.evaluate(ctx)
|
|
assert suggestion is not None
|
|
assert suggestion.id == "create_first_class"
|
|
|
|
# Mit Klassen sollte keine Suggestion kommen
|
|
ctx.classes.append(ClassSummary(
|
|
class_id="1", name="7a", grade_level=7, student_count=25, subject="Deutsch"
|
|
))
|
|
assert rule.evaluate(ctx) is None
|
|
|
|
def test_rule_phase_restriction(self):
|
|
"""Testet Phasen-Einschränkung."""
|
|
rule = next(r for r in RULES if r.id == "no_classes")
|
|
|
|
# In erlaubter Phase
|
|
ctx = TeacherContext(
|
|
teacher_id="test",
|
|
school_id="test",
|
|
school_year_id="test",
|
|
current_phase=SchoolYearPhase.SCHOOL_YEAR_START,
|
|
classes=[]
|
|
)
|
|
assert rule.evaluate(ctx) is not None
|
|
|
|
# In nicht erlaubter Phase
|
|
ctx.current_phase = SchoolYearPhase.PERFORMANCE_1
|
|
assert rule.evaluate(ctx) is None
|
|
|
|
|
|
class TestStateEngineAPI:
|
|
"""Tests für State Engine API."""
|
|
|
|
def test_get_context(self):
|
|
"""Testet Context-Abruf."""
|
|
response = client.get("/api/state/context?teacher_id=test-api")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "context" in data
|
|
assert "phase_info" in data
|
|
|
|
def test_get_phase(self):
|
|
"""Testet Phasen-Abruf."""
|
|
response = client.get("/api/state/phase?teacher_id=test-api")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "current_phase" in data
|
|
assert "phase_info" in data
|
|
|
|
def test_get_all_phases(self):
|
|
"""Testet Abruf aller Phasen."""
|
|
response = client.get("/api/state/phases")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "phases" in data
|
|
assert len(data["phases"]) >= 8
|
|
|
|
def test_get_suggestions(self):
|
|
"""Testet Vorschläge-Abruf."""
|
|
response = client.get("/api/state/suggestions?teacher_id=test-api")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "suggestions" in data
|
|
assert "current_phase" in data
|
|
assert "priority_counts" in data
|
|
|
|
def test_get_top_suggestion(self):
|
|
"""Testet Top-Vorschlag-Abruf."""
|
|
response = client.get("/api/state/suggestions/top?teacher_id=test-api")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "suggestion" in data or "message" in data
|
|
|
|
def test_get_dashboard(self):
|
|
"""Testet Dashboard-Abruf."""
|
|
response = client.get("/api/state/dashboard?teacher_id=test-api")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "context" in data
|
|
assert "suggestions" in data
|
|
assert "stats" in data
|
|
assert "progress" in data
|
|
assert "phases" in data
|
|
|
|
def test_complete_milestone(self):
|
|
"""Testet Meilenstein-Abschluss."""
|
|
response = client.post(
|
|
"/api/state/milestone?teacher_id=milestone-test",
|
|
json={"milestone": "test_milestone"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert data["milestone"] == "test_milestone"
|
|
assert "test_milestone" in data["completed_milestones"]
|
|
|
|
def test_get_next_phase(self):
|
|
"""Testet nächste Phase."""
|
|
response = client.get("/api/state/next-phase?teacher_id=test-api")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "current_phase" in data
|
|
assert "next_phase" in data or "message" in data
|
|
|
|
|
|
class TestDemoEndpoints:
|
|
"""Tests für Demo-Endpoints."""
|
|
|
|
def test_demo_add_class(self):
|
|
"""Testet Demo-Klasse hinzufügen."""
|
|
response = client.post(
|
|
"/api/state/demo/add-class?teacher_id=demo-test&name=7a&grade_level=7&student_count=25"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert data["classes"] >= 1
|
|
|
|
def test_demo_add_event(self):
|
|
"""Testet Demo-Event hinzufügen."""
|
|
response = client.post(
|
|
"/api/state/demo/add-event?teacher_id=demo-test&event_type=exam&title=Mathe-Test&in_days=7"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
|
|
def test_demo_update_stats(self):
|
|
"""Testet Demo-Stats aktualisieren."""
|
|
response = client.post(
|
|
"/api/state/demo/update-stats?teacher_id=demo-test&learning_units=5&exams_scheduled=3"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["stats"]["learning_units_created"] == 5
|
|
assert data["stats"]["exams_scheduled"] == 3
|
|
|
|
def test_demo_reset(self):
|
|
"""Testet Demo-Reset."""
|
|
response = client.post("/api/state/demo/reset?teacher_id=demo-test")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
|
|
|
|
class TestPhaseTransitions:
|
|
"""Tests für Phasen-Übergänge."""
|
|
|
|
def test_manual_transition(self):
|
|
"""Testet manuellen Übergang."""
|
|
# Erst Kontext mit allen Voraussetzungen erstellen
|
|
client.post(
|
|
"/api/state/milestone?teacher_id=transition-test",
|
|
json={"milestone": "school_select"}
|
|
)
|
|
client.post(
|
|
"/api/state/milestone?teacher_id=transition-test",
|
|
json={"milestone": "consent_accept"}
|
|
)
|
|
client.post(
|
|
"/api/state/milestone?teacher_id=transition-test",
|
|
json={"milestone": "profile_complete"}
|
|
)
|
|
|
|
# Transition sollte automatisch erfolgt sein oder manuell möglich
|
|
response = client.get("/api/state/phase?teacher_id=transition-test")
|
|
assert response.status_code == 200
|
|
|
|
def test_invalid_transition(self):
|
|
"""Testet ungültigen Übergang."""
|
|
response = client.post(
|
|
"/api/state/transition?teacher_id=invalid-test",
|
|
json={"target_phase": "archived"} # Nicht erlaubt
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
def test_invalid_phase_name(self):
|
|
"""Testet ungültigen Phasen-Namen."""
|
|
response = client.post(
|
|
"/api/state/transition?teacher_id=test",
|
|
json={"target_phase": "invalid_phase"}
|
|
)
|
|
|
|
assert response.status_code == 400
|