Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer
Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big analog watch over whatever page is open, built from the Delphin Uhr mockup without its swimming dolphin. The overlay stays mounted and is only hidden, so it appears at once. Beside the watch, only while they run: - The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25) that starts `bus_lead_minutes` (60) before. School days skip weekends and the school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily from openholidaysapi.org and cached under the library cache. A badge on the clock says whether there is school today, and why not. - The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time). It lives in the backend, so a parent's phone can start it for the kiosk. When it runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the device; it clears after a minute, or at the next key, tap or remote press. Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay bookmarked. The timer comes first there, the device settings below it. Unmapped IR buttons are logged at debug level, to find out what irserver calls a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
141
python-backend/tests/test_school_days.py
Normal file
141
python-backend/tests/test_school_days.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user