Web frontend
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
BIN
python-backend/tests/data/silence.mp3
Normal file
BIN
python-backend/tests/data/silence.mp3
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
|
||||
309
python-backend/tests/test_library.py
Normal file
309
python-backend/tests/test_library.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
176
python-backend/tests/test_settings.py
Normal file
176
python-backend/tests/test_settings.py
Normal 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
|
||||
128
python-backend/tests/test_startup.py
Normal file
128
python-backend/tests/test_startup.py
Normal 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
|
||||
357
python-backend/tests/test_web.py
Normal file
357
python-backend/tests/test_web.py
Normal 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")
|
||||
68
python-backend/tests/websocket_harness.py
Normal file
68
python-backend/tests/websocket_harness.py
Normal 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
|
||||
Reference in New Issue
Block a user