"""The parents' timer and today's school day, on virtual time and a pinned date.""" from __future__ import annotations import asyncio import io import wave from array import array from collections.abc import AsyncIterator, Callable from datetime import datetime, timedelta from pathlib import Path import httpx2 import pytest from musicmouse.app import AppState from musicmouse.blubb import RATE, chime_wav from musicmouse.bus import EventBus from musicmouse.clock import FakeClock from musicmouse.color import ColorRGBW from musicmouse.config import ClockConfig from musicmouse.effects import EffectStaticConfig from musicmouse.events import ( CancelTimerRequested, ClockStateChanged, LedEffectRequested, NextTrackRequested, SetTimerRequested, UserActivity, ) from musicmouse.hardware import LedZone from musicmouse.services.timers import RING_SECONDS, TimerService, timer_end from tests.test_school_days import PUBLIC, SCHOOL #: A Monday in the Herbstferien. HOLIDAY = datetime(2026, 11, 2, 18, 0) #: A plain school Monday. SCHOOL_DAY = datetime(2026, 10, 5, 6, 0) # --------------------------------------------------------------------------- chime def test_the_chime_is_a_short_wav_at_the_requested_level() -> None: wav = chime_wav(gain=0.3) with wave.open(io.BytesIO(wav)) as audio: assert audio.getnchannels() == 1 assert audio.getframerate() == RATE frames = audio.readframes(audio.getnframes()) assert 1.0 < audio.getnframes() / RATE < 3.0 peak = max(abs(sample) for sample in array("h", frames)) assert peak == round(32767 * 0.3) # ----------------------------------------------------------------------- timer_end def test_minutes_count_from_now() -> None: assert timer_end(datetime(2026, 10, 5, 18, 50), minutes=15) == datetime(2026, 10, 5, 19, 5) def test_a_time_later_today_is_today() -> None: now = datetime(2026, 10, 5, 18, 50, 30) assert timer_end(now, until="19:30") == datetime(2026, 10, 5, 19, 30) def test_a_time_already_past_is_tomorrow() -> None: now = datetime(2026, 10, 5, 23, 40) assert timer_end(now, until="00:10") == datetime(2026, 10, 6, 0, 10) assert timer_end(now, until="23:40") == datetime(2026, 10, 6, 23, 40) def test_exactly_one_of_minutes_and_until() -> None: with pytest.raises(ValueError): timer_end(HOLIDAY) with pytest.raises(ValueError): timer_end(HOLIDAY, minutes=5, until="19:00") # ------------------------------------------------------------------------- service class Harness: def __init__(self, bus: EventBus, clock: FakeClock, service: TimerService) -> None: self.bus = bus self.clock = clock self.service = service self.state = service.state self.changes = 0 bus.subscribe(ClockStateChanged, self._count) def _count(self, _event: ClockStateChanged) -> None: self.changes += 1 def now(self) -> float: return self.service._wall().timestamp() def _holiday_api(status: int = 200) -> Callable[[httpx2.Request], httpx2.Response]: def handler(request: httpx2.Request) -> httpx2.Response: if status != 200: return httpx2.Response(status) return httpx2.Response(200, json=SCHOOL if "School" in request.url.path else PUBLIC) return handler @pytest.fixture def start() -> datetime: return HOLIDAY @pytest.fixture def api_status() -> int: return 200 @pytest.fixture async def harness(tmp_path: Path, start: datetime, api_status: int) -> AsyncIterator[Harness]: async with EventBus() as bus: clock = FakeClock(idle=bus.drain) service = TimerService( bus, AppState(), ClockConfig(), cache_file=tmp_path / "holidays.json", client=httpx2.AsyncClient(transport=httpx2.MockTransport(_holiday_api(api_status))), alsa_device=None, volume=lambda: 0.5, clock=clock, wall=lambda: start + timedelta(seconds=clock.now()), ) task = asyncio.create_task(service.run()) await clock.advance(0) try: yield Harness(bus, clock, service) finally: task.cancel() with pytest.raises(asyncio.CancelledError): await task async def _set(h: Harness, minutes: float) -> None: await h.bus.emit_and_wait(SetTimerRequested(ends_at=h.now() + minutes * 60, source="web")) await h.clock.advance(0) async def test_a_timer_counts_down_then_rings(harness: Harness) -> None: await _set(harness, 5) timer = harness.state.parent_timer assert timer is not None and not timer.ringing assert timer.ends_at - timer.started_at == 300 await harness.clock.advance(299) assert not harness.state.parent_timer.ringing # type: ignore[union-attr] await harness.clock.advance(1) assert harness.state.parent_timer.ringing # type: ignore[union-attr] async def test_a_ringing_timer_clears_itself_after_a_minute(harness: Harness) -> None: await _set(harness, 1) await harness.clock.advance(60) assert harness.state.parent_timer is not None await harness.clock.advance(RING_SECONDS - 1) assert harness.state.parent_timer is not None await harness.clock.advance(1) assert harness.state.parent_timer is None async def test_anything_a_person_does_silences_it(harness: Harness) -> None: await _set(harness, 1) await harness.clock.advance(60) await harness.bus.emit_and_wait(NextTrackRequested(source="lirc")) assert harness.state.parent_timer is None async def test_a_key_press_in_the_kiosk_silences_it(harness: Harness) -> None: await _set(harness, 1) await harness.clock.advance(60) await harness.bus.emit_and_wait(UserActivity(source="web")) assert harness.state.parent_timer is None async def test_the_backend_and_home_assistant_do_not_silence_it(harness: Harness) -> None: await _set(harness, 1) await harness.clock.advance(60) await harness.bus.emit_and_wait(NextTrackRequested(source="system")) effect = EffectStaticConfig(color=ColorRGBW(1, 0, 0, 0)) await harness.bus.emit_and_wait( LedEffectRequested(zone=LedZone.MOUSE, effect=effect, source="mqtt") ) assert harness.state.parent_timer is not None async def test_input_while_counting_down_does_not_stop_it(harness: Harness) -> None: await _set(harness, 5) await harness.bus.emit_and_wait(NextTrackRequested(source="lirc")) assert harness.state.parent_timer is not None async def test_cancel_stops_a_running_timer(harness: Harness) -> None: await _set(harness, 5) await harness.bus.emit_and_wait(CancelTimerRequested(source="web")) assert harness.state.parent_timer is None await harness.clock.advance(600) # and it does not come back to ring assert harness.state.parent_timer is None async def test_a_new_timer_replaces_the_old_one(harness: Harness) -> None: await _set(harness, 5) await _set(harness, 30) await harness.clock.advance(300) timer = harness.state.parent_timer assert timer is not None and not timer.ringing async def test_every_step_is_announced(harness: Harness) -> None: before = harness.changes await _set(harness, 1) # set await harness.clock.advance(60) # ringing await harness.clock.advance(RING_SECONDS) # cleared assert harness.changes - before == 3 # -------------------------------------------------------------------- school day async def test_a_holiday_is_no_school_day(harness: Harness) -> None: assert harness.state.school_day is False assert harness.state.school_day_reason == "Herbstferien" @pytest.mark.parametrize("start", [SCHOOL_DAY]) async def test_a_plain_monday_is_a_school_day(harness: Harness) -> None: assert harness.state.school_day is True assert harness.state.school_day_reason is None async def test_the_holidays_are_cached(harness: Harness) -> None: assert harness.service.cache_file.exists() async def test_the_day_changes_at_midnight(harness: Harness) -> None: # Friday 2026-11-06 is the last day of the Herbstferien; Saturday is the weekend. await harness.clock.advance(4 * 86400) assert harness.state.school_day_reason == "Herbstferien" await harness.clock.advance(86400) assert harness.state.school_day_reason == "Wochenende" @pytest.mark.parametrize(("start", "api_status"), [(SCHOOL_DAY, 503)]) async def test_offline_without_a_cache_the_weekday_is_unknown(harness: Harness) -> None: assert harness.state.school_day is None @pytest.mark.parametrize("api_status", [503]) async def test_offline_with_a_cache_the_cache_is_used( tmp_path: Path, start: datetime, api_status: int ) -> None: from musicmouse.school_days import HolidayCalendar, parse_holidays, save_calendar cache = tmp_path / "holidays.json" save_calendar( cache, HolidayCalendar( fetched=start.date() - timedelta(days=3), subdivision="DE-BY", school=parse_holidays(SCHOOL), public=parse_holidays(PUBLIC), ), ) async with EventBus() as bus: service = TimerService( bus, AppState(), ClockConfig(), cache_file=cache, client=httpx2.AsyncClient(transport=httpx2.MockTransport(_holiday_api(api_status))), alsa_device=None, volume=lambda: 0.5, wall=lambda: start, ) assert await service.refresh_school_day() is False await service.client.aclose() assert service.state.school_day_reason == "Herbstferien"