"""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)