erster versuch der KI mit Plan

This commit is contained in:
Rob committed 2026-03-27 13:18:07 +01:00
1 parent c697c64f3a
commit 2faa1689dd
20 files changed
+1749

No files matched your search

View File
Whitespace-only changes.
+278
View File
@@ -0,0 +1,278 @@
"""Tests for src/excel_reader.py — file-independent business logic."""
import re
from datetime import date
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src.exceptions import ExcelDataError, ExcelSheetError, YearExtractionError
from src.excel_reader import (
_parse_cell_value,
extract_year_from_filename,
extract_persons,
extract_attendance,
)
from src.models import Person
# ---------------------------------------------------------------------------
# Minimal config fixture (no real file needed)
# ---------------------------------------------------------------------------
@pytest.fixture
def cfg():
return {
"excel_year_pattern": "(19\\d{2}|20\\d{2})",
"excel_name_header": "Name, Vorname",
"excel_name_column": 1,
"excel_seq_column": 0,
"excel_day_start_col": 9,
"excel_day_end_col": 39,
"month_mapping": {
"Jan": "01", "Feb": "02", "Mär": "03", "Mrz": "03",
"Apr": "04", "Mai": "05", "Jun": "06", "Jul": "07",
"Aug": "08", "Sep": "09", "Okt": "10", "Nov": "11", "Dez": "12",
},
"valid_status_codes": ["K", "KK", "E", "F", "P", "U"],
"plan_hours_value": "6",
"pauschale_value": "8,60",
"antragsnummer": "12345",
"ort": "Teststadt",
"template_pdf": "template/sab.pdf",
"output_dir": "output",
}
# ---------------------------------------------------------------------------
# extract_year_from_filename
# ---------------------------------------------------------------------------
class TestExtractYearFromFilename:
def test_standard_filename(self, cfg):
assert extract_year_from_filename("Anwesenheit_2026.xlsx", cfg) == 2026
def test_year_embedded_in_path(self, cfg):
assert extract_year_from_filename("/data/exports/Anwesenheit_2024.xlsx", cfg) == 2024
def test_no_year_raises(self, cfg):
with pytest.raises(YearExtractionError):
extract_year_from_filename("Anwesenheit.xlsx", cfg)
def test_out_of_range_raises(self, cfg):
# Overwrite pattern to force out-of-range year
bad_cfg = {**cfg, "excel_year_pattern": "(1800)"}
with pytest.raises(YearExtractionError):
extract_year_from_filename("data_1800.xlsx", bad_cfg)
def test_first_year_match_wins(self, cfg):
# Two years in name — first one is used
assert extract_year_from_filename("2023_to_2025_export.xlsx", cfg) == 2023
# ---------------------------------------------------------------------------
# _parse_cell_value
# ---------------------------------------------------------------------------
class TestParseCellValue:
VALID_CODES = ["K", "KK", "E", "F", "P", "U"]
def test_numeric_int(self):
result = _parse_cell_value(6, self.VALID_CODES)
assert result == ("X", 6.0)
def test_numeric_float(self):
result = _parse_cell_value(6.5, self.VALID_CODES)
assert result == ("X", 6.5)
def test_zero_skipped(self):
assert _parse_cell_value(0, self.VALID_CODES) is None
def test_negative_skipped(self):
assert _parse_cell_value(-1, self.VALID_CODES) is None
def test_none_skipped(self):
assert _parse_cell_value(None, self.VALID_CODES) is None
def test_empty_string_skipped(self):
assert _parse_cell_value("", self.VALID_CODES) is None
assert _parse_cell_value(" ", self.VALID_CODES) is None
def test_status_codes(self):
for code in self.VALID_CODES:
result = _parse_cell_value(code, self.VALID_CODES)
assert result is not None
assert result[0] == code
assert result[1] is None
def test_lowercase_status_code(self):
result = _parse_cell_value("k", self.VALID_CODES)
assert result is not None
assert result[0] == "K"
def test_explicit_x_no_hours(self):
result = _parse_cell_value("X", self.VALID_CODES)
assert result == ("X", None)
def test_unknown_string_skipped(self):
assert _parse_cell_value("UNBEKANNT", self.VALID_CODES) is None
# ---------------------------------------------------------------------------
# extract_persons (mocked workbook)
# ---------------------------------------------------------------------------
def _make_row(*values):
"""Create a list of values as a mock row."""
return list(values)
def _sheet_data_persons():
"""Simulated sheet data: header row + 3 persons."""
return [
[None, "Einrichtung XY", None], # row 0 – ignored
[None, "Name, Vorname", None], # row 1 – header
[1, "Mustermann, Max", None], # row 2 – person 1
[2, "Beispiel, Anna", None], # row 3 – person 2
[3, "Test, Peter", None], # row 4 – person 3
[None, None, None], # row 5 – empty row
]
class TestExtractPersons:
def test_returns_correct_persons(self, cfg):
with patch("src.excel_reader._open_workbook") as mock_open, \
patch("src.excel_reader._sheet_data", return_value=_sheet_data_persons()):
mock_wb = MagicMock()
mock_wb.sheetnames = ["Feb"]
mock_open.return_value = mock_wb
persons = extract_persons("dummy.xlsx", "Feb", cfg)
assert len(persons) == 3
assert persons[0].seq_num == 1
assert persons[1].name == "Beispiel, Anna"
assert persons[2].seq_num == 3
def test_empty_rows_ignored(self, cfg):
with patch("src.excel_reader._open_workbook") as mock_open, \
patch("src.excel_reader._sheet_data", return_value=_sheet_data_persons()):
mock_open.return_value = MagicMock(sheetnames=["Feb"])
persons = extract_persons("dummy.xlsx", "Feb", cfg)
assert all(p.name for p in persons)
# ---------------------------------------------------------------------------
# extract_attendance (mocked workbook)
# ---------------------------------------------------------------------------
def _sheet_data_attendance():
"""
Simulated month sheet with one person row.
Columns 0-8: seq, name, and spare cols.
Columns 9-39: days 1-31.
Day 1 (col 9): 6.0 → present 6h
Day 2 (col 10): "K" → sick
Day 3 (col 11): "KK" → child sick
Day 4 (col 12): None → skip
Day 5 (col 13): "F" → unexcused absence
"""
person_row = [None] * 40
person_row[0] = 1
person_row[1] = "Mustermann, Max"
person_row[9] = 6.0
person_row[10] = "K"
person_row[11] = "KK"
person_row[12] = None
person_row[13] = "F"
return [
[None, "Name, Vorname"] + [None] * 38, # header row
person_row,
]
class TestExtractAttendance:
def test_present_day(self, cfg):
with patch("src.excel_reader._open_workbook") as mock_open, \
patch("src.excel_reader._sheet_data", return_value=_sheet_data_attendance()):
mock_open.return_value = MagicMock(sheetnames=["Feb"])
person = Person(seq_num=1, name="Mustermann, Max")
attendance = extract_attendance("dummy.xlsx", "Feb", person, 2026, cfg)
entry = attendance.entries[0]
assert entry.status == "X"
assert entry.hours == 6.0
assert entry.date == date(2026, 2, 1)
def test_sick_day(self, cfg):
with patch("src.excel_reader._open_workbook") as mock_open, \
patch("src.excel_reader._sheet_data", return_value=_sheet_data_attendance()):
mock_open.return_value = MagicMock(sheetnames=["Feb"])
person = Person(seq_num=1, name="Mustermann, Max")
attendance = extract_attendance("dummy.xlsx", "Feb", person, 2026, cfg)
entry = attendance.entries[1]
assert entry.status == "K"
assert entry.hours is None
def test_empty_day_skipped(self, cfg):
with patch("src.excel_reader._open_workbook") as mock_open, \
patch("src.excel_reader._sheet_data", return_value=_sheet_data_attendance()):
mock_open.return_value = MagicMock(sheetnames=["Feb"])
person = Person(seq_num=1, name="Mustermann, Max")
attendance = extract_attendance("dummy.xlsx", "Feb", person, 2026, cfg)
# Day 4 (None) is skipped, so entry index 2 is Day 3 (KK) and index 3 is Day 5 (F)
assert len(attendance.entries) == 4
assert attendance.entries[2].status == "KK"
assert attendance.entries[3].status == "F"
def test_max_23_entries(self, cfg):
"""More than 23 valid entries must be truncated to 23."""
person_row = [None] * 40
person_row[0] = 1
person_row[1] = "Viel, Anwesend"
for col in range(9, 40): # 31 days all present
person_row[col] = 6.0
sheet_data = [
[None, "Name, Vorname"] + [None] * 38,
person_row,
]
with patch("src.excel_reader._open_workbook") as mock_open, \
patch("src.excel_reader._sheet_data", return_value=sheet_data):
mock_open.return_value = MagicMock(sheetnames=["Jan"])
person = Person(seq_num=1, name="Viel, Anwesend")
attendance = extract_attendance("dummy.xlsx", "Jan", person, 2026, cfg)
assert len(attendance.entries) == 23
def test_invalid_month_raises(self, cfg):
with pytest.raises(ExcelSheetError):
extract_attendance("dummy.xlsx", "UNGÜLTIG", Person(1, "X, Y"), 2026, cfg)
def test_february_day_29_invalid_year(self, cfg):
"""Day 29 in Feb of a non-leap year must be skipped."""
person_row = [None] * 40
person_row[0] = 1
person_row[1] = "Schaltjahr, Test"
person_row[9 + 27] = 6.0 # day 28 = col 36 → valid
person_row[9 + 28] = 6.0 # day 29 = col 37 → invalid (2025 not leap year)
person_row[9 + 29] = 6.0 # day 30 = col 38 → invalid
sheet_data = [
[None, "Name, Vorname"] + [None] * 38,
person_row,
]
with patch("src.excel_reader._open_workbook") as mock_open, \
patch("src.excel_reader._sheet_data", return_value=sheet_data):
mock_open.return_value = MagicMock(sheetnames=["Feb"])
person = Person(seq_num=1, name="Schaltjahr, Test")
attendance = extract_attendance("dummy.xlsx", "Feb", person, 2025, cfg)
# Only day 28 is valid in Feb 2025
assert len(attendance.entries) == 1
assert attendance.entries[0].date == date(2025, 2, 28)
+172
View File
@@ -0,0 +1,172 @@
"""Tests for src/pdf_filler.py — field dict building and pauschale/plan logic."""
from datetime import date
import pytest
from src.models import AttendanceDate, MonthlyAttendance, Person
from src.pdf_filler import build_field_dict
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def cfg():
return {
"antragsnummer": "SAB-12345",
"ort": "Leipzig",
"template_pdf": "template/sab.pdf",
"output_dir": "output",
"plan_hours_value": "6",
"pauschale_value": "8,60",
"excel_year_pattern": "(20\\d{2})",
"excel_name_header": "Name, Vorname",
"excel_name_column": 1,
"excel_seq_column": 0,
"excel_day_start_col": 9,
"excel_day_end_col": 39,
"month_mapping": {"Feb": "02"},
"valid_status_codes": ["K", "KK", "E", "F", "P", "U"],
}
def _make_attendance(*entries) -> MonthlyAttendance:
person = Person(seq_num=1, name="Teilnehmer, Test")
return MonthlyAttendance(
person=person,
year=2026,
month=2,
month_name="Feb",
entries=list(entries),
)
def _present(day: int, hours: float) -> AttendanceDate:
return AttendanceDate(date=date(2026, 2, day), status="X", hours=hours)
def _absent(day: int, status: str) -> AttendanceDate:
return AttendanceDate(date=date(2026, 2, day), status=status, hours=None)
# ---------------------------------------------------------------------------
# Header fields
# ---------------------------------------------------------------------------
class TestHeaderFields:
def test_person_name(self, cfg):
fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1))
assert fields["person.name"] == "Teilnehmer, Test"
def test_monat_jahr(self, cfg):
fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1))
assert fields["monat_jahr"] == "02/2026"
def test_antragsnummer(self, cfg):
fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1))
assert fields["antragsnummer"] == "SAB-12345"
def test_ort(self, cfg):
fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1))
assert fields["ort"] == "Leipzig"
def test_erstellungsdatum(self, cfg):
fields = build_field_dict(_make_attendance(), cfg, date(2026, 3, 27))
assert fields["erstellungsdatum"] == "27.03.2026"
# ---------------------------------------------------------------------------
# Present day (status X)
# ---------------------------------------------------------------------------
class TestPresentDay:
def test_datum_field(self, cfg):
fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1))
assert fields["datum.0"] == "03.02.2026"
def test_anwesend_field(self, cfg):
fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1))
assert fields["anwesend.0"] == "X"
def test_ist_field_whole_hours(self, cfg):
fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1))
assert fields["ist.0"] == "6"
def test_ist_field_decimal_hours(self, cfg):
fields = build_field_dict(_make_attendance(_present(3, 6.5)), cfg, date(2026, 2, 1))
assert fields["ist.0"] == "6,5"
def test_plan_always_6(self, cfg):
fields = build_field_dict(_make_attendance(_present(3, 3.0)), cfg, date(2026, 2, 1))
assert fields["plan.0"] == "6"
def test_pauschale_set(self, cfg):
fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1))
assert fields["pauschale.0"] == "8,60"
# ---------------------------------------------------------------------------
# Absent days (K, KK, E, F, P, U)
# ---------------------------------------------------------------------------
class TestAbsentDay:
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
def test_anwesend_field(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["anwesend.0"] == status
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
def test_ist_empty(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["ist.0"] == ""
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
def test_plan_empty(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["plan.0"] == ""
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
def test_pauschale_empty(self, cfg, status):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
assert fields["pauschale.0"] == ""
# ---------------------------------------------------------------------------
# Multi-row indexing
# ---------------------------------------------------------------------------
class TestMultiRow:
def test_second_entry_indexed_as_1(self, cfg):
entries = [_present(1, 6.0), _absent(2, "K")]
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
assert fields["datum.1"] == "02.02.2026"
assert fields["anwesend.1"] == "K"
def test_23_entries_produce_index_0_to_22(self, cfg):
entries = [_present(i, 6.0) for i in range(1, 24)] # 23 entries max
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
# Index 22 should exist
assert "datum.22" in fields
assert "anwesend.22" in fields
# Index 23 must NOT exist
assert "datum.23" not in fields
# ---------------------------------------------------------------------------
# Hours formatting
# ---------------------------------------------------------------------------
class TestHoursFormatting:
def test_whole_number_no_decimal(self):
entry = AttendanceDate(date=date(2026, 2, 1), status="X", hours=6.0)
assert entry.hours_german() == "6"
def test_decimal_comma_separator(self):
entry = AttendanceDate(date=date(2026, 2, 1), status="X", hours=6.5)
assert entry.hours_german() == "6,5"
def test_none_returns_empty(self):
entry = AttendanceDate(date=date(2026, 2, 1), status="K", hours=None)
assert entry.hours_german() == ""
+155
View File
@@ -0,0 +1,155 @@
"""Tests for src/validators.py — pre-generation validation logic."""
import json
import re
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src.validators import validate
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_config(tmp_path: Path, overrides: dict = None) -> Path:
"""Write a valid config.json into tmp_path, optionally applying overrides."""
cfg = {
"antragsnummer": "12345",
"ort": "Teststadt",
"template_pdf": "template/sab.pdf",
"output_dir": "output",
"excel_year_pattern": "(19\\d{2}|20\\d{2})",
"excel_name_header": "Name, Vorname",
"excel_name_column": 1,
"excel_seq_column": 0,
"excel_day_start_col": 9,
"excel_day_end_col": 39,
"month_mapping": {"Feb": "02", "Jan": "01"},
"valid_status_codes": ["K", "KK", "E", "F", "P", "U"],
"plan_hours_value": "6",
"pauschale_value": "8,60",
}
if overrides:
cfg.update(overrides)
cfg_path = tmp_path / "config.json"
cfg_path.write_text(json.dumps(cfg), encoding="utf-8")
return cfg_path
def _write_dummy_xlsx(tmp_path: Path, name: str = "Anwesenheit_2026.xlsx") -> Path:
"""Create a minimal valid xlsx file using openpyxl."""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Feb"
# Header row
ws.cell(row=1, column=2, value="Name, Vorname")
# Day headers (columns J–AN = col 10–40 in 1-based)
for col in range(10, 41):
ws.cell(row=1, column=col, value=col - 9) # day number as header
# Person row
ws.cell(row=2, column=1, value=1)
ws.cell(row=2, column=2, value="Mustermann, Max")
ws.cell(row=2, column=10, value=6.0) # day 1 → present 6h
xlsx_path = tmp_path / name
wb.save(str(xlsx_path))
return xlsx_path
def _write_dummy_pdf(tmp_path: Path) -> Path:
"""Create a 1-byte placeholder file that passes existence checks."""
pdf_path = tmp_path / "template" / "sab.pdf"
pdf_path.parent.mkdir(parents=True, exist_ok=True)
pdf_path.write_bytes(b"%PDF-1.4 placeholder")
return pdf_path
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestValidate:
def test_all_checks_pass(self, tmp_path):
cfg_path = _write_config(tmp_path)
xlsx_path = _write_dummy_xlsx(tmp_path)
_write_dummy_pdf(tmp_path)
# Reset config cache so the test config is used
from src.config_loader import reset_config_cache
reset_config_cache()
errors = validate(xlsx_path, "Feb", cfg_path)
assert errors == []
def test_excel_file_missing(self, tmp_path):
cfg_path = _write_config(tmp_path)
_write_dummy_pdf(tmp_path)
from src.config_loader import reset_config_cache
reset_config_cache()
errors = validate(tmp_path / "nonexistent.xlsx", "Feb", cfg_path)
assert any("nicht gefunden" in e for e in errors)
def test_year_not_in_filename(self, tmp_path):
cfg_path = _write_config(tmp_path)
xlsx_path = _write_dummy_xlsx(tmp_path, name="Anwesenheit_kein_jahr.xlsx")
_write_dummy_pdf(tmp_path)
from src.config_loader import reset_config_cache
reset_config_cache()
errors = validate(xlsx_path, "Feb", cfg_path)
assert any("Jahr" in e for e in errors)
def test_unknown_sheet_name(self, tmp_path):
cfg_path = _write_config(tmp_path)
xlsx_path = _write_dummy_xlsx(tmp_path)
_write_dummy_pdf(tmp_path)
from src.config_loader import reset_config_cache
reset_config_cache()
errors = validate(xlsx_path, "Xyz", cfg_path)
assert any("month_mapping" in e or "Sheet" in e for e in errors)
def test_pdf_template_missing(self, tmp_path):
# No _write_dummy_pdf call → template missing
cfg_path = _write_config(tmp_path)
xlsx_path = _write_dummy_xlsx(tmp_path)
from src.config_loader import reset_config_cache
reset_config_cache()
errors = validate(xlsx_path, "Feb", cfg_path)
assert any("Template" in e or "template" in e.lower() for e in errors)
def test_config_missing_key(self, tmp_path):
cfg = {
"antragsnummer": "123",
# omit 'ort' and other required keys
}
cfg_path = tmp_path / "config.json"
cfg_path.write_text(json.dumps(cfg), encoding="utf-8")
from src.config_loader import reset_config_cache
reset_config_cache()
errors = validate(tmp_path / "file.xlsx", "Feb", cfg_path)
assert any("Konfigurationsfehler" in e or "fehlen" in e.lower() for e in errors)
def test_non_xlsx_extension_flagged(self, tmp_path):
cfg_path = _write_config(tmp_path)
_write_dummy_pdf(tmp_path)
csv_path = tmp_path / "Anwesenheit_2026.csv"
csv_path.write_text("dummy")
from src.config_loader import reset_config_cache
reset_config_cache()
errors = validate(csv_path, "Feb", cfg_path)
assert any(".xlsx" in e or "Excel" in e for e in errors)