Refactor (Welle C): Lektionen über app.config['LESSONS'] + Route-Tests

#7 Globalen Lessons-Zustand abgelöst:
- helpers.load_lessons() gibt die Liste zurück statt global zu mutieren;
  neue helpers.get_lessons() liest die einzige Quelle app.config['LESSONS']
- create_app befüllt config['LESSONS'] (Normalbetrieb) bzw. via Override
  (Tests); /repair_json und /recreate_lessons aktualisieren die Config
- Lazy-Init-Caches in training.py/statistics.py entfernt; Services werden
  pro Request frisch aus get_lessons() gebaut -> keine stale-state-Bugs mehr
- modul-globales helpers.lessons vollständig entfernt

#8 Route-Level-Tests (tests/test_routes.py, 11 Tests):
- GET /, /train, /statistics, /settings; POST /set_lesson, /next_text,
  /update_progress, /save_lesson_statistics; CSV-Export
- enthält Regressionstest für die leere Statistik-Seite (stale Import)

Gesamt: 91 Tests grün, ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-22 12:50:10 +02:00
commit 6502b7f826
6 changed files with 188 additions and 54 deletions

18
app.py
View file

@ -17,7 +17,7 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
def _load_lessons_or_exit(app: Flask) -> None:
def _load_lessons_or_exit(app: Flask) -> list:
"""Prüft die Datenbank und lädt die Lektionen (nur im Normalbetrieb)."""
with app.app_context():
db_ready = helpers.check_database_status(db.engine)
@ -29,14 +29,15 @@ def _load_lessons_or_exit(app: Flask) -> None:
sys.exit(1)
# Versuche Lektionen zu laden
helpers.load_lessons()
lessons = helpers.load_lessons()
# Falls keine Lektionen geladen wurden, erstelle Beispieldaten
if not helpers.lessons:
if not lessons:
logger.warning("Keine Lektionen verfügbar, erstelle Beispiel-Lektionen...")
helpers.lessons = helpers.create_sample_lessons()
lessons = helpers.create_sample_lessons()
logger.info(f"{len(helpers.lessons)} Lektionen geladen")
logger.info(f"{len(lessons)} Lektionen geladen")
return lessons
def create_app(config: dict | None = None) -> Flask:
@ -60,6 +61,9 @@ def create_app(config: dict | None = None) -> Flask:
# Pfade und Version
helpers.initialize_paths(app.root_path)
app.config['APP_VERSION'] = helpers.get_version()
# Lektionen als zentrale Quelle in der Config (Default: leer; im
# Normalbetrieb unten befüllt, in Tests via config-Override setzbar)
app.config.setdefault('LESSONS', [])
@app.context_processor
def inject_globals():
@ -87,7 +91,7 @@ def create_app(config: dict | None = None) -> Flask:
# Datenbank-Status prüfen und Lektionen laden (nur im Normalbetrieb)
if not app.config.get('TESTING', False) and os.environ.get('FLASK_TESTING') != 'true':
_load_lessons_or_exit(app)
app.config['LESSONS'] = _load_lessons_or_exit(app)
return app
@ -99,7 +103,7 @@ if __name__ == '__main__':
logger.info("=" * 50)
logger.info("Tipptrainer gestartet")
logger.info(f"Version: {app.config['APP_VERSION']}")
logger.info(f"Lektionen geladen: {len(helpers.lessons)}")
logger.info(f"Lektionen geladen: {len(app.config['LESSONS'])}")
logger.info("Debug-Info verfügbar unter: http://127.0.0.1:5000/debug")
logger.info("=" * 50)

View file

