"""Which days are school days, from the OpenHolidays API's two lists.""" from __future__ import annotations from datetime import date from pathlib import Path from typing import Any import httpx2 import pytest from musicmouse.school_days import ( HolidayCalendar, fetch_calendar, load_calendar, parse_holidays, save_calendar, school_day, ) def _entry( start: str, end: str, name: str, *codes: str, nationwide: bool = False ) -> dict[str, Any]: """One entry in the API's shape (trimmed to the fields that are read).""" return { "startDate": start, "endDate": end, "name": [{"language": "DE", "text": name}], "nationwide": nationwide, "subdivisions": [{"code": code, "shortName": code[3:]} for code in codes], } #: Verbatim dates from the API for Bavaria, 2026/27. SCHOOL = [ _entry("2026-11-02", "2026-11-06", "Herbstferien", "DE-BY"), _entry("2026-11-18", "2026-11-18", "Buß- und Bettag", "DE-BY"), _entry("2026-12-24", "2027-01-08", "Weihnachtsferien", "DE-BY"), ] PUBLIC = [ _entry("2026-10-03", "2026-10-03", "Tag der Deutschen Einheit", nationwide=True), _entry("2027-05-27", "2027-05-27", "Fronleichnam", "DE-HE", "DE-BW", "DE-BY"), _entry("2027-08-09", "2027-08-09", "Friedensfest", "DE-BY-AU"), ] def _calendar(subdivision: str = "DE-BY") -> HolidayCalendar: return HolidayCalendar( fetched=date(2026, 10, 1), subdivision=subdivision, school=parse_holidays(SCHOOL), public=parse_holidays(PUBLIC), ) @pytest.mark.parametrize( ("day", "expected"), [ (date(2026, 10, 5), (True, None)), # a plain Monday (date(2026, 10, 10), (False, "Wochenende")), (date(2026, 10, 11), (False, "Wochenende")), (date(2026, 11, 4), (False, "Herbstferien")), (date(2026, 11, 9), (True, None)), # the Monday after (date(2026, 11, 18), (False, "Buß- und Bettag")), (date(2027, 1, 7), (False, "Weihnachtsferien")), # spans the new year (date(2027, 5, 27), (False, "Fronleichnam")), ], ) def test_school_days_in_bavaria(day: date, expected: tuple[bool, str | None]) -> None: assert school_day(day, _calendar()) == expected def test_a_nationwide_holiday_on_a_weekend_is_still_the_weekend() -> None: assert school_day(date(2026, 10, 3), _calendar()) == (False, "Wochenende") def test_a_city_holiday_counts_only_for_that_city() -> None: friedensfest = date(2027, 8, 9) # a Monday; pretend it is not in the summer holidays assert school_day(friedensfest, _calendar("DE-BY")) == (True, None) assert school_day(friedensfest, _calendar("DE-BY-AU")) == (False, "Friedensfest") def test_a_city_also_gets_its_states_holidays() -> None: assert school_day(date(2026, 11, 4), _calendar("DE-BY-AU")) == (False, "Herbstferien") def test_without_a_calendar_only_the_weekend_is_known() -> None: assert school_day(date(2026, 10, 5), None) == (None, None) assert school_day(date(2026, 10, 10), None) == (False, "Wochenende") def test_entries_that_do_not_parse_are_skipped() -> None: raw = [{"startDate": "nope"}, {"name": []}, SCHOOL[0]] assert [holiday.name for holiday in parse_holidays(raw)] == ["Herbstferien"] def test_the_cache_round_trips(tmp_path: Path) -> None: path = tmp_path / "cache" / "holidays.json" save_calendar(path, _calendar()) assert load_calendar(path, "DE-BY") == _calendar() def test_a_cache_for_another_subdivision_is_not_used(tmp_path: Path) -> None: path = tmp_path / "holidays.json" save_calendar(path, _calendar()) assert load_calendar(path, "DE-BW") is None def test_a_broken_or_missing_cache_is_no_cache(tmp_path: Path) -> None: path = tmp_path / "holidays.json" assert load_calendar(path, "DE-BY") is None path.write_text("{not json") assert load_calendar(path, "DE-BY") is None async def test_fetching_asks_for_the_state_and_both_lists() -> None: requests: list[httpx2.Request] = [] def handler(request: httpx2.Request) -> httpx2.Response: requests.append(request) body = SCHOOL if request.url.path == "/SchoolHolidays" else PUBLIC return httpx2.Response(200, json=body) async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: calendar = await fetch_calendar(client, "DE-BY-AU", date(2026, 10, 1)) assert [r.url.path for r in requests] == ["/SchoolHolidays", "/PublicHolidays"] assert all(r.url.params["subdivisionCode"] == "DE-BY" for r in requests) assert all(r.url.params["countryIsoCode"] == "DE" for r in requests) assert calendar.subdivision == "DE-BY-AU" assert calendar.fetched == date(2026, 10, 1) assert len(calendar.school) == 3 assert len(calendar.public) == 3 async def test_a_failed_fetch_raises() -> None: transport = httpx2.MockTransport(lambda _: httpx2.Response(503)) async with httpx2.AsyncClient(transport=transport) as client: with pytest.raises(httpx2.HTTPStatusError): await fetch_calendar(client, "DE-BY", date(2026, 10, 1))