282 lines
11 KiB
Python
282 lines
11 KiB
Python
"""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)
|