Phase 3: Unit Tests für Service Layer implementiert
54 Unit Tests hinzugefügt für die wichtigsten Service-Klassen: ✅ LessonService (17 Tests) - Lektions-Validierung und Navigation - Index-Wrapping (vor/zurück) - Fehlerbehandlung für ungültige Indizes - Edge Cases (leere Lektionen, Grenzen) ✅ StatisticsService (15 Tests) - Tippstatistik-Berechnungen (ZPM, WPM, Fehlerrate) - Geschwindigkeitsbegrenzungen (max 1000 ZPM, 200 WPM) - Zeit-Validierung (min. 1 Sekunde für realistische Werte) - Edge Cases (leere Eingabe, zu schnelle Eingabe) - Rundung und Formatierung ✅ SettingsService (22 Tests) - Validierung aller Einstellungsparameter - Metronom-Modus, -Geschwindigkeit, -Sound - Fehlerrate (0-100%), Max BPM (40-200) - Display-Modus (zpm/wpm) - Boundary Tests und Type-Safety Test-Framework: - Python unittest (eingebaut, keine Dependencies) - Fokus auf Business-Logik ohne DB-Zugriff - Alle Tests laufen in < 1 Sekunde - Ausführung: python -m unittest discover tests -v Dokumentation: - Umfassende README.md mit Beispielen - Test-Strategie und Best Practices - Troubleshooting-Guide Hinweis: - ProgressService Tests übersprungen (.skip) wegen SQLAlchemy Mocking-Komplexität - TrainingService nicht getestet (Orchestration, besser mit Integration Tests) Alle 54 Tests bestehen ✅
This commit is contained in:
parent
2ef3809e42
commit
bf892c8844
6 changed files with 1031 additions and 0 deletions
211
tests/README.md
Normal file
211
tests/README.md
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# Unit Tests für Typewriter Trainer Services
|
||||
|
||||
Dieses Verzeichnis enthält Unit Tests für die Service-Layer der Typewriter Trainer Anwendung.
|
||||
|
||||
## Test-Übersicht
|
||||
|
||||
### Erfolgreich getestete Services
|
||||
|
||||
| Service | Test-Datei | Anzahl Tests | Status |
|
||||
|---------|-----------|--------------|--------|
|
||||
| **LessonService** | `test_lesson_service.py` | 17 | ✅ Alle bestanden |
|
||||
| **StatisticsService** | `test_statistics_service.py` | 15 | ✅ Alle bestanden |
|
||||
| **SettingsService** | `test_settings_service.py` | 22 | ✅ Alle bestanden |
|
||||
|
||||
**Gesamt:** 54 Tests, alle bestanden ✅
|
||||
|
||||
### Hinweise
|
||||
|
||||
- **ProgressService Tests** (`test_progress_service.py.skip`): Diese Tests wurden übersprungen, da das Mocking von SQLAlchemy-Modellen zu komplex ist und einen vollständigen Flask-App-Context erfordern würde. Diese Funktionalität wird durch Integration-Tests abgedeckt.
|
||||
|
||||
- **TrainingService Tests**: Nicht implementiert, da TrainingService ein Orchestrations-Service ist, der hauptsächlich andere Services kombiniert. Diese Funktionalität wird besser durch Integration-Tests abgedeckt.
|
||||
|
||||
## Test-Strategie
|
||||
|
||||
### Was wird getestet?
|
||||
|
||||
#### LessonService (17 Tests)
|
||||
- Lektions-Validierung und Navigation
|
||||
- Index-Wrapping (Ende → Anfang)
|
||||
- Abrufen von Lektionsinformationen
|
||||
- Fehlerbehandlung bei ungültigen Indizes
|
||||
- Edge Cases (leere Lektionen, Grenzen)
|
||||
|
||||
#### StatisticsService (15 Tests)
|
||||
- Tippstatistik-Berechnungen (ZPM, WPM)
|
||||
- Fehlerraten-Berechnung
|
||||
- Genauigkeitsberechnung
|
||||
- Geschwindigkeitsbegrenzungen (max 1000 ZPM, 200 WPM)
|
||||
- Zeit-basierte Validierung (min. 1 Sekunde)
|
||||
- Edge Cases (leere Eingabe, zu schnelle Eingabe)
|
||||
- Rundung und Formatierung
|
||||
|
||||
#### SettingsService (22 Tests)
|
||||
- Validierung aller Einstellungsparameter:
|
||||
- Metronom-Modus (automatic/explicit)
|
||||
- Metronom-Geschwindigkeit (20-300 BPM)
|
||||
- Fehlerrate (0-100%)
|
||||
- Max BPM (40-200)
|
||||
- Display-Modus (zpm/wpm)
|
||||
- Metronom-Sound (beep/click/woodblock)
|
||||
- Grenzwerte und Boundary-Tests
|
||||
- Ungültige Werte
|
||||
- Type-Safety
|
||||
|
||||
### Test-Framework
|
||||
|
||||
- **Framework:** Python `unittest` (eingebaut, keine zusätzlichen Dependencies)
|
||||
- **Mocking:** `unittest.mock` für komplexe Dependencies
|
||||
- **Fokus:** Unit Tests für Business-Logik ohne Datenbankzugriff
|
||||
|
||||
### Warum unittest statt pytest?
|
||||
|
||||
- Bereits im Projekt verwendet (siehe `test_app.py`)
|
||||
- Keine zusätzlichen Dependencies
|
||||
- Standard Python-Tool
|
||||
- Ausreichend für Service-Layer Unit Tests
|
||||
|
||||
## Tests ausführen
|
||||
|
||||
### Alle Tests ausführen
|
||||
|
||||
```bash
|
||||
# Alle Tests im tests/ Verzeichnis
|
||||
python -m unittest discover -s tests -p "test_*.py" -v
|
||||
|
||||
# Oder einfach:
|
||||
python -m unittest discover tests -v
|
||||
```
|
||||
|
||||
### Einzelne Test-Dateien
|
||||
|
||||
```bash
|
||||
# LessonService Tests
|
||||
python -m unittest tests.test_lesson_service -v
|
||||
|
||||
# StatisticsService Tests
|
||||
python -m unittest tests.test_statistics_service -v
|
||||
|
||||
# SettingsService Tests
|
||||
python -m unittest tests.test_settings_service -v
|
||||
```
|
||||
|
||||
### Einzelne Test-Klassen
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_lesson_service.TestLessonService -v
|
||||
```
|
||||
|
||||
### Einzelne Tests
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_lesson_service.TestLessonService.test_validate_lesson_index_valid -v
|
||||
```
|
||||
|
||||
## Test-Coverage
|
||||
|
||||
Die Tests decken die wichtigsten Business-Logic-Methoden ab:
|
||||
|
||||
- ✅ **Validierungslogik:** Alle Validierungsmethoden getestet
|
||||
- ✅ **Berechnungslogik:** Alle Statistik-Berechnungen getestet
|
||||
- ✅ **Navigation:** Lektionswechsel und Index-Verwaltung
|
||||
- ✅ **Edge Cases:** Grenzwerte, ungültige Eingaben, leere Daten
|
||||
- ⚠️ **Datenbankoperationen:** Nicht durch Unit Tests abgedeckt (erfordert Integration Tests)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Was macht einen guten Unit Test aus?
|
||||
|
||||
1. **Isoliert:** Jeder Test testet genau eine Funktionalität
|
||||
2. **Schnell:** Alle 54 Tests laufen in < 1 Sekunde
|
||||
3. **Deterministisch:** Tests geben immer das gleiche Ergebnis
|
||||
4. **Lesbar:** Aussagekräftige Test-Namen und Docstrings
|
||||
5. **Unabhängig:** Tests können in beliebiger Reihenfolge ausgeführt werden
|
||||
|
||||
### Test-Namenskonvention
|
||||
|
||||
```python
|
||||
def test_<was_wird_getestet>_<erwartetes_verhalten>(self):
|
||||
"""Test: Beschreibung in Deutsch"""
|
||||
```
|
||||
|
||||
Beispiele:
|
||||
- `test_validate_lesson_index_valid` - Testet Validierung mit gültigem Index
|
||||
- `test_calculate_typing_statistics_perfect_input` - Testet Berechnung mit perfekter Eingabe
|
||||
- `test_validate_metronome_speed_too_high` - Testet Validierung mit zu hoher Geschwindigkeit
|
||||
|
||||
## Erweiterung der Tests
|
||||
|
||||
### Neue Tests hinzufügen
|
||||
|
||||
1. Erstelle eine neue Test-Datei: `tests/test_<service_name>.py`
|
||||
2. Importiere unittest und den zu testenden Service
|
||||
3. Erstelle eine Test-Klasse die von `unittest.TestCase` erbt
|
||||
4. Schreibe Test-Methoden mit dem Präfix `test_`
|
||||
5. Führe Tests aus mit `python -m unittest tests.test_<service_name> -v`
|
||||
|
||||
### Beispiel
|
||||
|
||||
```python
|
||||
import unittest
|
||||
from services.my_service import MyService
|
||||
|
||||
class TestMyService(unittest.TestCase):
|
||||
"""Tests für MyService"""
|
||||
|
||||
def setUp(self):
|
||||
"""Wird vor jedem Test ausgeführt"""
|
||||
self.service = MyService()
|
||||
|
||||
def test_my_method_valid_input(self):
|
||||
"""Test: my_method mit gültiger Eingabe"""
|
||||
result = self.service.my_method("valid")
|
||||
self.assertEqual(result, "expected")
|
||||
|
||||
def test_my_method_invalid_input(self):
|
||||
"""Test: my_method mit ungültiger Eingabe"""
|
||||
result = self.service.my_method("invalid")
|
||||
self.assertIsNone(result)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
Für vollständige Integration Tests (mit Datenbank, Flask-Context, etc.) siehe `test_app.py` im Hauptverzeichnis.
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Diese Tests können einfach in CI/CD-Pipelines integriert werden:
|
||||
|
||||
```bash
|
||||
# In GitHub Actions / GitLab CI
|
||||
python -m unittest discover tests -v
|
||||
```
|
||||
|
||||
Bei Fehlschlag gibt der Command einen Exit-Code ≠ 0 zurück.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: `ImportError: No module named 'services'`
|
||||
|
||||
**Lösung:** Tests müssen aus dem Projekt-Root ausgeführt werden:
|
||||
```bash
|
||||
cd /home/dschlueter/typewriter
|
||||
python -m unittest discover tests -v
|
||||
```
|
||||
|
||||
### Problem: SQLAlchemy-Fehler
|
||||
|
||||
**Lösung:** Services mit Datenbankzugriff erfordern Flask-App-Context. Siehe `test_app.py` für Beispiele mit `app.app_context()`.
|
||||
|
||||
### Problem: Tests schlagen intermittierend fehl
|
||||
|
||||
**Lösung:** Stelle sicher, dass Tests unabhängig sind und keine shared state verwenden. Nutze `setUp()` und `tearDown()` für Test-Isolation.
|
||||
|
||||
## Weitere Informationen
|
||||
|
||||
- [Python unittest Dokumentation](https://docs.python.org/3/library/unittest.html)
|
||||
- [Flask Testing Dokumentation](https://flask.palletsprojects.com/en/latest/testing/)
|
||||
- [SQLAlchemy Testing](https://docs.sqlalchemy.org/en/latest/orm/session_transaction.html)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
118
tests/test_lesson_service.py
Normal file
118
tests/test_lesson_service.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""
|
||||
Unit Tests für LessonService
|
||||
"""
|
||||
import unittest
|
||||
from services.lesson_service import LessonService
|
||||
|
||||
|
||||
class TestLessonService(unittest.TestCase):
|
||||
"""Tests für die LessonService-Klasse"""
|
||||
|
||||
def setUp(self):
|
||||
"""Setup für jeden Test - Erstellt Test-Lektionen"""
|
||||
self.test_lessons = [
|
||||
{"lesson": 1, "title": "Lektion 1", "text": "Test text 1"},
|
||||
{"lesson": 2, "title": "Lektion 2", "text": "Test text 2"},
|
||||
{"lesson": 3, "title": "Lektion 3", "text": "Test text 3"}
|
||||
]
|
||||
self.service = LessonService(self.test_lessons)
|
||||
|
||||
def test_init(self):
|
||||
"""Test: Initialisierung des Service"""
|
||||
self.assertEqual(len(self.service.lessons), 3)
|
||||
self.assertEqual(self.service.lessons[0]["title"], "Lektion 1")
|
||||
|
||||
def test_validate_lesson_index_valid(self):
|
||||
"""Test: Validierung eines gültigen Lektionsindex"""
|
||||
self.assertTrue(self.service.validate_lesson_index(0))
|
||||
self.assertTrue(self.service.validate_lesson_index(1))
|
||||
self.assertTrue(self.service.validate_lesson_index(2))
|
||||
|
||||
def test_validate_lesson_index_invalid(self):
|
||||
"""Test: Validierung ungültiger Lektionsindizes"""
|
||||
self.assertFalse(self.service.validate_lesson_index(-1))
|
||||
self.assertFalse(self.service.validate_lesson_index(3))
|
||||
self.assertFalse(self.service.validate_lesson_index(100))
|
||||
|
||||
def test_get_next_lesson_index_normal(self):
|
||||
"""Test: Nächster Lektionsindex im normalen Fall"""
|
||||
self.assertEqual(self.service.get_next_lesson_index(0), 1)
|
||||
self.assertEqual(self.service.get_next_lesson_index(1), 2)
|
||||
|
||||
def test_get_next_lesson_index_wrap_around(self):
|
||||
"""Test: Nächster Lektionsindex bei Wrap-Around (Ende -> Anfang)"""
|
||||
self.assertEqual(self.service.get_next_lesson_index(2), 0)
|
||||
|
||||
def test_get_previous_lesson_index_normal(self):
|
||||
"""Test: Vorheriger Lektionsindex im normalen Fall"""
|
||||
self.assertEqual(self.service.get_previous_lesson_index(1), 0)
|
||||
self.assertEqual(self.service.get_previous_lesson_index(2), 1)
|
||||
|
||||
def test_get_previous_lesson_index_wrap_around(self):
|
||||
"""Test: Vorheriger Lektionsindex bei Wrap-Around (Anfang -> Ende)"""
|
||||
self.assertEqual(self.service.get_previous_lesson_index(0), 2)
|
||||
|
||||
def test_get_lesson_text_valid(self):
|
||||
"""Test: Abrufen des Lektionstexts für gültigen Index"""
|
||||
self.assertEqual(self.service.get_lesson_text(0), "Test text 1")
|
||||
self.assertEqual(self.service.get_lesson_text(1), "Test text 2")
|
||||
self.assertEqual(self.service.get_lesson_text(2), "Test text 3")
|
||||
|
||||
def test_get_lesson_text_invalid(self):
|
||||
"""Test: Abrufen des Lektionstexts für ungültigen Index gibt Fallback"""
|
||||
self.assertEqual(self.service.get_lesson_text(-1), "Lektion nicht gefunden")
|
||||
self.assertEqual(self.service.get_lesson_text(3), "Lektion nicht gefunden")
|
||||
self.assertEqual(self.service.get_lesson_text(100), "Lektion nicht gefunden")
|
||||
|
||||
def test_get_lesson_title_valid(self):
|
||||
"""Test: Abrufen des Lektionstitels für gültigen Index"""
|
||||
self.assertEqual(self.service.get_lesson_title(0), "Lektion 1")
|
||||
self.assertEqual(self.service.get_lesson_title(1), "Lektion 2")
|
||||
self.assertEqual(self.service.get_lesson_title(2), "Lektion 3")
|
||||
|
||||
def test_get_lesson_title_invalid(self):
|
||||
"""Test: Abrufen des Lektionstitels für ungültigen Index gibt Fallback"""
|
||||
self.assertEqual(self.service.get_lesson_title(-1), "Unbekannte Lektion")
|
||||
self.assertEqual(self.service.get_lesson_title(3), "Unbekannte Lektion")
|
||||
|
||||
def test_get_lesson_info_valid(self):
|
||||
"""Test: Abrufen vollständiger Lektionsinfo"""
|
||||
info = self.service.get_lesson_info(0)
|
||||
self.assertEqual(info['index'], 0)
|
||||
self.assertEqual(info['text'], "Test text 1")
|
||||
self.assertEqual(info['title'], "Lektion 1")
|
||||
self.assertEqual(info['total'], 3)
|
||||
|
||||
def test_get_lesson_info_invalid(self):
|
||||
"""Test: Abrufen vollständiger Lektionsinfo für ungültigen Index"""
|
||||
info = self.service.get_lesson_info(100)
|
||||
self.assertEqual(info['index'], 100)
|
||||
self.assertEqual(info['text'], "Lektion nicht gefunden")
|
||||
self.assertEqual(info['title'], "Unbekannte Lektion")
|
||||
self.assertEqual(info['total'], 3)
|
||||
|
||||
def test_get_lesson_count(self):
|
||||
"""Test: Anzahl der Lektionen"""
|
||||
self.assertEqual(self.service.get_lesson_count(), 3)
|
||||
|
||||
def test_get_lesson_count_empty(self):
|
||||
"""Test: Anzahl der Lektionen bei leerer Liste"""
|
||||
empty_service = LessonService([])
|
||||
self.assertEqual(empty_service.get_lesson_count(), 0)
|
||||
|
||||
def test_get_all_lessons(self):
|
||||
"""Test: Abrufen aller Lektionen"""
|
||||
all_lessons = self.service.get_all_lessons()
|
||||
self.assertEqual(len(all_lessons), 3)
|
||||
self.assertEqual(all_lessons[0]["title"], "Lektion 1")
|
||||
self.assertEqual(all_lessons[2]["title"], "Lektion 3")
|
||||
|
||||
def test_get_all_lessons_returns_list(self):
|
||||
"""Test: get_all_lessons gibt eine Liste zurück"""
|
||||
lessons_list = self.service.get_all_lessons()
|
||||
self.assertIsInstance(lessons_list, list)
|
||||
self.assertEqual(len(lessons_list), 3)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
248
tests/test_progress_service.py.skip
Normal file
248
tests/test_progress_service.py.skip
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""
|
||||
Unit Tests für ProgressService
|
||||
"""
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from datetime import datetime
|
||||
from services.progress_service import ProgressService
|
||||
from models import Progress, LessonProgress
|
||||
|
||||
|
||||
class TestProgressService(unittest.TestCase):
|
||||
"""Tests für die ProgressService-Klasse"""
|
||||
|
||||
def setUp(self):
|
||||
"""Setup für jeden Test"""
|
||||
self.service = ProgressService()
|
||||
|
||||
# ========================
|
||||
# Global Progress Tests
|
||||
# ========================
|
||||
|
||||
@patch('services.progress_service.Progress')
|
||||
def test_get_or_create_global_progress_existing(self, mock_progress):
|
||||
"""Test: Abrufen eines existierenden Progress"""
|
||||
mock_prog = Mock(spec=Progress)
|
||||
mock_prog.current_text_index = 5
|
||||
mock_progress.query.first.return_value = mock_prog
|
||||
|
||||
result = ProgressService.get_or_create_global_progress()
|
||||
|
||||
self.assertEqual(result, mock_prog)
|
||||
self.assertEqual(result.current_text_index, 5)
|
||||
mock_progress.query.first.assert_called_once()
|
||||
|
||||
@patch('services.progress_service.db')
|
||||
@patch('services.progress_service.Progress')
|
||||
def test_get_or_create_global_progress_create_new(self, mock_progress, mock_db):
|
||||
"""Test: Erstellen eines neuen Progress wenn keiner existiert"""
|
||||
mock_progress.query.first.return_value = None
|
||||
new_progress = Mock(spec=Progress)
|
||||
mock_progress.return_value = new_progress
|
||||
|
||||
result = ProgressService.get_or_create_global_progress()
|
||||
|
||||
self.assertEqual(result, new_progress)
|
||||
mock_db.session.add.assert_called_once_with(new_progress)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
@patch('services.progress_service.Progress')
|
||||
def test_get_current_lesson_index(self, mock_progress):
|
||||
"""Test: Abrufen des aktuellen Lektionsindex"""
|
||||
mock_prog = Mock(spec=Progress)
|
||||
mock_prog.current_text_index = 3
|
||||
mock_progress.query.first.return_value = mock_prog
|
||||
|
||||
result = ProgressService.get_current_lesson_index()
|
||||
|
||||
self.assertEqual(result, 3)
|
||||
|
||||
@patch('services.progress_service.db')
|
||||
@patch('services.progress_service.Progress')
|
||||
def test_set_current_lesson_index(self, mock_progress, mock_db):
|
||||
"""Test: Setzen des aktuellen Lektionsindex"""
|
||||
mock_prog = Mock(spec=Progress)
|
||||
mock_progress.query.first.return_value = mock_prog
|
||||
|
||||
ProgressService.set_current_lesson_index(7)
|
||||
|
||||
self.assertEqual(mock_prog.current_text_index, 7)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
# ========================
|
||||
# Lesson Progress Tests
|
||||
# ========================
|
||||
|
||||
@patch('services.progress_service.LessonProgress')
|
||||
def test_get_or_create_lesson_progress_existing(self, mock_lesson_progress):
|
||||
"""Test: Abrufen eines existierenden LessonProgress"""
|
||||
mock_progress = Mock(spec=LessonProgress)
|
||||
mock_progress.current_position = 100
|
||||
mock_progress.last_text = "test input"
|
||||
mock_lesson_progress.query.filter_by.return_value.first.return_value = mock_progress
|
||||
|
||||
result = ProgressService.get_or_create_lesson_progress(2)
|
||||
|
||||
self.assertEqual(result, mock_progress)
|
||||
self.assertEqual(result.current_position, 100)
|
||||
mock_lesson_progress.query.filter_by.assert_called_once_with(lesson_index=2)
|
||||
|
||||
@patch('services.progress_service.db')
|
||||
@patch('services.progress_service.LessonProgress')
|
||||
def test_get_or_create_lesson_progress_create_new(self, mock_lesson_progress, mock_db):
|
||||
"""Test: Erstellen eines neuen LessonProgress"""
|
||||
mock_lesson_progress.query.filter_by.return_value.first.return_value = None
|
||||
new_progress = Mock(spec=LessonProgress)
|
||||
mock_lesson_progress.return_value = new_progress
|
||||
|
||||
result = ProgressService.get_or_create_lesson_progress(5)
|
||||
|
||||
mock_lesson_progress.assert_called_once_with(
|
||||
lesson_index=5,
|
||||
current_position=0,
|
||||
last_text="",
|
||||
total_elapsed_time=0,
|
||||
key_stroke_count=0,
|
||||
is_paused=True
|
||||
)
|
||||
mock_db.session.add.assert_called_once_with(new_progress)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
@patch('services.progress_service.db')
|
||||
@patch('services.progress_service.LessonProgress')
|
||||
def test_update_lesson_progress(self, mock_lesson_progress, mock_db):
|
||||
"""Test: Aktualisieren eines LessonProgress"""
|
||||
mock_progress = Mock(spec=LessonProgress)
|
||||
mock_lesson_progress.query.filter_by.return_value.first.return_value = mock_progress
|
||||
|
||||
ProgressService.update_lesson_progress(
|
||||
lesson_index=3,
|
||||
cursor_position=50,
|
||||
user_input="hello world",
|
||||
total_elapsed_time=120500,
|
||||
key_stroke_count=150,
|
||||
is_paused=False
|
||||
)
|
||||
|
||||
self.assertEqual(mock_progress.current_position, 50)
|
||||
self.assertEqual(mock_progress.last_text, "hello world")
|
||||
self.assertEqual(mock_progress.total_elapsed_time, 120500)
|
||||
self.assertEqual(mock_progress.key_stroke_count, 150)
|
||||
self.assertFalse(mock_progress.is_paused)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
@patch('services.progress_service.db')
|
||||
@patch('services.progress_service.LessonProgress')
|
||||
def test_reset_lesson_progress(self, mock_lesson_progress, mock_db):
|
||||
"""Test: Zurücksetzen eines LessonProgress"""
|
||||
mock_progress = Mock(spec=LessonProgress)
|
||||
mock_lesson_progress.query.filter_by.return_value.first.return_value = mock_progress
|
||||
|
||||
ProgressService.reset_lesson_progress(4)
|
||||
|
||||
self.assertEqual(mock_progress.current_position, 0)
|
||||
self.assertEqual(mock_progress.last_text, "")
|
||||
self.assertEqual(mock_progress.total_elapsed_time, 0)
|
||||
self.assertEqual(mock_progress.key_stroke_count, 0)
|
||||
self.assertTrue(mock_progress.is_paused)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
@patch('services.progress_service.LessonProgress')
|
||||
def test_get_lesson_progress_data(self, mock_lesson_progress):
|
||||
"""Test: Abrufen der Lesson Progress Daten"""
|
||||
mock_progress = Mock(spec=LessonProgress)
|
||||
mock_progress.current_position = 75
|
||||
mock_progress.last_text = "test"
|
||||
mock_progress.total_elapsed_time = 60.0
|
||||
mock_progress.key_stroke_count = 80
|
||||
mock_progress.is_paused = False
|
||||
mock_lesson_progress.query.filter_by.return_value.first.return_value = mock_progress
|
||||
|
||||
result = ProgressService.get_lesson_progress_data(1)
|
||||
|
||||
self.assertEqual(result['current_position'], 75)
|
||||
self.assertEqual(result['last_text'], "test")
|
||||
self.assertEqual(result['total_elapsed_time'], 60.0)
|
||||
self.assertEqual(result['key_stroke_count'], 80)
|
||||
self.assertFalse(result['is_paused'])
|
||||
|
||||
@patch('services.progress_service.db')
|
||||
@patch('services.progress_service.LessonProgress')
|
||||
def test_get_lesson_progress_data_creates_new_if_not_found(self, mock_lesson_progress, mock_db):
|
||||
"""Test: get_lesson_progress_data erstellt neuen Eintrag wenn nicht vorhanden"""
|
||||
# First call returns None, second call returns the new progress
|
||||
mock_lesson_progress.query.filter_by.return_value.first.side_effect = [None, Mock(
|
||||
current_position=0,
|
||||
last_text="",
|
||||
total_elapsed_time=0,
|
||||
key_stroke_count=0,
|
||||
is_paused=True
|
||||
)]
|
||||
new_progress = Mock(spec=LessonProgress)
|
||||
mock_lesson_progress.return_value = new_progress
|
||||
|
||||
result = ProgressService.get_lesson_progress_data(99)
|
||||
|
||||
# Should create new entry and return default values
|
||||
self.assertEqual(result['current_position'], 0)
|
||||
self.assertEqual(result['last_text'], "")
|
||||
|
||||
@patch('services.progress_service.db')
|
||||
@patch('services.progress_service.LessonProgress')
|
||||
def test_complete_lesson(self, mock_lesson_progress, mock_db):
|
||||
"""Test: Lektion abschließen"""
|
||||
mock_progress = Mock(spec=LessonProgress)
|
||||
mock_lesson_progress.query.filter_by.return_value.first.return_value = mock_progress
|
||||
|
||||
ProgressService.complete_lesson(5)
|
||||
|
||||
# Sollte Progress zurücksetzen
|
||||
self.assertEqual(mock_progress.current_position, 0)
|
||||
self.assertEqual(mock_progress.last_text, "")
|
||||
self.assertEqual(mock_progress.total_elapsed_time, 0)
|
||||
self.assertEqual(mock_progress.key_stroke_count, 0)
|
||||
self.assertTrue(mock_progress.is_paused)
|
||||
|
||||
# ========================
|
||||
# Completion Check Tests
|
||||
# ========================
|
||||
|
||||
def test_check_lesson_completed_true(self):
|
||||
"""Test: Lektion ist abgeschlossen"""
|
||||
result = ProgressService.check_lesson_completed(
|
||||
cursor_position=100,
|
||||
user_input_length=100,
|
||||
lesson_text_length=100
|
||||
)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_check_lesson_completed_cursor_not_at_end(self):
|
||||
"""Test: Lektion nicht abgeschlossen - Cursor nicht am Ende"""
|
||||
result = ProgressService.check_lesson_completed(
|
||||
cursor_position=50,
|
||||
user_input_length=100,
|
||||
lesson_text_length=100
|
||||
)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_check_lesson_completed_input_too_short(self):
|
||||
"""Test: Lektion nicht abgeschlossen - Eingabe zu kurz"""
|
||||
result = ProgressService.check_lesson_completed(
|
||||
cursor_position=100,
|
||||
user_input_length=50,
|
||||
lesson_text_length=100
|
||||
)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_check_lesson_completed_both_incomplete(self):
|
||||
"""Test: Lektion nicht abgeschlossen - beides unvollständig"""
|
||||
result = ProgressService.check_lesson_completed(
|
||||
cursor_position=50,
|
||||
user_input_length=50,
|
||||
lesson_text_length=100
|
||||
)
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
214
tests/test_settings_service.py
Normal file
214
tests/test_settings_service.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""
|
||||
Unit Tests für SettingsService
|
||||
"""
|
||||
import unittest
|
||||
from services.settings_service import SettingsService
|
||||
|
||||
|
||||
class TestSettingsServiceValidation(unittest.TestCase):
|
||||
"""Tests für Validierungsmethoden (ohne DB-Zugriff)"""
|
||||
|
||||
# ========================
|
||||
# Metronome Mode Validation
|
||||
# ========================
|
||||
|
||||
def test_validate_metronome_mode_automatic(self):
|
||||
"""Test: 'automatic' ist gültiger Metronom-Modus"""
|
||||
self.assertTrue(SettingsService.validate_metronome_mode('automatic'))
|
||||
|
||||
def test_validate_metronome_mode_explicit(self):
|
||||
"""Test: 'explicit' ist gültiger Metronom-Modus"""
|
||||
self.assertTrue(SettingsService.validate_metronome_mode('explicit'))
|
||||
|
||||
def test_validate_metronome_mode_invalid(self):
|
||||
"""Test: Ungültige Metronom-Modi"""
|
||||
self.assertFalse(SettingsService.validate_metronome_mode('manual'))
|
||||
self.assertFalse(SettingsService.validate_metronome_mode('auto'))
|
||||
self.assertFalse(SettingsService.validate_metronome_mode(''))
|
||||
self.assertFalse(SettingsService.validate_metronome_mode('AUTOMATIC'))
|
||||
|
||||
# ========================
|
||||
# Metronome Speed Validation
|
||||
# ========================
|
||||
|
||||
def test_validate_metronome_speed_valid_range(self):
|
||||
"""Test: Gültige Metronom-Geschwindigkeiten (20-300)"""
|
||||
self.assertTrue(SettingsService.validate_metronome_speed(20))
|
||||
self.assertTrue(SettingsService.validate_metronome_speed(60))
|
||||
self.assertTrue(SettingsService.validate_metronome_speed(100))
|
||||
self.assertTrue(SettingsService.validate_metronome_speed(150))
|
||||
self.assertTrue(SettingsService.validate_metronome_speed(300))
|
||||
|
||||
def test_validate_metronome_speed_too_low(self):
|
||||
"""Test: Zu niedrige Metronom-Geschwindigkeit"""
|
||||
self.assertFalse(SettingsService.validate_metronome_speed(19))
|
||||
self.assertFalse(SettingsService.validate_metronome_speed(0))
|
||||
self.assertFalse(SettingsService.validate_metronome_speed(-10))
|
||||
|
||||
def test_validate_metronome_speed_too_high(self):
|
||||
"""Test: Zu hohe Metronom-Geschwindigkeit"""
|
||||
self.assertFalse(SettingsService.validate_metronome_speed(301))
|
||||
self.assertFalse(SettingsService.validate_metronome_speed(500))
|
||||
self.assertFalse(SettingsService.validate_metronome_speed(1000))
|
||||
|
||||
def test_validate_metronome_speed_boundary_values(self):
|
||||
"""Test: Grenzwerte (20 und 300)"""
|
||||
self.assertTrue(SettingsService.validate_metronome_speed(20))
|
||||
self.assertTrue(SettingsService.validate_metronome_speed(300))
|
||||
self.assertFalse(SettingsService.validate_metronome_speed(19))
|
||||
self.assertFalse(SettingsService.validate_metronome_speed(301))
|
||||
|
||||
# ========================
|
||||
# Target Error Rate Validation
|
||||
# ========================
|
||||
|
||||
def test_validate_target_error_rate_valid_range(self):
|
||||
"""Test: Gültige Fehlerraten (0-100)"""
|
||||
self.assertTrue(SettingsService.validate_target_error_rate(0))
|
||||
self.assertTrue(SettingsService.validate_target_error_rate(5))
|
||||
self.assertTrue(SettingsService.validate_target_error_rate(10))
|
||||
self.assertTrue(SettingsService.validate_target_error_rate(50))
|
||||
self.assertTrue(SettingsService.validate_target_error_rate(100))
|
||||
|
||||
def test_validate_target_error_rate_negative(self):
|
||||
"""Test: Negative Fehlerraten sind ungültig"""
|
||||
self.assertFalse(SettingsService.validate_target_error_rate(-1))
|
||||
self.assertFalse(SettingsService.validate_target_error_rate(-10))
|
||||
|
||||
def test_validate_target_error_rate_too_high(self):
|
||||
"""Test: Fehlerraten über 100 sind ungültig"""
|
||||
self.assertFalse(SettingsService.validate_target_error_rate(101))
|
||||
self.assertFalse(SettingsService.validate_target_error_rate(200))
|
||||
|
||||
def test_validate_target_error_rate_boundary_values(self):
|
||||
"""Test: Grenzwerte (0 und 100)"""
|
||||
self.assertTrue(SettingsService.validate_target_error_rate(0))
|
||||
self.assertTrue(SettingsService.validate_target_error_rate(100))
|
||||
self.assertFalse(SettingsService.validate_target_error_rate(-1))
|
||||
self.assertFalse(SettingsService.validate_target_error_rate(101))
|
||||
|
||||
# ========================
|
||||
# Max BPM Speed Validation
|
||||
# ========================
|
||||
|
||||
def test_validate_max_bpm_speed_valid_range(self):
|
||||
"""Test: Gültige Max-BPM-Geschwindigkeiten (40-200)"""
|
||||
self.assertTrue(SettingsService.validate_max_bpm_speed(40))
|
||||
self.assertTrue(SettingsService.validate_max_bpm_speed(60))
|
||||
self.assertTrue(SettingsService.validate_max_bpm_speed(100))
|
||||
self.assertTrue(SettingsService.validate_max_bpm_speed(150))
|
||||
self.assertTrue(SettingsService.validate_max_bpm_speed(200))
|
||||
|
||||
def test_validate_max_bpm_speed_too_low(self):
|
||||
"""Test: Zu niedrige Max-BPM-Geschwindigkeit"""
|
||||
self.assertFalse(SettingsService.validate_max_bpm_speed(39))
|
||||
self.assertFalse(SettingsService.validate_max_bpm_speed(0))
|
||||
self.assertFalse(SettingsService.validate_max_bpm_speed(-10))
|
||||
|
||||
def test_validate_max_bpm_speed_too_high(self):
|
||||
"""Test: Zu hohe Max-BPM-Geschwindigkeit"""
|
||||
self.assertFalse(SettingsService.validate_max_bpm_speed(201))
|
||||
self.assertFalse(SettingsService.validate_max_bpm_speed(300))
|
||||
self.assertFalse(SettingsService.validate_max_bpm_speed(500))
|
||||
|
||||
def test_validate_max_bpm_speed_boundary_values(self):
|
||||
"""Test: Grenzwerte (40 und 200)"""
|
||||
self.assertTrue(SettingsService.validate_max_bpm_speed(40))
|
||||
self.assertTrue(SettingsService.validate_max_bpm_speed(200))
|
||||
self.assertFalse(SettingsService.validate_max_bpm_speed(39))
|
||||
self.assertFalse(SettingsService.validate_max_bpm_speed(201))
|
||||
|
||||
# ========================
|
||||
# Speed Display Validation
|
||||
# ========================
|
||||
|
||||
def test_validate_speed_display_zpm(self):
|
||||
"""Test: 'zpm' ist gültiges Display-Format"""
|
||||
self.assertTrue(SettingsService.validate_speed_display('zpm'))
|
||||
|
||||
def test_validate_speed_display_wpm(self):
|
||||
"""Test: 'wpm' ist gültiges Display-Format"""
|
||||
self.assertTrue(SettingsService.validate_speed_display('wpm'))
|
||||
|
||||
def test_validate_speed_display_invalid(self):
|
||||
"""Test: Ungültige Display-Formate"""
|
||||
self.assertFalse(SettingsService.validate_speed_display('cpm'))
|
||||
self.assertFalse(SettingsService.validate_speed_display('bpm'))
|
||||
self.assertFalse(SettingsService.validate_speed_display('ZPM'))
|
||||
self.assertFalse(SettingsService.validate_speed_display(''))
|
||||
self.assertFalse(SettingsService.validate_speed_display('words'))
|
||||
|
||||
# ========================
|
||||
# Metronome Sound Validation
|
||||
# ========================
|
||||
|
||||
def test_validate_metronome_sound_valid(self):
|
||||
"""Test: Gültige Metronom-Sounds"""
|
||||
self.assertTrue(SettingsService.validate_metronome_sound('beep'))
|
||||
self.assertTrue(SettingsService.validate_metronome_sound('click'))
|
||||
self.assertTrue(SettingsService.validate_metronome_sound('woodblock'))
|
||||
|
||||
def test_validate_metronome_sound_invalid(self):
|
||||
"""Test: Ungültige Metronom-Sounds"""
|
||||
self.assertFalse(SettingsService.validate_metronome_sound('bell'))
|
||||
self.assertFalse(SettingsService.validate_metronome_sound('BEEP'))
|
||||
self.assertFalse(SettingsService.validate_metronome_sound(''))
|
||||
self.assertFalse(SettingsService.validate_metronome_sound('tick'))
|
||||
|
||||
# ========================
|
||||
# Edge Cases & Type Safety
|
||||
# ========================
|
||||
|
||||
def test_validate_with_string_numbers(self):
|
||||
"""Test: String-Zahlen sollten False geben (Type Safety)"""
|
||||
# Python's comparison operators werden String mit Int vergleichen
|
||||
# "100" < 20 gibt False, "100" <= 300 gibt True bei String-Vergleich
|
||||
# Aber diese Tests sollten idealerweise Type-safe sein
|
||||
try:
|
||||
result = SettingsService.validate_metronome_speed("60")
|
||||
# Wenn keine Exception, dann wurde String als Zahl behandelt
|
||||
# Das ist OK, aber nicht ideal
|
||||
self.assertIsInstance(result, bool)
|
||||
except TypeError:
|
||||
# Wenn TypeError, dann ist die Methode type-safe (gut!)
|
||||
pass
|
||||
|
||||
def test_validate_comprehensive_settings(self):
|
||||
"""Test: Umfassende Validierung eines kompletten Settings-Sets"""
|
||||
# Gültige Settings
|
||||
valid_settings = {
|
||||
'metronome_mode': 'automatic',
|
||||
'metronome_speed': 60,
|
||||
'target_error_rate': 5,
|
||||
'max_bpm_speed': 100,
|
||||
'speed_display': 'zpm',
|
||||
'metronome_sound': 'beep'
|
||||
}
|
||||
|
||||
self.assertTrue(SettingsService.validate_metronome_mode(valid_settings['metronome_mode']))
|
||||
self.assertTrue(SettingsService.validate_metronome_speed(valid_settings['metronome_speed']))
|
||||
self.assertTrue(SettingsService.validate_target_error_rate(valid_settings['target_error_rate']))
|
||||
self.assertTrue(SettingsService.validate_max_bpm_speed(valid_settings['max_bpm_speed']))
|
||||
self.assertTrue(SettingsService.validate_speed_display(valid_settings['speed_display']))
|
||||
self.assertTrue(SettingsService.validate_metronome_sound(valid_settings['metronome_sound']))
|
||||
|
||||
# Ungültige Settings
|
||||
invalid_settings = {
|
||||
'metronome_mode': 'manual',
|
||||
'metronome_speed': 400,
|
||||
'target_error_rate': 150,
|
||||
'max_bpm_speed': 300,
|
||||
'speed_display': 'cpm',
|
||||
'metronome_sound': 'bell'
|
||||
}
|
||||
|
||||
self.assertFalse(SettingsService.validate_metronome_mode(invalid_settings['metronome_mode']))
|
||||
self.assertFalse(SettingsService.validate_metronome_speed(invalid_settings['metronome_speed']))
|
||||
self.assertFalse(SettingsService.validate_target_error_rate(invalid_settings['target_error_rate']))
|
||||
self.assertFalse(SettingsService.validate_max_bpm_speed(invalid_settings['max_bpm_speed']))
|
||||
self.assertFalse(SettingsService.validate_speed_display(invalid_settings['speed_display']))
|
||||
self.assertFalse(SettingsService.validate_metronome_sound(invalid_settings['metronome_sound']))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
240
tests/test_statistics_service.py
Normal file
240
tests/test_statistics_service.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""
|
||||
Unit Tests für StatisticsService
|
||||
"""
|
||||
import unittest
|
||||
from services.statistics_service import StatisticsService
|
||||
|
||||
|
||||
class TestStatisticsServiceCalculations(unittest.TestCase):
|
||||
"""Tests für Statistik-Berechnungen (ohne DB-Zugriff)"""
|
||||
|
||||
def test_calculate_typing_statistics_perfect_input(self):
|
||||
"""Test: Perfekte Eingabe ohne Fehler"""
|
||||
user_input = "hello world"
|
||||
current_text = "hello world"
|
||||
elapsed_time = 10.0 # 10 Sekunden
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
self.assertEqual(stats['correct_chars'], 11)
|
||||
self.assertEqual(stats['incorrect_chars'], 0)
|
||||
self.assertEqual(stats['total_chars'], 11)
|
||||
self.assertEqual(stats['error_rate'], 0.0)
|
||||
self.assertEqual(stats['accuracy'], 100.0)
|
||||
self.assertGreater(stats['typing_speed'], 0)
|
||||
self.assertGreater(stats['words_per_minute'], 0)
|
||||
|
||||
def test_calculate_typing_statistics_with_errors(self):
|
||||
"""Test: Eingabe mit Fehlern"""
|
||||
user_input = "hallo world" # 'a' statt 'e'
|
||||
current_text = "hello world"
|
||||
elapsed_time = 10.0
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
self.assertEqual(stats['correct_chars'], 10) # alle außer 'a'
|
||||
self.assertEqual(stats['incorrect_chars'], 1)
|
||||
self.assertEqual(stats['total_chars'], 11)
|
||||
self.assertGreater(stats['error_rate'], 0)
|
||||
self.assertLess(stats['accuracy'], 100.0)
|
||||
|
||||
def test_calculate_typing_statistics_all_wrong(self):
|
||||
"""Test: Alle Zeichen falsch"""
|
||||
user_input = "xxxxx"
|
||||
current_text = "hello"
|
||||
elapsed_time = 5.0
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
self.assertEqual(stats['correct_chars'], 0)
|
||||
self.assertEqual(stats['incorrect_chars'], 5)
|
||||
self.assertEqual(stats['error_rate'], 100.0)
|
||||
self.assertEqual(stats['accuracy'], 0.0)
|
||||
|
||||
def test_calculate_typing_statistics_empty_input(self):
|
||||
"""Test: Leere Eingabe"""
|
||||
user_input = ""
|
||||
current_text = "hello"
|
||||
elapsed_time = 5.0
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
self.assertEqual(stats['correct_chars'], 0)
|
||||
self.assertEqual(stats['incorrect_chars'], 0)
|
||||
self.assertEqual(stats['total_chars'], 0)
|
||||
self.assertEqual(stats['error_rate'], 0.0)
|
||||
self.assertEqual(stats['accuracy'], 0.0)
|
||||
|
||||
def test_calculate_typing_statistics_too_fast(self):
|
||||
"""Test: Zu schnelle Eingabe (< 1 Sekunde) gibt 0 zurück"""
|
||||
user_input = "hello"
|
||||
current_text = "hello"
|
||||
elapsed_time = 0.5 # Unter 1 Sekunde
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
# Sollte 0 zurückgeben wegen unrealistischer Zeit
|
||||
self.assertEqual(stats['typing_speed'], 0)
|
||||
self.assertEqual(stats['words_per_minute'], 0)
|
||||
|
||||
def test_calculate_typing_statistics_realistic_speed(self):
|
||||
"""Test: Realistische Tippgeschwindigkeit"""
|
||||
# 60 Zeichen in 60 Sekunden = 60 ZPM = 12 WPM
|
||||
user_input = "a" * 60
|
||||
current_text = "a" * 60
|
||||
elapsed_time = 60.0
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
# 60 chars / 60 sec * 60 = 60 ZPM
|
||||
self.assertEqual(stats['typing_speed'], 60)
|
||||
# 60 correct chars / 5 / 1 minute = 12 WPM
|
||||
self.assertEqual(stats['words_per_minute'], 12.0)
|
||||
|
||||
def test_calculate_typing_statistics_speed_limit(self):
|
||||
"""Test: Geschwindigkeitsbegrenzung (max 1000 ZPM, 200 WPM)"""
|
||||
# Unrealistisch schnelle Eingabe
|
||||
user_input = "a" * 1000
|
||||
current_text = "a" * 1000
|
||||
elapsed_time = 1.0 # 1 Sekunde
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
# Sollte auf max. Werte begrenzt sein
|
||||
self.assertLessEqual(stats['typing_speed'], 1000)
|
||||
self.assertLessEqual(stats['words_per_minute'], 200.0)
|
||||
|
||||
def test_calculate_typing_statistics_partial_correct(self):
|
||||
"""Test: Teilweise korrekte Eingabe"""
|
||||
user_input = "hello xxx" # Erste 5 korrekt, dann falsch
|
||||
current_text = "hello world"
|
||||
elapsed_time = 10.0
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
self.assertEqual(stats['correct_chars'], 6) # "hello "
|
||||
self.assertEqual(stats['incorrect_chars'], 3) # "xxx"
|
||||
self.assertEqual(stats['total_chars'], 9)
|
||||
|
||||
def test_calculate_typing_statistics_longer_than_text(self):
|
||||
"""Test: Eingabe länger als Zieltext (alle Extra-Zeichen sind Fehler)"""
|
||||
user_input = "hello world extra"
|
||||
current_text = "hello world"
|
||||
elapsed_time = 10.0
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
self.assertEqual(stats['correct_chars'], 11) # "hello world"
|
||||
self.assertEqual(stats['incorrect_chars'], 6) # " extra"
|
||||
self.assertEqual(stats['total_chars'], 17)
|
||||
|
||||
def test_calculate_typing_statistics_error_rate_calculation(self):
|
||||
"""Test: Fehlerrate-Berechnung"""
|
||||
# 10 Zeichen, 2 falsch = 20% Fehlerrate
|
||||
user_input = "heXlo worXd"
|
||||
current_text = "hello world"
|
||||
elapsed_time = 10.0
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
self.assertEqual(stats['incorrect_chars'], 2)
|
||||
self.assertEqual(stats['total_chars'], 11)
|
||||
# 2/11 * 100 ≈ 18.2%
|
||||
self.assertAlmostEqual(stats['error_rate'], 18.2, delta=0.1)
|
||||
self.assertAlmostEqual(stats['accuracy'], 81.8, delta=0.1)
|
||||
|
||||
def test_calculate_typing_statistics_duration_per_char(self):
|
||||
"""Test: Durchschnittliche Zeit pro Zeichen"""
|
||||
user_input = "hello" # 5 Zeichen
|
||||
current_text = "hello"
|
||||
elapsed_time = 5.0 # 5 Sekunden
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
# 5 sec / 5 chars = 1.0 sec/char
|
||||
self.assertEqual(stats['duration_per_char'], 1.0)
|
||||
|
||||
def test_calculate_typing_statistics_wpm_formula(self):
|
||||
"""Test: WPM-Formel (korrekte Zeichen / 5 / Minuten)"""
|
||||
# 100 korrekte Zeichen in 2 Minuten
|
||||
user_input = "a" * 100
|
||||
current_text = "a" * 100
|
||||
elapsed_time = 120.0 # 2 Minuten
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
# 100 / 5 / 2 = 10 WPM
|
||||
self.assertEqual(stats['words_per_minute'], 10.0)
|
||||
|
||||
def test_calculate_typing_statistics_rounding(self):
|
||||
"""Test: Rundung der Ergebnisse"""
|
||||
user_input = "hello world test"
|
||||
current_text = "hello world test"
|
||||
elapsed_time = 7.123 # Ungerade Zeit
|
||||
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
user_input, current_text, elapsed_time
|
||||
)
|
||||
|
||||
# Alle Werte sollten gerundet sein
|
||||
self.assertIsInstance(stats['typing_speed'], int) # Ganzzahl
|
||||
self.assertIsInstance(stats['error_rate'], float)
|
||||
self.assertIsInstance(stats['words_per_minute'], float)
|
||||
self.assertIsInstance(stats['accuracy'], float)
|
||||
|
||||
# Überprüfe Dezimalstellen
|
||||
error_rate_str = str(stats['error_rate'])
|
||||
if '.' in error_rate_str:
|
||||
decimals = len(error_rate_str.split('.')[1])
|
||||
self.assertLessEqual(decimals, 1) # Max 1 Dezimalstelle
|
||||
|
||||
|
||||
class TestStatisticsServiceHelpers(unittest.TestCase):
|
||||
"""Tests für Helper-Methoden ohne DB-Zugriff"""
|
||||
|
||||
def test_calculate_chars_per_minute_from_stats(self):
|
||||
"""Test: Berechnung aus Statistik-Dict"""
|
||||
# Wenn wir die typing_speed erhalten, sollte sie korrekt sein
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
"hello", "hello", 5.0
|
||||
)
|
||||
|
||||
# 5 chars / 5 sec * 60 = 60 ZPM
|
||||
self.assertEqual(stats['typing_speed'], 60)
|
||||
|
||||
def test_calculate_wpm_from_stats(self):
|
||||
"""Test: WPM-Berechnung aus Statistik-Dict"""
|
||||
stats = StatisticsService.calculate_typing_statistics(
|
||||
"hello", "hello", 5.0
|
||||
)
|
||||
|
||||
# 5 correct chars / 5 / (5/60 minutes) = 12 WPM
|
||||
self.assertEqual(stats['words_per_minute'], 12.0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue