Web frontend

This commit is contained in:
2026-08-27 12:32:20 +02:00
parent d44c24ec97
commit edb6e5e027
97 changed files with 9535 additions and 195 deletions

View File

@@ -6,29 +6,93 @@ from typing import Any
import pytest
from ruamel.yaml import YAML
#: Half a second of silence, so ``mutagen`` reports a real duration and tags can be
#: written onto a file that is actually an MP3. Generated once with ffmpeg.
SILENCE = Path(__file__).parent / "data" / "silence.mp3"
VALID_CONFIG: dict[str, Any] = {
"general": {
"figure_folder": "music",
"library": {"root": "music", "cache": ".cache"},
"serial_port": "/dev/ttyUSB0",
"alsa_device": "simulate",
"min_volume": 0,
"max_volume": 60,
"initial_volume": 40,
},
"figures": {
"fuchs": {"id": "04a1b2c3d4", "colors": ["#ff6600", "#ffcc00", "#331100", "wff"]},
"eule": {"id": "04b2c3d4e5", "colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"]},
# One of each, so the figure-kind branch is exercised by every fixture.
"eule": {
"id": "04b2c3d4e5",
"colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"],
"kind": "book",
},
},
}
def write_track(path: Path, **tags: str) -> Path:
"""Copy the silence fixture to ``path`` and stamp the given easy-mode ID3 tags."""
import mutagen
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(SILENCE.read_bytes())
if tags:
audio = mutagen.File(path, easy=True)
if audio.tags is None:
audio.add_tags()
for key, value in tags.items():
audio[key] = value
audio.save()
return path
@pytest.fixture
def config_dir(tmp_path: Path) -> Path:
"""A directory with a valid ``music/`` tree and two figures' worth of tracks."""
"""A directory holding a small but structurally real music library.
Two figure folders (matching ``VALID_CONFIG``), one music album, one audiobook and
one podcast - enough for every branch the scanner has.
"""
root = tmp_path / "music"
for figure, tracks in (("fuchs", 3), ("eule", 2)):
folder = tmp_path / "music" / figure
folder.mkdir(parents=True)
for index in range(tracks):
(folder / f"{index:02d} - track.mp3").write_bytes(b"")
write_track(
root / "Figuren" / figure / f"{index:02d} - track.mp3",
title=f"Track {index}",
album="Waldlieder",
albumartist="Die Tiere",
)
for index in range(2):
write_track(
root / "Musik" / "Kinderparty - Kinderparty Lieder" / f"{index:02d} - lied.mp3",
title=f"Lied {index}",
album="Kinderparty Lieder",
albumartist="Kinderparty",
)
for index in range(2):
write_track(
root / "Hörbücher" / "Conni - Conni in den Bergen" / f"{index:02d} - teil.mp3",
title=f"Teil {index}",
album="Conni in den Bergen",
albumartist="Conni, Julia Boehme",
)
podcast = root / "Kinderpodcasts" / "Wissen macht Ah"
for date, title in (("20240101", "Alt"), ("20260101", "Neu")):
write_track(
podcast / f"{date} - {title}.mp3",
title=title,
album="Wissen macht Ah! - Podcast",
albumartist="Ein Name, Noch Einer, Und Einer",
)
# The two kinds of scratch file a podcast downloader leaves behind.
(podcast / "archive.json").write_text("[]")
(podcast / ".podcast-dl-abc.download.tmp").write_bytes(b"")
return tmp_path

Binary file not shown.

View File

@@ -7,7 +7,7 @@ from typing import Any
import pytest
from musicmouse.color import ColorRGBW, parse_color
from musicmouse.config import ConfigError, build_playlists, load_config
from musicmouse.config import ConfigError, load_config
from tests.conftest import VALID_CONFIG, write_config
@@ -27,57 +27,29 @@ def test_loads_valid_config(config_dir: Path) -> None:
assert config.general.max_volume == 60
def test_figure_folder_resolves_relative_to_the_config_file(config_dir: Path) -> None:
def test_library_root_resolves_relative_to_the_config_file(config_dir: Path) -> None:
config = load_config(write_config(config_dir, VALID_CONFIG))
assert config.general.figure_folder == (config_dir / "music").resolve()
assert config.general.library.root == (config_dir / "music").resolve()
assert config.general.library.figure_folder == (config_dir / "music" / "Figuren").resolve()
assert config.folder_for("fuchs").name == "fuchs"
def test_tag_map_and_playlists(config_dir: Path) -> None:
def test_tag_map(config_dir: Path) -> None:
config = load_config(write_config(config_dir, VALID_CONFIG))
assert config.tag_map == {
bytes.fromhex("04a1b2c3d4"): "fuchs",
bytes.fromhex("04b2c3d4e5"): "eule",
}
playlists = build_playlists(config)
assert [t.path.name for t in playlists["fuchs"].tracks] == [
"00 - track.mp3",
"01 - track.mp3",
"02 - track.mp3",
]
assert len(playlists["eule"]) == 2
def test_playlist_is_alphabetical_regardless_of_creation_order(config_dir: Path) -> None:
folder = config_dir / "music" / "fuchs"
for name in ("zz last.mp3", "aa first.mp3"):
(folder / name).write_bytes(b"")
playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG)))
names = [t.path.name for t in playlists["fuchs"].tracks]
assert names == sorted(names)
assert names[0] == "00 - track.mp3"
def test_non_audio_files_are_ignored(config_dir: Path) -> None:
(config_dir / "music" / "eule" / "cover.jpg").write_bytes(b"")
(config_dir / "music" / "eule" / "notes.txt").write_bytes(b"")
playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG)))
assert len(playlists["eule"]) == 2
def test_missing_figure_folder_warns_but_does_not_fail(
config_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["figures"]["neu"] = {"id": "0400000001", "colors": ["#111111"] * 4}
def test_web_section_is_optional(config_dir: Path) -> None:
assert load_config(write_config(config_dir, VALID_CONFIG)).general.web is None
data = _config(web={"port": 9000, "static_dir": "dist"})
config = load_config(write_config(config_dir, data))
playlists = build_playlists(config)
assert len(playlists["neu"]) == 0
assert "no media folder" in caplog.text
assert config.general.web is not None
assert config.general.web.port == 9000
assert config.general.web.static_dir == (config_dir / "dist").resolve()
# --------------------------------------------------------------------- error paths
@@ -147,9 +119,9 @@ def test_initial_volume_must_lie_in_range(config_dir: Path) -> None:
assert "must lie between" in message
def test_missing_figure_folder_is_an_error(config_dir: Path) -> None:
message = _error(config_dir, _config(figure_folder="does-not-exist"))
assert "general.figure_folder" in message
def test_missing_library_root_is_an_error(config_dir: Path) -> None:
message = _error(config_dir, _config(library={"root": "does-not-exist"}))
assert "general.library.root" in message
assert "no such directory" in message

View File

@@ -0,0 +1,309 @@
"""The library scan: what ends up in the index, and what stays out of it."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
from musicmouse.library import Album, MusicLibrary
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis
from musicmouse.library.cache import LibraryCache
from musicmouse.library.colors import colors_from_id
from musicmouse.library.models import album_id
from tests.conftest import VALID_CONFIG, write_config, write_track
EXTENSIONS = frozenset(DEFAULT_AUDIO_EXTENSIONS)
async def build(config_dir: Path) -> MusicLibrary:
config = load_config(write_config(config_dir, VALID_CONFIG))
return await MusicLibrary.build(
config.general.library.root,
config.general.library.cache,
EXTENSIONS,
figure_kinds=config.figure_kinds,
)
def album_named(library: MusicLibrary, title: str) -> Album:
return next(album for album in library.albums if album.title == title)
# ------------------------------------------------------------------------ structure
async def test_every_section_is_scanned(config_dir: Path) -> None:
library = await build(config_dir)
assert {album.title for album in library.albums} == {
"Fuchs",
"Eule",
"Kinderparty Lieder",
"Conni in den Bergen",
"Wissen macht Ah",
}
async def test_figure_playlists_are_keyed_by_figure_name(config_dir: Path) -> None:
library = await build(config_dir)
playlists = library.figure_playlists()
assert set(playlists) == {"fuchs", "eule"}
assert len(playlists["fuchs"]) == 3
assert [track.path.name for track in playlists["fuchs"].tracks] == [
"00 - track.mp3",
"01 - track.mp3",
"02 - track.mp3",
]
# The playlist carries the album it came from, which is how a front-end answers
# "what is playing?" without keeping its own copy of that state.
assert playlists["fuchs"].album_id == album_named(library, "Fuchs").id
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Kinderparty Lieder")
assert album.kind == "music"
assert album.artist == "Kinderparty"
# Music groups by artist, so it has no series.
assert album.series is None
assert album.category == "Kinderparty"
async def test_audiobooks_group_by_the_name_before_the_comma(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Conni in den Bergen")
assert album.kind == "book"
# album_artist is a credit list; only the first name is the character.
assert album.artist == "Conni, Julia Boehme"
assert album.series == "Conni"
assert album.category == "Conni"
async def test_podcasts_are_named_after_the_folder_not_the_tags(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Wissen macht Ah")
# The tags say album="Wissen macht Ah! - Podcast" and artist=<six presenters>.
assert album.kind == "book"
assert album.artist == "Wissen macht Ah"
assert album.series == "Wissen macht Ah"
async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Wissen macht Ah")
assert [track.title for track in album.tracks] == ["Neu", "Alt"]
async def test_other_sections_keep_filename_order(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Kinderparty Lieder")
assert [track.path.name for track in album.tracks] == [
"00 - lied.mp3",
"01 - lied.mp3",
]
async def test_figures_are_marked_and_others_are_not(config_dir: Path) -> None:
library = await build(config_dir)
assert album_named(library, "Fuchs").figure == "fuchs"
assert album_named(library, "Kinderparty Lieder").figure is None
async def test_durations_come_from_the_files(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Eule")
assert all(track.duration > 0 for track in album.tracks)
assert album.duration == pytest.approx(sum(t.duration for t in album.tracks))
# -------------------------------------------------------------------- what is skipped
async def test_scratch_files_never_reach_a_playlist(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Wissen macht Ah")
names = {track.path.name for track in album.tracks}
assert "archive.json" not in names
assert not any(name.startswith(".") for name in names)
assert len(album.tracks) == 2
async def test_unknown_top_level_folders_are_ignored(config_dir: Path) -> None:
stray = config_dir / "music" / "Sonstiges" / "Irgendwas"
write_track(stray / "01 - x.mp3", title="X")
library = await build(config_dir)
assert not any(album.section == "Sonstiges" for album in library.albums)
async def test_a_missing_section_warns_rather_than_failing(
config_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
import shutil
shutil.rmtree(config_dir / "music" / "Musik")
library = await build(config_dir)
assert "Musik" in caplog.text
assert len(library.albums) == 4
async def test_an_empty_album_folder_is_not_an_album(config_dir: Path) -> None:
(config_dir / "music" / "Musik" / "Leer").mkdir()
library = await build(config_dir)
assert not any(album.title == "Leer" for album in library.albums)
# ------------------------------------------------------------------------- colours
async def test_every_album_has_three_colours(config_dir: Path) -> None:
library = await build(config_dir)
for album in library.albums:
assert len(album.colors) == 3
assert all(colour.startswith("#") and len(colour) == 7 for colour in album.colors)
async def test_colours_are_synthesised_when_there_is_no_cover(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Fuchs")
assert album.cover is None
assert album.colors == colors_from_id(album.id)
async def test_a_cover_file_is_found_and_used(config_dir: Path) -> None:
from PIL import Image
folder = config_dir / "music" / "Musik" / "Kinderparty - Kinderparty Lieder"
Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg")
album = album_named(await build(config_dir), "Kinderparty Lieder")
assert album.cover == folder / "cover.jpg"
# A solid red cover has one usable colour; the rest fall back to the synthesised
# palette rather than repeating it.
assert album.colors[0] != colors_from_id(album.id)[0]
# --------------------------------------------------------------------------- cache
async def test_a_second_build_reuses_the_index(config_dir: Path) -> None:
first = await build(config_dir)
second = await build(config_dir)
assert {a.id for a in first.albums} == {a.id for a in second.albums}
assert (config_dir / ".cache" / "index.json").is_file()
async def test_a_changed_folder_is_rescanned(config_dir: Path) -> None:
library = await build(config_dir)
assert len(album_named(library, "Eule").tracks) == 2
write_track(config_dir / "music" / "Figuren" / "eule" / "99 - neu.mp3", title="Neu")
library = await build(config_dir)
assert len(album_named(library, "Eule").tracks) == 3
async def test_a_rescan_leaves_analysis_alone(config_dir: Path) -> None:
"""The whole reason the cache is a directory rather than one file."""
library = await build(config_dir)
album = album_named(library, "Eule")
from musicmouse.library.models import track_key
key = track_key(album.tracks[0].path)
library.cache.store_analysis(key, TrackAnalysis(version=ANALYZER_VERSION, tempo=128.0))
library.cache.store_beats(key, BeatGrid((0.5, 1.0), (1.0, 0.5)))
# Force a full rescan by dropping the cheap part of the cache.
(config_dir / ".cache" / "index.json").unlink()
library = await build(config_dir)
album = album_named(library, "Eule")
assert album.tracks[0].analysis is not None
assert album.tracks[0].analysis.tempo == 128.0
grid = library.beats(album.id, 0)
assert grid is not None
assert grid.times == (0.5, 1.0)
async def test_analysis_survives_an_analyzer_that_grew_a_field(tmp_path: Path) -> None:
cache = LibraryCache(tmp_path / "cache")
cache.prepare()
(cache.analysis / "abc.json").write_text(
json.dumps({"version": 1, "tempo": 90.0, "danceability": 0.7})
)
analysis = cache.load_analysis("abc")
assert analysis is not None
assert analysis.tempo == 90.0
async def test_a_corrupt_index_is_rebuilt_rather_than_fatal(config_dir: Path) -> None:
await build(config_dir)
(config_dir / ".cache" / "index.json").write_text("{ not json")
library = await build(config_dir)
assert len(library.albums) == 5
def test_album_ids_are_stable_and_path_derived(tmp_path: Path) -> None:
first = album_id(tmp_path, tmp_path / "Musik" / "Ein Album")
second = album_id(tmp_path, tmp_path / "Musik" / "Ein Album")
other = album_id(tmp_path, tmp_path / "Musik" / "Ein Anderes")
assert first == second
assert first != other
# -------------------------------------------------------------------- figure kinds
async def test_a_figure_is_music_unless_the_config_says_otherwise(
config_dir: Path,
) -> None:
"""A figure folder is named after the figurine, so nothing on disk says what it is."""
library = await build(config_dir)
assert album_named(library, "Fuchs").kind == "music"
assert album_named(library, "Eule").kind == "book"
async def test_a_book_figure_groups_with_the_audiobooks(config_dir: Path) -> None:
"""The bug this fixed: a figure holding an audiobook landed in its artist's shelf
as music, so the category showed "4 Alben" and was drawn like one."""
for index in range(2):
write_track(
config_dir / "music" / "Figuren" / "eule" / f"{index:02d} - track.mp3",
title=f"Teil {index}",
album="Conni in den Bergen",
albumartist="Conni, Julia Boehme",
)
library = await build(config_dir)
figure = album_named(library, "Eule")
assert figure.kind == "book"
# Books group by series, which a music album does not have at all.
assert figure.series == "Conni"
assert figure.category == "Conni"
conni = [album for album in library.albums if album.category == "Conni"]
assert len(conni) == 2
assert {album.kind for album in conni} == {"book"}
async def test_a_music_figure_has_no_series(config_dir: Path) -> None:
figure = album_named(await build(config_dir), "Fuchs")
assert figure.series is None
assert figure.category == "Die Tiere"
async def test_a_figures_kind_survives_the_cache(config_dir: Path) -> None:
"""It cannot be re-derived on load: the section says "Figuren", not what is in it.
The index used to rebuild `kind` from the section, so a book figure came back as
music on the second boot - and the browse view drew it square.
"""
first = await build(config_dir)
assert album_named(first, "Eule").kind == "book"
assert (config_dir / ".cache" / "index.json").is_file()
# Nothing on disk changed, so this run is served entirely from the index.
second = await build(config_dir)
eule = album_named(second, "Eule")
assert eule.kind == "book"
assert eule.series is not None
assert album_named(second, "Fuchs").kind == "music"
assert album_named(second, "Fuchs").series is None

View File

@@ -1,7 +1,9 @@
"""Tests for the shared player behaviour, exercised through FakePlayer.
VlcPlayer adds only the libVLC bindings on top of PlayerBase; it needs a real audio
device and is covered by the on-device checklist, not here.
device and is covered by the on-device checklist, not here - except for how it works
out which track is playing, which is stubbed out at the bottom of this file because
getting it wrong is invisible until a front-end displays it.
"""
from __future__ import annotations
@@ -13,7 +15,7 @@ import pytest
from musicmouse.bus import EventBus
from musicmouse.clock import FakeClock
from musicmouse.devices.player import Player, VlcPlayer
from musicmouse.devices.player import Player, PlayerBase, VlcPlayer
from musicmouse.events import (
Event,
PlaybackChanged,
@@ -295,3 +297,69 @@ def test_fake_player_satisfies_the_player_protocol(player: FakePlayer) -> None:
def _vlc_player_satisfies_the_player_protocol(real: VlcPlayer) -> Player:
"""Checked by mypy, not at runtime: VlcPlayer needs libVLC to instantiate."""
return real
# --------------------------------------------------------- track index without libVLC
class _FakeMedia:
def __init__(self, mrl: str) -> None:
self._mrl = mrl
def get_mrl(self) -> str:
return self._mrl
class _FakeMediaPlayer:
"""Just enough of libVLC's media player to exercise index syncing."""
def __init__(self) -> None:
self.media: _FakeMedia | None = None
def get_media(self) -> _FakeMedia | None:
return self.media
def _vlc_like(bus: EventBus, playlist: Playlist) -> VlcPlayer:
"""A VlcPlayer with its libVLC parts stubbed out, without calling __init__."""
player = object.__new__(VlcPlayer)
PlayerBase.__init__(player, bus)
player._media_player = _FakeMediaPlayer() # type: ignore[assignment]
player._playlist = playlist
player._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)}
return player
async def test_the_track_index_is_read_back_off_the_player(bus: EventBus) -> None:
"""Not taken from the event payload.
``MediaListPlayerNextItemSet`` carries a bare int rather than a Media on some
libVLC builds, and trusting it left the index pinned at zero: the UI showed track 1
of an album that was audibly on track 3.
"""
playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3)))
player = _vlc_like(bus, playlist)
events: list[Event] = []
bus.subscribe(TrackChanged, events.append)
player._media_player.media = _FakeMedia("file:///music/2.mp3") # type: ignore[attr-defined]
player._on_next_item(object())
await bus.drain()
assert player.track_index == 2
assert player.current_track is not None
assert player.current_track.path.name == "2.mp3"
assert len(events) == 1
async def test_an_unknown_media_leaves_the_index_alone(bus: EventBus) -> None:
playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3)))
player = _vlc_like(bus, playlist)
player._media_player.media = None # type: ignore[attr-defined]
player._on_next_item(object())
player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3") # type: ignore[attr-defined]
player._on_next_item(object())
await bus.drain()
assert player.track_index == 0

View File

@@ -0,0 +1,176 @@
"""Parent mode: the small set of settings the UI may change, and how they are saved."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import httpx2
import pytest
from musicmouse.config import WebConfig, load_config
from musicmouse.services.web.service import build_app
from musicmouse.services.web.settings import to_device_volume, to_percent
from musicmouse.simulator.harness import Simulation, build_simulation
from tests.conftest import VALID_CONFIG, write_config
SETTINGS = {
"min_volume": 0,
"max_volume": 60,
"initial_volume": 40,
"volume_increment": 5,
"button_leds_brightness": 0.5,
}
#: A config file with comments, to prove saving does not flatten them.
COMMENTED = """\
# The MusicMouse config.
general:
library:
root: music # where the shelves live
cache: .cache
serial_port: "/dev/ttyUSB0"
alsa_device: simulate # no sound from the test suite, please
# Volume, 0..100.
min_volume: 0
max_volume: 60
initial_volume: 40
figures:
fuchs:
id: "04a1b2c3d4"
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
eule:
id: "04b2c3d4e5"
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
"""
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
config = load_config(write_config(config_dir, VALID_CONFIG))
simulation = await build_simulation(config)
try:
yield simulation
finally:
await simulation.aclose()
@pytest.fixture
async def client(sim: Simulation, config_dir: Path) -> AsyncIterator[httpx2.AsyncClient]:
path = config_dir / "config.yml"
path.write_text(COMMENTED, encoding="utf-8")
api, hub = build_app(sim.app, WebConfig(), path)
hub.start()
transport = httpx2.ASGITransport(app=api)
try:
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
yield http
finally:
hub.stop()
# ------------------------------------------------------------------------- mapping
def test_full_percent_is_the_configured_ceiling(config_dir: Path) -> None:
general = load_config(write_config(config_dir, VALID_CONFIG)).general
assert to_device_volume(100, general) == 60
assert to_device_volume(0, general) == 0
assert to_device_volume(50, general) == 30
def test_percent_round_trips(config_dir: Path) -> None:
general = load_config(write_config(config_dir, VALID_CONFIG)).general
for percent in (0, 25, 50, 75, 100):
assert to_percent(to_device_volume(percent, general), general) == percent
def test_a_degenerate_range_reads_as_full(config_dir: Path) -> None:
"""min == max is a legal config; it must not divide by zero."""
general = load_config(write_config(config_dir, VALID_CONFIG)).general
general.min_volume = general.max_volume = 40
assert to_percent(40, general) == 100
assert to_device_volume(50, general) == 40
# -------------------------------------------------------------------------- reading
async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None:
body = (await client.get("/api/settings")).json()
assert set(body) == set(SETTINGS)
assert body["max_volume"] == 60
# -------------------------------------------------------------------------- writing
async def test_saving_keeps_the_file_readable_and_commented(
client: httpx2.AsyncClient, config_dir: Path
) -> None:
response = await client.put("/api/settings", json={**SETTINGS, "max_volume": 45})
assert response.status_code == 200
text = (config_dir / "config.yml").read_text(encoding="utf-8")
assert "max_volume: 45" in text
assert "# The MusicMouse config." in text
assert "# where the shelves live" in text
assert "# Volume, 0..100." in text
assert "# no sound from the test suite, please" in text
# And it still loads.
reloaded = load_config(config_dir / "config.yml")
assert reloaded.general.max_volume == 45
assert set(reloaded.figures) == {"fuchs", "eule"}
async def test_a_new_ceiling_applies_to_the_running_player(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
"""A parent lowering the ceiling expects the next song to be quieter, not the next boot."""
await client.post("/api/volume", json={"percent": 100})
await sim.bus.drain()
assert sim.player.volume == 60
response = await client.put(
"/api/settings", json={**SETTINGS, "max_volume": 30, "initial_volume": 20}
)
assert response.status_code == 200
await sim.bus.drain()
assert sim.player.volume == 30
assert (await client.get("/api/state")).json()["volume"] == 100
async def test_an_inverted_range_is_rejected(client: httpx2.AsyncClient) -> None:
response = await client.put("/api/settings", json={**SETTINGS, "min_volume": 70})
assert response.status_code == 422
assert "min_volume" in response.json()["detail"]
async def test_an_initial_volume_outside_the_range_is_rejected(
client: httpx2.AsyncClient,
) -> None:
response = await client.put("/api/settings", json={**SETTINGS, "initial_volume": 90})
assert response.status_code == 422
assert "initial_volume" in response.json()["detail"]
async def test_out_of_bounds_values_are_rejected_by_the_schema(
client: httpx2.AsyncClient,
) -> None:
assert (
await client.put("/api/settings", json={**SETTINGS, "button_leds_brightness": 5})
).status_code == 422
assert (
await client.put("/api/settings", json={**SETTINGS, "volume_increment": 0})
).status_code == 422
async def test_a_rejected_save_leaves_the_file_alone(
client: httpx2.AsyncClient, config_dir: Path
) -> None:
before = (config_dir / "config.yml").read_text(encoding="utf-8")
await client.put("/api/settings", json={**SETTINGS, "min_volume": 70})
assert (config_dir / "config.yml").read_text(encoding="utf-8") == before

View File

@@ -0,0 +1,128 @@
"""What the app does when the config leaves the hardware out.
Both of these are deliberately warnings rather than errors: a spare machine running
only the web front-end is a supported way to use this, and it should say so once at
startup rather than fail or - worse - look like it is working when it is not.
"""
from __future__ import annotations
import copy
import logging
from pathlib import Path
from typing import Any
import pytest
from musicmouse.__main__ import _build_player, wants_hardware
from musicmouse.bus import EventBus
from musicmouse.clock import RealClock
from musicmouse.config import SIMULATE, Config, ConfigError, load_config
from musicmouse.devices.null_transport import NullTransport
from musicmouse.simulator.fake_player import FakePlayer
from tests.conftest import VALID_CONFIG, write_config
def _config(config_dir: Path, *, drop: tuple[str, ...] = (), **general: Any) -> Config:
data = copy.deepcopy(VALID_CONFIG)
data["general"].update(general)
for key in drop:
data["general"].pop(key, None)
return load_config(write_config(config_dir, data))
# ------------------------------------------------------------------- serial port
def test_a_missing_serial_port_is_an_error(config_dir: Path) -> None:
"""Simulation has to be asked for. A config that lost the line must not boot."""
with pytest.raises(ConfigError) as excinfo:
_config(config_dir, drop=("serial_port",))
assert "general.serial_port" in str(excinfo.value)
assert "required" in str(excinfo.value)
def test_a_missing_alsa_device_is_an_error(config_dir: Path) -> None:
with pytest.raises(ConfigError) as excinfo:
_config(config_dir, drop=("alsa_device",))
assert "general.alsa_device" in str(excinfo.value)
assert "required" in str(excinfo.value)
def test_both_missing_are_reported_together(config_dir: Path) -> None:
with pytest.raises(ConfigError) as excinfo:
_config(config_dir, drop=("serial_port", "alsa_device"))
message = str(excinfo.value)
assert "2 problems" in message
assert "general.serial_port" in message
assert "general.alsa_device" in message
def test_simulate_switches_off_the_serial_link(
config_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
config = _config(config_dir, serial_port=SIMULATE)
assert config.general.serial_simulated is True
with caplog.at_level(logging.WARNING):
assert wants_hardware(config, False) is False
assert "running without the mouse" in caplog.text
def test_a_real_port_is_used(config_dir: Path) -> None:
config = _config(config_dir)
assert config.general.serial_simulated is False
assert wants_hardware(config, False) is True
def test_the_flag_wins_over_a_configured_port(config_dir: Path) -> None:
assert wants_hardware(_config(config_dir), True) is False
# ------------------------------------------------------------------ audio device
async def test_simulate_gives_a_silent_player(
config_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
bus = EventBus()
await bus.start()
try:
general = _config(config_dir, alsa_device=SIMULATE).general
assert general.audio_simulated is True
with caplog.at_level(logging.WARNING):
player = _build_player(bus, general, clock=RealClock())
assert isinstance(player, FakePlayer)
assert "alsa_device" in caplog.text
assert "nothing will be audible" in caplog.text
# The clamps still come from the config, so volume behaves the same either way.
assert player.volume == 40
player.set_volume(999)
assert player.volume == 60
finally:
player.close()
await bus.stop()
async def test_an_alsa_device_asks_for_the_real_player(config_dir: Path) -> None:
"""Only that it tries: instantiating VlcPlayer needs libVLC, which CI has not got."""
bus = EventBus()
await bus.start()
try:
general = _config(config_dir, alsa_device="default").general
assert general.audio_simulated is False
with pytest.raises(Exception): # noqa: B017 - ImportError or an OSError from libVLC
_build_player(bus, general, clock=RealClock())
finally:
await bus.stop()
# ---------------------------------------------------------------- null transport
def test_the_null_transport_drops_what_it_is_handed() -> None:
transport = NullTransport()
transport.write(b"\x00\x01\x02")
assert transport.connected is False

View File

@@ -0,0 +1,357 @@
"""The web front-end, driven against the simulator.
Everything below the HTTP layer is production code: the same bus, the same reactions,
the same player interface - only the serial link and VLC are fake.
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
import httpx2
import pytest
from fastapi import FastAPI
from musicmouse.config import WebConfig, load_config
from musicmouse.services.web.service import build_app
from musicmouse.simulator.harness import Simulation, build_simulation
from tests.conftest import VALID_CONFIG, write_config
from tests.websocket_harness import websocket_connect
TRACK_SECONDS = 10.0
#: Shorthand: every test takes the same client type.
type Client = httpx2.AsyncClient
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
config = load_config(write_config(config_dir, VALID_CONFIG))
simulation = await build_simulation(config, track_duration=TRACK_SECONDS)
try:
yield simulation
finally:
await simulation.aclose()
@pytest.fixture
def api(sim: Simulation, config_dir: Path) -> Iterator[FastAPI]:
"""The real ASGI app, on the test's own event loop."""
application, hub = build_app(sim.app, WebConfig(), config_dir / "config.yml")
hub.start()
try:
yield application
finally:
hub.stop()
@pytest.fixture
async def client(api: FastAPI) -> AsyncIterator[httpx2.AsyncClient]:
transport = httpx2.ASGITransport(app=api)
async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http:
yield http
async def album_by_title(client: httpx2.AsyncClient, title: str) -> dict:
albums = (await client.get("/api/library")).json()["albums"]
return next(album for album in albums if album["title"] == title)
# ------------------------------------------------------------------------- library
async def test_library_lists_every_album_with_its_tracks(client: Client) -> None:
body = (await client.get("/api/library")).json()
titles = {album["title"] for album in body["albums"]}
assert titles == {
"Fuchs",
"Eule",
"Kinderparty Lieder",
"Conni in den Bergen",
"Wissen macht Ah",
}
album = await album_by_title(client, "Conni in den Bergen")
assert album["kind"] == "book"
assert album["category"] == "Conni"
assert len(album["colors"]) == 3
assert [track["title"] for track in album["tracks"]] == ["Teil 0", "Teil 1"]
async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None:
album = await album_by_title(client, "Fuchs")
assert album["has_cover"] is False
assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404
async def test_unanalyzed_tracks_report_no_analysis(client: Client) -> None:
album = await album_by_title(client, "Eule")
assert all(track["analysis"] is None for track in album["tracks"])
assert (await client.get(f"/api/tracks/{album['id']}/0/analysis")).status_code == 404
# -------------------------------------------------------------------------- state
async def test_state_starts_idle(client: Client) -> None:
state = (await client.get("/api/state")).json()
assert state["playing"] is False
assert state["album_id"] is None
assert state["active_figure"] is None
assert state["connected"] == {"firmware": True, "mqtt": False}
async def test_play_loads_the_album_and_starts_it(client: Client, sim: Simulation) -> None:
album = await album_by_title(client, "Kinderparty Lieder")
response = await client.post("/api/play", json={"album_id": album["id"]})
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.is_playing
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == album["id"]
assert (await client.get("/api/state")).json()["album_title"] == "Kinderparty Lieder"
async def test_track_titles_come_from_the_tags_not_the_filename(
client: Client, sim: Simulation
) -> None:
"""``01 - So ein schoener Tag.mp3`` is a filename, not a title."""
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"]})
await sim.bus.drain()
state = (await client.get("/api/state")).json()
assert state["track_title"] == "Track 0"
assert state["duration"] > 0
async def test_play_can_start_at_a_track(client: Client, sim: Simulation) -> None:
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"], "track_index": 2})
await sim.bus.drain()
assert sim.player.track_index == 2
assert (await client.get("/api/state")).json()["track_index"] == 2
async def test_playing_a_figure_album_reuses_the_figure_playlist(
client: Client, sim: Simulation
) -> None:
"""Identity matters: ``play_figure`` resumes on an ``is`` check."""
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"]})
await sim.bus.drain()
assert sim.player.playlist is sim.app.playlists["fuchs"]
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None:
response = await client.post("/api/play", json={"album_id": "nope"})
assert response.status_code == 404
async def test_transport_commands_reach_the_player(client: Client, sim: Simulation) -> None:
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"]})
await sim.bus.drain()
await client.post("/api/next")
await sim.bus.drain()
assert sim.player.track_index == 1
await client.post("/api/previous")
await sim.bus.drain()
assert sim.player.track_index == 0
await client.post("/api/pause")
await sim.bus.drain()
assert not sim.player.is_playing
await client.post("/api/resume")
await sim.bus.drain()
assert sim.player.is_playing
async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None:
album = await album_by_title(client, "Fuchs")
await client.post("/api/play", json={"album_id": album["id"]})
await sim.bus.drain()
response = await client.post("/api/seek", json={"position": 4.0})
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.position == pytest.approx(4.0, abs=0.1)
async def test_seeking_backwards_is_rejected(client: Client) -> None:
assert (await client.post("/api/seek", json={"position": -1})).status_code == 422
# ------------------------------------------------------------------------- volume
async def test_full_volume_means_the_configured_ceiling(
client: Client, sim: Simulation
) -> None:
"""The child sees 0..100; the config's max of 60 never crosses the API boundary."""
response = await client.post("/api/volume", json={"percent": 100})
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.volume == 60
assert (await client.get("/api/state")).json()["volume"] == 100
async def test_volume_scales_across_the_allowed_range(
client: Client, sim: Simulation
) -> None:
await client.post("/api/volume", json={"percent": 50})
await sim.bus.drain()
assert sim.player.volume == 30
assert (await client.get("/api/state")).json()["volume"] == 50
async def test_volume_steps_are_relative_to_the_percentage(
client: Client, sim: Simulation
) -> None:
await client.post("/api/volume", json={"percent": 50})
await sim.bus.drain()
await client.post("/api/volume", json={"delta_percent": 10})
await sim.bus.drain()
assert (await client.get("/api/state")).json()["volume"] == 60
assert sim.player.volume == 36
async def test_volume_steps_clamp_at_the_ends(client: Client, sim: Simulation) -> None:
await client.post("/api/volume", json={"percent": 95})
await sim.bus.drain()
await client.post("/api/volume", json={"delta_percent": 20})
await sim.bus.drain()
assert (await client.get("/api/state")).json()["volume"] == 100
async def test_the_device_volume_range_is_never_exposed(client: Client) -> None:
state = (await client.get("/api/state")).json()
assert "volume_min" not in state
assert "volume_max" not in state
async def test_volume_needs_one_of_the_two_fields(client: Client) -> None:
assert (await client.post("/api/volume", json={})).status_code == 422
# ---------------------------------------------------------------------- websocket
async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) -> None:
"""State events only fire on change, so a tab that connects mid-track needs this."""
async with websocket_connect(api, "/api/ws") as socket:
message = await socket.next_json()
assert message["type"] == "state"
assert message["state"]["playing"] is False
async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None:
async with websocket_connect(api, "/api/ws") as first, websocket_connect(
api, "/api/ws"
) as second:
await first.next_json()
await second.next_json()
await sim.driver.place("fuchs")
await sim.bus.drain()
for socket in (first, second):
message = await socket.next_json()
assert message["type"] == "state"
assert message["state"]["active_figure"] == "fuchs"
async def test_a_refresh_tells_the_clients_to_reload_the_library(
api: FastAPI, client: Client, sim: Simulation
) -> None:
async with websocket_connect(api, "/api/ws") as socket:
await socket.next_json()
response = await client.post("/api/library/refresh")
assert response.status_code == 202
message = await socket.next_json(timeout=5.0)
assert message["type"] == "library"
assert set(sim.app.playlists) == {"fuchs", "eule"}
# ------------------------------------------------------------------------ figures
async def test_the_web_ui_follows_a_figure_placed_on_the_reader(
client: Client, sim: Simulation
) -> None:
await sim.driver.place("fuchs")
await sim.bus.drain()
state = (await client.get("/api/state")).json()
assert state["active_figure"] == "fuchs"
assert state["playing"] is True
assert state["album_title"] == "Fuchs"
# ------------------------------------------------- through a real uvicorn, not just ASGI
async def test_websockets_survive_a_real_server(sim: Simulation, config_dir: Path) -> None:
"""Boot the actual service and open an actual websocket.
Everything above drives the ASGI app directly, which cannot see whether uvicorn is
able to answer an upgrade at all - and it is not, unless a websocket implementation
is installed alongside it. That gap answered ``/api/ws`` with a 404 in production
while every other test passed.
"""
import json
import socket
import websockets
from musicmouse.services.web.service import WebService
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
service = WebService(
sim.app,
WebConfig(host="127.0.0.1", port=port),
config_dir / "config.yml",
)
server = asyncio.create_task(service.run(), name="web-service")
try:
await _wait_for_port(port)
async with websockets.connect(f"ws://127.0.0.1:{port}/api/ws") as client:
snapshot = json.loads(await asyncio.wait_for(client.recv(), 5.0))
assert snapshot["type"] == "state"
assert snapshot["state"]["playing"] is False
finally:
server.cancel()
with contextlib.suppress(asyncio.CancelledError):
await server
async def _wait_for_port(port: int, timeout: float = 5.0) -> None:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
try:
reader, writer = await asyncio.open_connection("127.0.0.1", port)
except OSError:
await asyncio.sleep(0.05)
continue
del reader
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
return
raise AssertionError(f"nothing listening on {port} after {timeout}s")

