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:
@@ -21,6 +21,7 @@ from musicmouse.events import (
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PreviousTrackRequested,
|
||||
ToggleClockRequested,
|
||||
ToggleScreenRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
@@ -316,6 +317,26 @@ async def test_the_power_button_toggles_the_screen(
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
async def test_the_guide_key_asks_for_the_clock(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
station, server = lircd
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
seen: list[Event] = []
|
||||
sim.bus.subscribe(ToggleClockRequested, seen.append)
|
||||
|
||||
task = await _run_service(sim, _lirc_config(port))
|
||||
try:
|
||||
await station.wait_connected()
|
||||
await station.send("b7c0000000000000 00 key_epg hauppauge")
|
||||
await station.send("b7c0000000000000 00 key_epg hauppauge") # still held
|
||||
await sim.bus.drain()
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
assert [event.source for event in seen] == ["lirc"]
|
||||
|
||||
|
||||
async def test_mute_toggles_and_restores(
|
||||
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
|
||||
) -> None:
|
||||
|
||||
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))
|
||||
292
python-backend/tests/test_timers.py
Normal file
292
python-backend/tests/test_timers.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""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"
|
||||
@@ -9,7 +9,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TypeAlias
|
||||
|
||||
@@ -17,8 +19,15 @@ import httpx2
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from musicmouse.app import ParentTimer
|
||||
from musicmouse.config import WebConfig, load_config
|
||||
from musicmouse.events import ToggleScreenRequested
|
||||
from musicmouse.events import (
|
||||
CancelTimerRequested,
|
||||
ClockStateChanged,
|
||||
SetTimerRequested,
|
||||
ToggleClockRequested,
|
||||
ToggleScreenRequested,
|
||||
)
|
||||
from musicmouse.library.analysis import BeatGrid, TrackCurves
|
||||
from musicmouse.library.models import track_key
|
||||
from musicmouse.services.web.service import build_app
|
||||
@@ -318,6 +327,76 @@ async def test_volume_needs_one_of_the_fields(client: Client) -> None:
|
||||
assert (await client.post("/api/volume", json={})).status_code == 422
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- clock
|
||||
|
||||
|
||||
async def test_a_timer_in_minutes_is_an_intent(client: Client, sim: Simulation) -> None:
|
||||
seen: list[SetTimerRequested] = []
|
||||
sim.bus.subscribe(SetTimerRequested, seen.append)
|
||||
|
||||
before = time.time()
|
||||
response = await client.post("/api/timer", json={"minutes": 15})
|
||||
await sim.bus.drain()
|
||||
|
||||
assert response.status_code == 204
|
||||
assert len(seen) == 1
|
||||
assert before + 900 <= seen[0].ends_at <= time.time() + 900
|
||||
|
||||
|
||||
async def test_a_timer_until_a_time_is_an_intent(client: Client, sim: Simulation) -> None:
|
||||
seen: list[SetTimerRequested] = []
|
||||
sim.bus.subscribe(SetTimerRequested, seen.append)
|
||||
|
||||
response = await client.post("/api/timer", json={"until": "19:30"})
|
||||
await sim.bus.drain()
|
||||
|
||||
assert response.status_code == 204
|
||||
end = datetime.fromtimestamp(seen[0].ends_at)
|
||||
assert (end.hour, end.minute) == (19, 30)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body", [{}, {"minutes": 5, "until": "19:30"}, {"minutes": 0}, {"until": "25:00"}]
|
||||
)
|
||||
async def test_a_timer_needs_exactly_one_valid_field(client: Client, body: dict) -> None:
|
||||
assert (await client.post("/api/timer", json=body)).status_code == 422
|
||||
|
||||
|
||||
async def test_cancelling_the_timer_is_an_intent(client: Client, sim: Simulation) -> None:
|
||||
seen: list[CancelTimerRequested] = []
|
||||
sim.bus.subscribe(CancelTimerRequested, seen.append)
|
||||
|
||||
response = await client.delete("/api/timer")
|
||||
await sim.bus.drain()
|
||||
|
||||
assert response.status_code == 204
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
async def test_the_state_carries_the_clock(client: Client, sim: Simulation) -> None:
|
||||
sim.app.state.school_day = True
|
||||
sim.app.state.parent_timer = ParentTimer(started_at=100.0, ends_at=400.0)
|
||||
|
||||
clock = (await client.get("/api/state")).json()["clock"]
|
||||
|
||||
assert clock == {
|
||||
"school_day": True,
|
||||
"school_day_reason": None,
|
||||
"bus": {"time": "07:25", "lead_minutes": 60},
|
||||
"parent_timer": {"started_at": 100.0, "ends_at": 400.0, "ringing": False},
|
||||
}
|
||||
|
||||
|
||||
async def test_no_bus_without_school(client: Client, sim: Simulation) -> None:
|
||||
sim.app.state.school_day = False
|
||||
sim.app.state.school_day_reason = "Herbstferien"
|
||||
|
||||
clock = (await client.get("/api/state")).json()["clock"]
|
||||
|
||||
assert clock["bus"] is None
|
||||
assert clock["school_day_reason"] == "Herbstferien"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- websocket
|
||||
|
||||
|
||||
@@ -383,6 +462,26 @@ async def test_switching_albums_while_playing_reaches_every_client(
|
||||
assert (await last_state(socket))["album_title"] == "Eule"
|
||||
|
||||
|
||||
async def test_a_clock_change_reaches_the_clients(api: FastAPI, sim: Simulation) -> None:
|
||||
async with websocket_connect(api, "/api/ws") as socket:
|
||||
await socket.next_json()
|
||||
|
||||
sim.app.state.parent_timer = ParentTimer(started_at=1.0, ends_at=2.0, ringing=True)
|
||||
await sim.bus.emit_and_wait(ClockStateChanged())
|
||||
|
||||
state = await last_state(socket)
|
||||
assert state["clock"]["parent_timer"]["ringing"] is True
|
||||
|
||||
|
||||
async def test_the_remotes_clock_key_reaches_the_clients(api: FastAPI, sim: Simulation) -> None:
|
||||
async with websocket_connect(api, "/api/ws") as socket:
|
||||
await socket.next_json()
|
||||
|
||||
await sim.bus.emit_and_wait(ToggleClockRequested(source="lirc"))
|
||||
|
||||
assert await socket.next_json() == {"type": "clock_toggle"}
|
||||
|
||||
|
||||
async def test_a_refresh_tells_the_clients_to_reload_the_library(
|
||||
api: FastAPI, client: Client, sim: Simulation
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user