diff --git a/app.py b/app.py index 5ac535f..d2430e2 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/routes/statistics.py b/routes/statistics.py index 6e945cd..b0fabd2 100644 --- a/routes/statistics.py +++ b/routes/statistics.py @@ -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'), diff --git a/routes/training.py b/routes/training.py index afeaa52..6b815d1 100644 --- a/routes/training.py +++ b/routes/training.py @@ -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: diff --git a/routes/utils.py b/routes/utils.py index 16647f2..4923540 100644 --- a/routes/utils.py +++ b/routes/utils.py @@ -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', diff --git a/services/helpers.py b/services/helpers.py index 3ca6e79..f03c482 100644 --- a/services/helpers.py +++ b/services/helpers.py @@ -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]]: diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..b22bf19 --- /dev/null +++ b/tests/test_routes.py @@ -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()