Files
sufw-anweisenheiten/tests/test_excel_reader.py
T

282 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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", "A", "V"],
"plan_hours_value": "6",
"pdf_field_ort": "unterschrift.ort",
"pdf_field_erstellungsdatum": "unterschrift.datum",
"pdf_pauschale_prefix": "pauschale_5",
"pauschale_present_value": "1",
"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)