@ -23,17 +23,13 @@ statistics_bp = Blueprint('statistics', __name__)
statistics_service = StatisticsService()
progress_service = ProgressService()
# LessonService lazy initialisieren, da helpers.lessons erst nach
# load_lessons() befüllt ist (sonst leere Liste zur Importzeit).
_lesson_service = None
def _lesson_service() -> LessonService:
"""LessonService mit den aktuell geladenen Lektionen (pro Request).
def get_lesson_service() -> LessonService:
"""Lazy-Initialisierung des LessonService mit aktuellen Lektionen"""
global _lesson_service
if _lesson_service is None:
_lesson_service = LessonService(helpers.lessons)
return _lesson_service
Quelle ist helpers.get_lessons() (app.config['LESSONS']) kein
modul-globaler Cache.
"""
return LessonService(helpers.get_lessons())
@statistics_bp.route('/statistics')
@ -46,14 +42,14 @@ def statistics():
# Check which lessons have statistics
lesson_has_data = [
statistics_service.has_lesson_statistics(i)
for i in range(get_lesson_service().get_lesson_count())
for i in range(_lesson_service().get_lesson_count())
]
# Get speed display setting
speed_display = session.get('speed_display', 'zpm')
return render_template('statistics.html',
lessons=helpers.lessons,
lessons=helpers.get_lessons(),
current_lesson_index=current_lesson_index,
lesson_has_data=lesson_has_data,
speed_display=speed_display)
@ -95,7 +91,7 @@ def reset_lesson_statistics():
except (ValueError, TypeError):
return jsonify({'error': 'Ungültiger Lektionsindex'}), 400
if not get_lesson_service().validate_lesson_index(lesson_index):
if not _lesson_service().validate_lesson_index(lesson_index):
return jsonify({'error': 'Lektionsindex außerhalb des gültigen Bereichs'}), 400
# Delete statistics
@ -130,7 +126,7 @@ def export_statistics_csv() -> Response:
# Data
for stat in stats:
lesson_title = get_lesson_service().get_lesson_title(stat.lesson_index)
lesson_title = _lesson_service().get_lesson_title(stat.lesson_index)
writer.writerow([
stat.created_at.strftime('%Y-%m-%d %H:%M:%S'),
@ -169,7 +165,7 @@ def export_statistics_json() -> Response:
}
for stat in stats:
lesson_title = get_lesson_service().get_lesson_title(stat.lesson_index)
lesson_title = _lesson_service().get_lesson_title(stat.lesson_index)
export_data['statistics'].append({
'date': stat.created_at.strftime('%Y-%m-%d %H:%M:%S'),

View file

@ -14,25 +14,20 @@ logger = logging.getLogger(__name__)
training_bp = Blueprint('training', __name__)
# Initialize services (will be populated when lessons are loaded)
lesson_service = None
training_service = None
def _training_service() -> TrainingService:
"""Erstellt einen TrainingService mit den aktuell geladenen Lektionen.
def get_services():
"""Lazy initialization of services after lessons are loaded"""
global lesson_service, training_service
if lesson_service is None:
lesson_service = LessonService(helpers.lessons)
training_service = TrainingService(lesson_service)
return lesson_service, training_service
Pro Request frisch aus helpers.get_lessons() (app.config['LESSONS'])
kein modul-globaler Cache, daher keine stale-state-Probleme.
"""
return TrainingService(LessonService(helpers.get_lessons()))
@training_bp.route('/train')
def train():
"""Hauptseite des Tipptrainers"""
try:
_, training_service = get_services()
training_service = _training_service()
context = training_service.get_training_context()
return render_template('index.html', **context)
@ -82,7 +77,7 @@ def update_progress():
try:
# Update progress and get statistics
_, training_service = get_services()
training_service = _training_service()
stats = training_service.update_training_progress(
user_input=user_input,
current_text=current_text,
@ -110,7 +105,7 @@ def update_progress():
def next_text():
"""Wechselt zum nächsten Text"""
try:
_, training_service = get_services()
training_service = _training_service()
result = training_service.switch_to_next_lesson()
return jsonify(result)
@ -129,7 +124,7 @@ def set_lesson():
if lesson_index is None or not isinstance(lesson_index, int):
return jsonify({'error': 'Ungültiger Lektionsindex'}), 400
_, training_service = get_services()
training_service = _training_service()
success, result = training_service.switch_to_lesson(lesson_index)
if not success:
@ -167,7 +162,7 @@ def save_lesson_statistics():
return jsonify({'error': 'Ungültige Datentypen'}), 400
# Bereichsvalidierung
if lesson_index < 0 or lesson_index >= len(helpers.lessons):
if lesson_index < 0 or lesson_index >= len(helpers.get_lessons()):
return jsonify({'error': 'Ungültiger Lektionsindex'}), 400
if chars_per_minute < 0 or error_rate < 0 or wpm < 0 or training_duration < 0:
@ -201,7 +196,7 @@ def save_lesson_statistics():
def reset_lesson():
"""Setzt den Fortschritt der aktuellen Lektion zurück"""
try:
_, training_service = get_services()
training_service = _training_service()
success, result = training_service.reset_current_lesson()
if not success:

View file

@ -43,9 +43,10 @@ def debug():
json_content = f"Fehler beim Lesen der Datei: {e}"
json_error = str(e)
lessons = helpers.get_lessons()
debug_info = {
'lessons_loaded': len(helpers.lessons),
'lessons_sample': helpers.lessons[:3] if helpers.lessons else [], # Nur erste 3 anzeigen
'lessons_loaded': len(lessons),
'lessons_sample': lessons[:3] if lessons else [], # Nur erste 3 anzeigen
'data_path': helpers.DATA_PATH,
'file_exists': os.path.exists(helpers.DATA_PATH),
'file_size': os.path.getsize(helpers.DATA_PATH) if os.path.exists(helpers.DATA_PATH) else 0,
@ -87,13 +88,13 @@ def repair_json_endpoint():
with open(helpers.DATA_PATH, "w", encoding="utf-8") as f:
f.write(repaired_content)
# Lektionen neu laden
load_lessons()
# Lektionen neu laden und in der App-Config aktualisieren
current_app.config['LESSONS'] = load_lessons()
return jsonify({
'status': 'success',
'message': f'JSON erfolgreich repariert. Backup erstellt: {backup_path}',
'lessons_loaded': len(helpers.lessons)
'lessons_loaded': len(current_app.config['LESSONS'])
})
except json.JSONDecodeError as e:
@ -119,6 +120,7 @@ def recreate_lessons():
"""Endpoint zum Neuerstellen der Lektionen-Datei"""
try:
new_lessons = create_sample_lessons()
current_app.config['LESSONS'] = new_lessons
return jsonify({
'status': 'success',
'message': 'Beispiel-Lektionen erfolgreich erstellt',

View file

@ -15,7 +15,6 @@ from sqlalchemy import inspect
logger = logging.getLogger(__name__)
# Globale Variablen
lessons: List[Dict[str, Any]] = []
DATA_PATH: str = ""
# Fallback-Lektionen für den Fall, dass die JSON-Datei nicht geladen werden kann
@ -112,9 +111,12 @@ def repair_json(json_string: str) -> str:
return json_string
def load_lessons() -> None:
"""Lädt Lektionen aus der JSON-Datei oder verwendet Fallback"""
global lessons
def load_lessons() -> List[Dict[str, Any]]:
"""Lädt Lektionen aus der JSON-Datei und gibt sie zurück (sonst Fallback).
Mutiert keinen globalen Zustand mehr; die Aufrufer legen das Ergebnis in
app.config['LESSONS'] ab (siehe get_lessons()).
"""
try:
if os.path.exists(DATA_PATH):
with open(DATA_PATH, "r", encoding="utf-8") as f:
@ -126,27 +128,36 @@ def load_lessons() -> None:
try:
lessons = json.loads(content)
logger.info(f"Erfolgreich {len(lessons)} Lektionen aus {DATA_PATH} geladen")
return
return lessons
except json.JSONDecodeError as e:
logger.error(f"JSON-Fehler in {DATA_PATH}: Zeile {e.lineno}, Spalte {e.colno}: {e.msg}")
logger.warning(
"Verwende Fallback-Lektionen (in-memory). Die fehlerhafte Datei "
"bleibt unverändert; zur Reparatur den Endpoint POST /repair_json nutzen."
)
lessons = FALLBACK_LESSONS
return
return list(FALLBACK_LESSONS)
else:
logger.warning(f"Datei {DATA_PATH} nicht gefunden. Verwende Fallback-Lektionen und erstelle Datei.")
lessons = FALLBACK_LESSONS
# Speichere Fallback-Lektionen für zukünftige Verwendung
with open(DATA_PATH, "w", encoding="utf-8") as f:
json.dump(lessons, f, indent=2, ensure_ascii=False)
json.dump(FALLBACK_LESSONS, f, indent=2, ensure_ascii=False)
logger.info(f"Fallback-Lektionen gespeichert in: {DATA_PATH}")
return list(FALLBACK_LESSONS)
except Exception as e:
logger.error(f"Unerwarteter Fehler beim Laden der Lektionen: {e}")
logger.warning("Verwende Fallback-Lektionen")
lessons = FALLBACK_LESSONS
return list(FALLBACK_LESSONS)
def get_lessons() -> List[Dict[str, Any]]:
"""Gibt die aktuell geladenen Lektionen aus der App-Config zurück.
Einzige Zugriffsquelle für Routes/Services ersetzt den früheren
modul-globalen Zustand und verhindert stale Imports.
"""
from flask import current_app
return current_app.config.get('LESSONS', [])
def create_sample_lessons() -> List[Dict[str, Any]]:

126
tests/test_routes.py Normal file
View file

@ -0,0 +1,126 @@
"""
Route-Level Tests mit dem Flask-Testclient.
Fangen die Bug-Klasse ab, bei der Routes ihre Lektionen über stale Imports
bzw. modul-globale Caches bezogen (leere Statistik-Seite, "Unbekannte
Lektion" im Export). Nutzen die create_app()-Factory mit eigener Config.
"""
import unittest
import os
os.environ['FLASK_TESTING'] = 'true' # vor dem App-Import setzen
from app import create_app, db
TEST_LESSONS = [
{'lesson': '1.1', 'title': 'Lektion Eins', 'text': 'ffff jjjj', 'task': 'Grundstellung'},
{'lesson': '1.2', 'title': 'Lektion Zwei', 'text': 'dddd kkkk', 'task': ''},
{'lesson': '2', 'title': 'Lektion Drei', 'text': 'asdf jklö', 'task': 'Weiter'},
]
class RouteTestBase(unittest.TestCase):
"""Basis: Test-App mit In-Memory-DB, CSRF aus, eigene Lektionen."""
def setUp(self):
self.app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'WTF_CSRF_ENABLED': False,
'LESSONS': TEST_LESSONS,
})
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
self.client = self.app.test_client()
def tearDown(self):
db.session.remove()
db.drop_all()
self.ctx.pop()
class TestPageRoutes(RouteTestBase):
def test_welcome_ok(self):
self.assertEqual(self.client.get('/').status_code, 200)
def test_train_shows_first_lesson(self):
r = self.client.get('/train')
self.assertEqual(r.status_code, 200)
self.assertIn('ffff jjjj', r.get_data(as_text=True))
def test_statistics_lists_all_lessons(self):
# Regressionstest: ALLE Lektionen müssen als auswählbare Einträge auf
# der Statistik-Seite erscheinen (vormals leer durch stale Import in
# statistics.py -> lesson_has_data/lessons waren leer).
r = self.client.get('/statistics')
self.assertEqual(r.status_code, 200)
html = r.get_data(as_text=True)
for i in range(len(TEST_LESSONS)):
self.assertIn(f'data-lesson-index="{i}"', html)
def test_settings_ok(self):
self.assertEqual(self.client.get('/settings').status_code, 200)
class TestTrainingApi(RouteTestBase):
def test_set_lesson_switches(self):
r = self.client.post('/set_lesson', json={'lesson_index': 1})
self.assertEqual(r.status_code, 200)
data = r.get_json()
self.assertEqual(data['index'], 1)
self.assertIn('dddd kkkk', data['text'])
def test_set_lesson_invalid_index(self):
r = self.client.post('/set_lesson', json={'lesson_index': 999})
self.assertEqual(r.status_code, 400)
def test_next_text_ok(self):
self.assertEqual(self.client.post('/next_text').status_code, 200)
def test_update_progress_returns_stats(self):
r = self.client.post('/update_progress', json={
'user_input': 'ffff',
'current_text': 'ffff jjjj',
'elapsed_time': 5.0,
'cursor_position': 4,
'total_elapsed_time': 5000,
'net_training_time': 5000,
'key_stroke_count': 4,
'is_paused': False,
})
self.assertEqual(r.status_code, 200)
data = r.get_json()
self.assertIn('typing_speed', data)
self.assertIn('lesson_completed', data)
def test_save_lesson_statistics_rejects_out_of_range(self):
r = self.client.post('/save_lesson_statistics', json={
'lesson_index': 999, 'chars_per_minute': 100,
'error_rate': 2, 'wpm': 20, 'training_duration': 30,
})
self.assertEqual(r.status_code, 400)
class TestStatisticsApi(RouteTestBase):
def test_get_lesson_statistics_empty(self):
r = self.client.get('/get_lesson_statistics/0')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.get_json()['dates'], [])
def test_export_csv_uses_lesson_titles(self):
# Statistik anlegen, dann CSV exportieren Titel statt "Unbekannt".
self.client.post('/save_lesson_statistics', json={
'lesson_index': 0, 'chars_per_minute': 120,
'error_rate': 2.0, 'wpm': 24, 'training_duration': 30,
})
r = self.client.get('/export/statistics/csv')
self.assertEqual(r.status_code, 200)
self.assertIn('text/csv', r.content_type)
self.assertIn('Lektion Eins', r.get_data(as_text=True))
if __name__ == '__main__':
unittest.main()