View File

@@ -0,0 +1,68 @@
"""Drive an ASGI websocket endpoint on the caller's own event loop.
Starlette's ``TestClient`` runs the app in a second thread with its own loop, which
would put the bus and the websockets on different loops - so ``bus.emit`` would take
its thread-safe path and a following ``drain()`` could return before the event was even
queued. In the real app there is only ever one loop, and this harness keeps the tests
that way.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from collections.abc import AsyncIterator
from typing import Any
class WebSocketSession:
def __init__(self) -> None:
self.to_app: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
self.from_app: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def receive(self) -> dict[str, Any]:
return await self.to_app.get()
async def send(self, message: dict[str, Any]) -> None:
await self.from_app.put(message)
async def next_json(self, timeout: float = 2.0) -> dict[str, Any]:
"""The next ``websocket.send`` frame, decoded."""
while True:
message = await asyncio.wait_for(self.from_app.get(), timeout)
if message["type"] == "websocket.send":
text: str = message["text"]
return json.loads(text)
@contextlib.asynccontextmanager
async def websocket_connect(app: Any, path: str) -> AsyncIterator[WebSocketSession]:
session = WebSocketSession()
scope = {
"type": "websocket",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"scheme": "ws",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"root_path": "",
"headers": [(b"host", b"testserver")],
"client": ("testclient", 50000),
"server": ("testserver", 80),
"subprotocols": [],
"state": {},
}
await session.to_app.put({"type": "websocket.connect"})
task = asyncio.create_task(app(scope, session.receive, session.send))
accepted = await asyncio.wait_for(session.from_app.get(), 2.0)
assert accepted["type"] == "websocket.accept", accepted
try:
yield session
finally:
await session.to_app.put({"type": "websocket.disconnect", "code": 1000})
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task