diff --git a/README.md b/README.md index 78a2338..7c10afb 100644 --- a/README.md +++ b/README.md @@ -69,4 +69,25 @@ Mapping von Input-Feldern auf Template-Zellen: - Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt - Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt -- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx` \ No newline at end of file +- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx` + +## Tests + +End-to-End-Tests parsen die reale Input-Datei (`input/2025 JM.xlsx`) und das reale +Template (`template/Tätigkeitsnachweise Stellenförderung.xlsx`) und validieren +den gesamten Generierungs-Workflow. + +```bash +# Test-Abhängigkeiten installieren +.venv/bin/pip install pytest + +# Tests ausführen +.venv/bin/python -m pytest tests/test_e2e.py -v +``` + +Die Tests prüfen: +- Vorhandensein der Input- und Template-Dateien +- Korrekte Parsierung (168 Datensätze, 19 Personen) +- Vollständige Pipeline für die erste Person („Draßdo, Cornelia"): 12 Monats-Sheets + mit allen Zellinhalten (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat, + Funktion, Tätigkeit, Ort, Datum) \ No newline at end of file diff --git a/test.command b/test.command new file mode 100755 index 0000000..5ba4365 --- /dev/null +++ b/test.command @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Doppelklickbares Test-Script für macOS (.command wird von Finder automatisch im Terminal geöffnet) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "========================================" +echo " Tätigkeitsnachweis-Generator – Tests" +echo "========================================" +echo "" + +VENV_DIR=".venv" + +if [[ ! -x "$VENV_DIR/bin/python" ]]; then + echo "[FEHLER] Virtuelle Umgebung nicht gefunden." + echo "Bitte zuerst start.command ausführen, um das Projekt einzurichten." + read -p "Drücke Enter zum Beenden..." + exit 1 +fi + +echo "[INFO] Stelle sicher, dass pytest installiert ist..." +"$VENV_DIR/bin/pip" install pytest --quiet 2>/dev/null || true + +echo "" +echo "[INFO] Führe End-to-End-Tests aus…" +echo "" + +"$VENV_DIR/bin/python" -m pytest tests/test_e2e.py -v + +echo "" +read -p "Drücke Enter zum Beenden..." diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..374012e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests für den Tätigkeitsnachweis-Generator.""" \ No newline at end of file diff --git a/tests/fixture/Tätigkeitsnachweise Stellenförderung.xlsx b/tests/fixture/Tätigkeitsnachweise Stellenförderung.xlsx new file mode 100644 index 0000000..c60d9e3 Binary files /dev/null and b/tests/fixture/Tätigkeitsnachweise Stellenförderung.xlsx differ diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..518a66e --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,213 @@ +"""End-to-End-Test: Reale Input-Datei + reales Template → Excel-Output für erste Person.""" + +import sys +from pathlib import Path + +import pytest +from openpyxl import load_workbook + +# Projekt-Root zum Python-Path hinzufügen +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.config import load_settings, load_input_schema, load_output_mapping +from src.parser import parse_input_file, group_by_employee, get_unique_employees +from src.writer import generate_activity_reports + + +# ────────────────────────────────────────────── +# Pfade zu den realen Fixture-Dateien +# ────────────────────────────────────────────── + +CONFIG_DIR = PROJECT_ROOT / "config" +INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx" +TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.xlsx" + + +def _load_app_config(): + """Lädt die echte App-Konfiguration (Schema, Mapping, Settings).""" + config = load_settings(CONFIG_DIR, PROJECT_ROOT) + config.input_schema = load_input_schema(CONFIG_DIR) + config.output_mapping = load_output_mapping(CONFIG_DIR) + return config + + +# ────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────── + +@pytest.fixture(scope="module") +def app_config(): + """Reale App-Konfiguration aus config/.""" + return _load_app_config() + + +@pytest.fixture(scope="module") +def parsed_records(app_config): + """Geparste Records aus der realen Input-Datei.""" + return parse_input_file( + filepath=INPUT_FILE, + schema=app_config.input_schema, + month_sheet_pattern=app_config.month_sheet_pattern, + skip_empty_name=app_config.skip_empty_name, + skip_empty_antragsnummer=app_config.skip_empty_antragsnummer, + ) + + +@pytest.fixture +def output_dir(tmp_path): + """Temporäres Output-Verzeichnis.""" + out = tmp_path / "output" + out.mkdir() + return out + + +# ────────────────────────────────────────────── +# End-to-End Tests +# ────────────────────────────────────────────── + +class TestEndToEndFirstPerson: + """End-to-End: Reale Input-Datei + reales Template → nur erste Person.""" + + def test_input_file_exists(self): + """Stellt sicher, dass die reale Input-Datei vorhanden ist.""" + assert INPUT_FILE.exists(), f"Input-Datei nicht gefunden: {INPUT_FILE}" + + def test_template_file_exists(self): + """Stellt sicher, dass das reale Template vorhanden ist.""" + assert TEMPLATE_FILE.exists(), f"Template nicht gefunden: {TEMPLATE_FILE}" + + def test_parse_real_input(self, parsed_records): + """Validiert das Parsen der realen Input-Datei.""" + # Sollte 168 Einträge über 12 Monate für 19 Personen ergeben + assert len(parsed_records) == 168, ( + f"Erwartet 168 Records, aber {len(parsed_records)} gefunden" + ) + + employees = get_unique_employees(parsed_records) + assert len(employees) == 19, ( + f"Erwartet 19 Mitarbeiter, aber {len(employees)} gefunden" + ) + + # Erste Person alphabetisch: Draßdo, Cornelia + assert employees[0] == "Draßdo, Cornelia", ( + f"Erwartet 'Draßdo, Cornelia' als erste Person, aber '{employees[0]}'" + ) + + def test_full_pipeline_first_person( + self, app_config, parsed_records, output_dir + ): + """Kompletter Workflow für die erste gefundene Person (Draßdo, Cornelia).""" + # 1. Nach Mitarbeiter gruppieren + grouped = group_by_employee(parsed_records) + assert len(grouped) == 19 + + # 2. Erste Person alphabetisch + first_person = sorted(grouped.keys())[0] + assert first_person == "Draßdo, Cornelia" + + first_records = grouped[first_person] + # Draßdo hat 12 Einträge (einen pro Monat) + assert len(first_records) == 12, ( + f"Erwartet 12 Records für {first_person}, aber {len(first_records)}" + ) + + # 3. Reports NUR für die erste Person generieren + single_person_group = {first_person: first_records} + generated = generate_activity_reports( + employee_records=single_person_group, + template_path=TEMPLATE_FILE, + mapping=app_config.output_mapping, + output_dir=output_dir, + filename_pattern=app_config.filename_pattern, + ) + + # 4. Ergebnisse prüfen + assert len(generated) == 1, ( + f"Erwartet 1 generierte Datei, aber {len(generated)}" + ) + output_file = generated[0] + assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}" + assert output_file.suffix == ".xlsx" + + erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.xlsx" + assert output_file.name == erwarteter_name, ( + f"Erwartet '{erwarteter_name}', aber '{output_file.name}'" + ) + + # 5. Output-Datei öffnen und Inhalt validieren + wb = load_workbook(output_file, data_only=True) + + # 12 sichtbare Sheets, Rest (36 - 12 = 24) ausgeblendet + visible_sheets = [s for s in wb.worksheets if s.sheet_state == "visible"] + assert len(visible_sheets) == 12, ( + f"Erwartet 12 sichtbare Sheets, aber {len(visible_sheets)}" + ) + + # Prüfe Sheet-Namen für alle 12 Monate + expected_titles = { + "01_Januar_Anl", "02_Februar_Anl", "03_März_Anl", + "04_April_Anl", "05_Mai_Anl", "06_Juni_Anl", + "07_Juli_Anl", "08_August_Anl", "09_September_Anl", + "10_Oktober_Anl", "11_November_Anl", "12_Dezember_Anl", + } + actual_titles = {ws.title for ws in visible_sheets} + assert actual_titles == expected_titles, ( + f"Erwartet {expected_titles},\n aber {actual_titles}" + ) + + # Prüfe exemplarisch das Januar-Sheet (01_Januar_Anl) + jan = wb["01_Januar_Anl"] + assert jan["B3"].value == "100740439", ( + f"Erwartet '100740439' in B3, aber '{jan['B3'].value}'" + ) + assert jan["B17"].value == "Draßdo, Cornelia", ( + f"Erwartet 'Draßdo, Cornelia' in B17, aber '{jan['B17'].value}'" + ) + # Stellenanteil: 0.5 → vom Parser zu "50" konvertiert (0 < x ≤ 1 → *100) + assert jan["R17"].value == "50", ( + f"Erwartet '50' in R17, aber '{jan['R17'].value}'" + ) + assert jan["B21"].value == "Meisterabschluß Küche", ( + f"Falsche Qualifikation in B21: '{jan['B21'].value}'" + ) + assert jan["R21"].value == "01.2025", ( + f"Erwartet '01.2025' in R21, aber '{jan['R21'].value}'" + ) + assert jan["B25"].value == "Anleiterin / Ausbilderin", ( + f"Falsche Funktion in B25: '{jan['B25'].value}'" + ) + # Tätigkeit beginnt mit "Tätigkeiten im Praxisbereich Garten…" + taetigkeit = jan["B31"].value or "" + assert taetigkeit.startswith("Tätigkeiten im Praxisbereich Garten"), ( + f"Tätigkeit in B31 beginnt nicht wie erwartet: '{taetigkeit[:60]}…'" + ) + # Ort wird hartkodiert auf "Dresden" gesetzt + assert jan["B95"].value == "Dresden", ( + f"Erwartet 'Dresden' in B95, aber '{jan['B95'].value}'" + ) + # Datum: letzter Tag des Monats Januar → 31.01.2025 + assert jan["J95"].value == "31.01.2025", ( + f"Erwartet '31.01.2025' in J95, aber '{jan['J95'].value}'" + ) + + # Prüfe exemplarisch das Juli-Sheet (Stellenanteil wechselt auf 40%) + jul = wb["07_Juli_Anl"] + assert jul["R17"].value == "40", ( + f"Erwartet '40' in R17 (Juli), aber '{jul['R17'].value}'" + ) + assert jul["R21"].value == "07.2025", ( + f"Erwartet '07.2025' in R21, aber '{jul['R21'].value}'" + ) + # Datum Juli → 31.07.2025 + assert jul["J95"].value == "31.07.2025", ( + f"Erwartet '31.07.2025' in J95, aber '{jul['J95'].value}'" + ) + + # Prüfe Dezember-Sheet (Datum: 31.12.2025) + dez = wb["12_Dezember_Anl"] + assert dez["J95"].value == "31.12.2025", ( + f"Erwartet '31.12.2025' in J95, aber '{dez['J95'].value}'" + ) + + wb.close()