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:
parent
c2663c0801
commit
6502b7f826
6 changed files with 188 additions and 54 deletions
18
app.py
18
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
126
tests/test_routes.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue