Figures: several albums per figure, picked on the LED ring

A figure folder that holds album folders instead of audio can play any of them,
up to six, in folder order (depth-first, alphabetical). Each is its own album,
titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays
the album it played last, or the first, straight away; another figure placed in
between forgets the position in that album, not the album itself.

Meanwhile the ring offers all of them: one section per album from a fixed palette,
edges shifting towards the neighbours' hue, the selected one bright and wobbling.
The knob moves the selection one section per detent and wraps; pressing plays the
selected album, and left alone for 8 s the picker fades into the figure's colours
and the music carries on. While open, the device hands the knob to the picker
(`capture_knob`): turns do not change the volume and the press is not published to
Home Assistant. A tick per step and a confirm "pling" go with it.

The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23),
re-sent on every detent with the section to glide from. The look is tunable through
a temporary `general.figure_selection` config section until the right values
become constants.

Every UI sound - the screen's blubb, the parents' timer, the picker - now follows
the music volume with a floor, times a new parent-mode "Effekt-Lautstärke"
(`general.effects_volume`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 00:29:18 +02:00
parent a55cc4f67f
commit d465a74cde
37 changed files with 1570 additions and 68 deletions

View File

@@ -15,6 +15,7 @@ from musicmouse.effects import (
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectSegmentSelect,
EffectStaticConfig,
EffectStaticDetailedConfig,
EffectSwipeAndChange,
@@ -135,7 +136,54 @@ def test_swipe_and_change_is_the_two_payloads_concatenated() -> None:
(EffectRandomTwoColorInterpolationConfig(), 35),
(EffectReverseSwipe(), 12),
(EffectSwipeAndChange(), 64),
(EffectSegmentSelect(colors=(ColorRGBW(0, 0, 0, 0),)), 53),
],
)
def test_payload_sizes(effect: LedEffect, size: int) -> None:
assert len(effect.as_bytes()) == size
def test_segment_select() -> None:
effect = EffectSegmentSelect(
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 0, 1, 0)),
selected=1,
previous=0,
gap_leds=2,
offset_degrees=180.0,
direction=-1,
dim_factor=0.5,
wobble_amplitude=1.5,
wobble_period_ms=1000.0,
slide_ms=200.0,
edge_blend=0.25,
)
assert effect.as_bytes().hex() == (
"02" "01" "00" "02" # count, selected, previous, gap
"ff000000" "0000ff00" + "00000000" * 4 # always six colours on the wire
+ "00003443" # offset 180.0
"ff" # direction -1
"0000003f" # dim 0.5
"0000c03f" # wobble 1.5
"00007a44" # period 1000.0
"00004843" # slide 200.0
"0000803e" # edge blend 0.25
)
def test_segment_select_without_previous_does_not_glide() -> None:
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * 3, selected=2)
assert effect.as_bytes()[:3] == bytes([3, 2, 2])
def test_segment_select_round_trips() -> None:
# Every float exact in float32, as the firmware has them.
effect = EffectSegmentSelect(
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)), previous=1, dim_factor=0.25
)
assert EffectSegmentSelect.from_bytes(effect.as_bytes()) == effect
@pytest.mark.parametrize("count", [0, 7])
def test_segment_select_takes_one_to_six_sections(count: int) -> None:
with pytest.raises(ValueError, match="between 1 and 6"):
EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * count)

View File

@@ -15,7 +15,14 @@ from pathlib import Path
import pytest
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
from musicmouse.library import Album, Analyzer, MusicLibrary, default_worker_count
from musicmouse.effects import SEGMENT_SELECT_MAX_SEGMENTS
from musicmouse.library import (
MAX_FIGURE_ALBUMS,
Album,
Analyzer,
MusicLibrary,
default_worker_count,
)
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.cache import LibraryCache
from musicmouse.library.colors import colors_from_id
@@ -92,15 +99,72 @@ async def test_figure_playlists_are_keyed_by_figure_name(config_dir: Path) -> No
playlists = library.figure_playlists()
assert set(playlists) == {"fuchs", "eule"}
assert len(playlists["fuchs"]) == 3
assert [track.path.name for track in playlists["fuchs"].tracks] == [
(fuchs,) = playlists["fuchs"]
assert len(fuchs) == 3
assert [track.path.name for track in 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
assert fuchs.album_id == album_named(library, "Fuchs").id
def _figure_with_albums(config_dir: Path, figure: str, *folders: str) -> None:
"""Replace a figure's audio with one album per folder (relative to the figure)."""
figure_folder = config_dir / "music" / "Figuren" / figure
for track in figure_folder.glob("*.mp3"):
track.unlink()
for folder in folders:
write_track(figure_folder / folder / "01 - track.mp3", title=f"In {folder}")
async def test_a_figure_with_album_subfolders_offers_each_in_folder_order(
config_dir: Path,
) -> None:
# Depth-first and alphabetical, however deep an album sits.
_figure_with_albums(
config_dir, "fuchs", "B Lieder", "A Geschichten/Teil 2", "A Geschichten/Teil 1"
)
library = await build(config_dir)
choices = library.figure_playlists()["fuchs"]
albums = [library.get(playlist.album_id) for playlist in choices]
assert [album.title if album else None for album in albums] == [
"Fuchs - Teil 1",
"Fuchs - Teil 2",
"Fuchs - B Lieder",
]
assert all(album is not None and album.figure == "fuchs" for album in albums)
async def test_a_figure_album_subfolder_takes_the_figures_kind(config_dir: Path) -> None:
_figure_with_albums(config_dir, "eule", "Folge 1", "Folge 2")
library = await build(config_dir)
albums = [library.get(p.album_id) for p in library.figure_playlists()["eule"]]
assert len(albums) == 2
assert {album.kind if album else None for album in albums} == {"book"}
async def test_a_figure_offers_no_more_albums_than_the_ring_has_room_for(
config_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
_figure_with_albums(config_dir, "fuchs", *(f"Album {n}" for n in range(MAX_FIGURE_ALBUMS + 2)))
library = await build(config_dir)
choices = library.figure_playlists()["fuchs"]
assert len(choices) == MAX_FIGURE_ALBUMS
assert "only the first 6" in caplog.text
# Browsing still shows every one of them.
assert sum(album.figure == "fuchs" for album in library.albums) == MAX_FIGURE_ALBUMS + 2
def test_the_album_cap_is_the_rings_section_count() -> None:
assert MAX_FIGURE_ALBUMS == SEGMENT_SELECT_MAX_SEGMENTS
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:

View File

@@ -14,6 +14,7 @@ from musicmouse.effects import (
OFF,
EffectAlexaSwipeConfig,
EffectCircularConfig,
EffectSegmentSelect,
EffectStaticConfig,
)
from musicmouse.events import (
@@ -23,9 +24,12 @@ from musicmouse.events import (
Event,
LedEffectChanged,
RfidTokenRead,
RotaryTurned,
SelectorPressed,
SelectorTurned,
TouchButtonPressed,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
from musicmouse.simulator import FakeTransport
FUCHS = bytes.fromhex("04a1b2c3d4")
@@ -82,6 +86,56 @@ async def test_button_press_reaches_the_bus(
]
def _turn(increment: int, direction: RotaryDirection) -> bytes:
return encode_input_event(RotaryTurned(position=0, increment=increment, direction=direction))
def _knob(action: ButtonAction) -> bytes:
return encode_input_event(ButtonEvent(button=Button.ROTARY, action=action))
async def test_a_captured_knob_turns_the_selector_not_the_volume(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
device.feed(_turn(2, RotaryDirection.UP))
device.feed(_turn(1, RotaryDirection.DOWN))
# The first turn after boot reports no increment, but it was still a detent.
device.feed(_turn(0, RotaryDirection.UP))
await bus.drain()
assert [e.steps for e in only(seen, SelectorTurned)] == [2, -1, 1]
assert not only(seen, RotaryTurned)
async def test_a_captured_knob_press_is_the_selectors_alone(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
for action in (ButtonAction.PRESSED, ButtonAction.RELEASED):
device.feed(_knob(action))
# The other buttons are not captured.
device.feed(encode_input_event(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED)))
await bus.drain()
assert len(only(seen, SelectorPressed)) == 1
assert [e.button for e in only(seen, ButtonEvent)] == [Button.LEFT]
async def test_a_released_knob_is_a_volume_knob_again(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
device.capture_knob(False)
device.feed(_turn(1, RotaryDirection.UP))
device.feed(_knob(ButtonAction.PRESSED))
await bus.drain()
assert len(only(seen, RotaryTurned)) == 1
assert len(only(seen, ButtonEvent)) == 1
assert not only(seen, SelectorTurned) and not only(seen, SelectorPressed)
async def test_touch_press_reaches_the_bus(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
@@ -255,6 +309,19 @@ async def test_an_effect_a_zone_does_not_support_is_reported_not_sent(
assert "cannot be sent to the mouse LEDs" in caplog.text
async def test_only_the_ring_can_be_a_selector(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)))
device.set_effect(LedZone.RING, effect)
device.set_effect(LedZone.SHELF, effect)
await bus.drain()
sent = transport.effect(LedZone.RING)
assert sent is not None and sent.as_bytes() == effect.as_bytes()
assert transport.effect(LedZone.SHELF) is None
async def test_button_brightness_sets_both_backlights(
device: MusicMouseDevice, transport: FakeTransport
) -> None:

View File

@@ -0,0 +1,371 @@
"""Picking one of a figure's albums on the LED ring, driven through the simulator."""
from __future__ import annotations
from collections.abc import AsyncIterator
from copy import deepcopy
from pathlib import Path
from typing import Any, TypeVar
import pytest
from musicmouse.blubb import confirm_wav, tick_wav
from musicmouse.config import load_config
from musicmouse.effects import (
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectSegmentSelect,
EffectSwipeAndChange,
)
from musicmouse.events import ButtonEvent, Event, FigureSelectionChanged, RotaryTurned
from musicmouse.hardware import LedZone
from musicmouse.reactions import selection
from musicmouse.reactions.selection import CONFIRM_GAIN, DEFAULTS, PALETTE, TICK_GAIN
from musicmouse.simulator.driver import SimulatorDriver
from musicmouse.simulator.harness import Simulation, build_simulation
from musicmouse.sound import ui_level
from tests.conftest import VALID_CONFIG, write_config, write_track
TRACK_SECONDS = 10.0
TIMEOUT = DEFAULTS.timeout
T = TypeVar("T", bound=Event)
def _albums(config_dir: Path) -> None:
"""Give the fox three albums of two tracks each; the owl keeps its one."""
fuchs = config_dir / "music" / "Figuren" / "fuchs"
for track in fuchs.glob("*.mp3"):
track.unlink()
for folder in ("A", "B", "C"):
for index in range(2):
write_track(fuchs / folder / f"{index:02d}.mp3", title=f"{folder}{index}")
async def _simulation(config_dir: Path, config: dict[str, Any]) -> Simulation:
_albums(config_dir)
return await build_simulation(
load_config(write_config(config_dir, config)), track_duration=TRACK_SECONDS
)
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
simulation = await _simulation(config_dir, VALID_CONFIG)
try:
yield simulation
finally:
await simulation.aclose()
@pytest.fixture
def mouse(sim: Simulation) -> SimulatorDriver:
return sim.driver
@pytest.fixture
def seen(sim: Simulation) -> list[Event]:
events: list[Event] = []
sim.bus.subscribe_all(events.append)
return events
def only(events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]
def ring(sim: Simulation) -> object:
return sim.app.mouse.effect(LedZone.RING)
def picker(sim: Simulation) -> EffectSegmentSelect:
effect = ring(sim)
assert isinstance(effect, EffectSegmentSelect)
return effect
# ------------------------------------------------------------------------- opening
async def test_a_figure_with_several_albums_plays_the_first_and_offers_all(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
mouse.check("playing", "true")
mouse.check("album", "Fuchs - A")
effect = picker(sim)
assert effect.colors == PALETTE[:3]
assert effect.selected == 0
# Only the ring is the picker; the body and shelf run the usual start animation.
assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectSwipeAndChange)
assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange)
async def test_a_figure_with_one_album_gets_no_picker(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("eule")
assert isinstance(ring(sim), EffectSwipeAndChange)
assert sim.app.state.selector is None
assert not sim.app.mouse.knob_captured
async def test_the_config_can_override_how_the_picker_looks(config_dir: Path) -> None:
config = deepcopy(VALID_CONFIG)
config["general"]["figure_selection"] = {"offset_degrees": 90, "wobble_amplitude": 3}
sim = await _simulation(config_dir, config)
try:
await sim.driver.place("fuchs")
effect = picker(sim)
assert effect.offset_degrees == 90
assert effect.wobble_amplitude == 3
assert effect.slide_ms == DEFAULTS.slide_ms
finally:
await sim.aclose()
# --------------------------------------------------------------------------- knob
async def test_turning_moves_the_selection_instead_of_the_volume(
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
) -> None:
await mouse.place("fuchs")
volume = sim.player.volume
await mouse.turn(1)
effect = picker(sim)
assert (effect.previous, effect.selected) == (0, 1)
assert sim.player.volume == volume
assert not only(seen, RotaryTurned)
assert only(seen, FigureSelectionChanged)[-1].selected == 1
# Turning only moves the highlight; what plays changes on a press.
mouse.check("album", "Fuchs - A")
async def test_turning_wraps_round_the_ring(mouse: SimulatorDriver, sim: Simulation) -> None:
await mouse.place("fuchs")
await mouse.turn(-1)
assert picker(sim).selected == 2
await mouse.turn(2)
assert picker(sim).selected == 1
async def test_pressing_plays_the_selected_album_and_gives_the_knob_back(
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
) -> None:
await mouse.place("fuchs")
await mouse.turn(2)
await mouse.press("rotary")
mouse.check("album", "Fuchs - C")
mouse.check("track", "0")
mouse.check("playing", "true")
effect = ring(sim)
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
assert effect.start_with_existing # a fade from the sections, not a jump
# The press was the picker's: Home Assistant never hears of it.
assert not only(seen, ButtonEvent)
assert only(seen, FigureSelectionChanged)[-1].open is False
volume = sim.player.volume
await mouse.turn(-1)
assert sim.player.volume < volume
async def test_pressing_on_the_album_already_playing_does_not_restart_it(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.wait(3)
await mouse.press("rotary")
mouse.check("album", "Fuchs - A")
assert sim.player.position == pytest.approx(3.0)
assert sim.app.state.selector is None
async def test_presses_reach_home_assistant_again_once_the_picker_is_closed(
mouse: SimulatorDriver, seen: list[Event]
) -> None:
await mouse.place("fuchs")
await mouse.press("rotary")
await mouse.press("rotary")
assert len(only(seen, ButtonEvent)) == 1
# ------------------------------------------------------------------------ timeout
async def test_left_alone_the_picker_closes_and_the_music_carries_on(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.wait(TIMEOUT + 0.1)
assert sim.app.state.selector is None
assert not sim.app.mouse.knob_captured
assert isinstance(ring(sim), EffectRandomTwoColorInterpolationConfig)
mouse.check("album", "Fuchs - A")
mouse.check("playing", "true")
async def test_every_turn_starts_the_timeout_over(mouse: SimulatorDriver, sim: Simulation) -> None:
await mouse.place("fuchs")
await mouse.wait(TIMEOUT - 2)
await mouse.turn(1)
await mouse.wait(TIMEOUT - 1)
assert sim.app.state.selector is not None
await mouse.wait(1.1)
assert sim.app.state.selector is None
async def test_lifting_the_figure_closes_the_picker(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.remove()
assert sim.app.state.selector is None
assert not sim.app.mouse.knob_captured
assert isinstance(ring(sim), EffectReverseSwipe)
# ------------------------------------------------------------------------- memory
async def test_putting_the_figure_back_resumes_the_picked_album(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.press("rotary")
await mouse.wait(3)
await mouse.remove()
await mouse.place("fuchs")
mouse.check("album", "Fuchs - B")
assert sim.player.position == pytest.approx(3.0)
assert picker(sim).selected == 1
async def test_another_figure_in_between_keeps_the_album_but_not_the_place(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.press("rotary")
await mouse.wait(TRACK_SECONDS + 3)
await mouse.remove()
await mouse.place("eule")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("album", "Fuchs - B")
mouse.check("track", "0")
assert sim.player.position == pytest.approx(0.0)
assert picker(sim).selected == 1
async def test_an_album_played_to_the_end_is_still_the_one_picked_next_time(
mouse: SimulatorDriver,
) -> None:
await mouse.place("fuchs")
await mouse.turn(1)
await mouse.press("rotary")
await mouse.wait(2 * TRACK_SECONDS + 1)
mouse.check("playing", "false")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("album", "Fuchs - B")
mouse.check("track", "0")
async def test_an_album_started_from_the_web_becomes_the_figures_pick(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.play_album("Fuchs - C")
await mouse.wait(2)
await mouse.place("fuchs")
mouse.check("album", "Fuchs - C")
assert picker(sim).selected == 2
# -------------------------------------------------------------------------- sound
@pytest.fixture
def sounds(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, bytes]]:
played: list[tuple[str, bytes]] = []
monkeypatch.setattr(
selection, "play_wav_soon", lambda device, wav: played.append((device, wav))
)
return played
async def _audible(config_dir: Path, effects_volume: int = 100) -> Simulation:
config = deepcopy(VALID_CONFIG)
config["general"]["alsa_device"] = "softvol_effects"
config["general"]["effects_volume"] = effects_volume
return await _simulation(config_dir, config)
async def test_each_step_ticks_but_fast_turns_do_not_pile_up(
config_dir: Path, sounds: list[tuple[str, bytes]]
) -> None:
sim = await _audible(config_dir)
try:
await sim.driver.place("fuchs")
await sim.driver.turn(1)
await sim.driver.turn(1) # no time passed: one tick for both
await sim.driver.wait(0.1)
await sim.driver.turn(1)
level = ui_level(sim.player.volume / 100, 1.0)
assert sounds == [("softvol_effects", tick_wav(gain=TICK_GAIN * level))] * 2
finally:
await sim.aclose()
async def test_picking_an_album_plays_the_confirm_sound(
config_dir: Path, sounds: list[tuple[str, bytes]]
) -> None:
sim = await _audible(config_dir, effects_volume=50)
try:
await sim.driver.place("fuchs")
await sim.driver.press("rotary")
level = ui_level(sim.player.volume / 100, 0.5)
assert sounds == [("softvol_effects", confirm_wav(gain=CONFIRM_GAIN * level))]
finally:
await sim.aclose()
async def test_no_picker_sounds_with_effects_turned_off(
config_dir: Path, sounds: list[tuple[str, bytes]]
) -> None:
sim = await _audible(config_dir, effects_volume=0)
try:
await sim.driver.place("fuchs")
await sim.driver.turn(1)
await sim.driver.press("rotary")
assert sounds == []
finally:
await sim.aclose()

View File

@@ -20,6 +20,7 @@ SETTINGS = {
"initial_volume": 40,
"volume_increment": 5,
"button_leds_brightness": 0.5,
"effects_volume": 100,
}
#: A config file with comments, to prove saving does not flatten them.

View File

@@ -0,0 +1,47 @@
"""How loud UI sounds are, and the album picker's two sounds."""
from __future__ import annotations
import io
import wave
from collections.abc import Callable
import pytest
from musicmouse.blubb import RATE, confirm_wav, tick_wav
from musicmouse.sound import MIN_PLAYER_VOLUME, ui_level
def test_ui_sounds_follow_the_music_volume() -> None:
assert ui_level(0.8, 1.0) == pytest.approx(0.8)
assert ui_level(1.0, 0.5) == pytest.approx(0.5)
def test_ui_sounds_never_drop_below_the_minimum_player_volume() -> None:
# A muted player must not mute the timer, nor make the album picker silent.
assert ui_level(0.0, 1.0) == pytest.approx(MIN_PLAYER_VOLUME)
assert ui_level(0.0, 0.5) == pytest.approx(MIN_PLAYER_VOLUME * 0.5)
def test_parents_can_still_turn_ui_sounds_off() -> None:
assert ui_level(0.8, 0.0) == 0.0
@pytest.mark.parametrize(("make", "longest"), [(tick_wav, 0.05), (confirm_wav, 0.4)])
def test_the_picker_sounds_are_short_mono_wavs(make: Callable[..., bytes], longest: float) -> None:
with wave.open(io.BytesIO(make(gain=0.5))) as wav:
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
assert 0 < wav.getnframes() / RATE < longest
def test_the_gain_sets_the_peak() -> None:
def peak(data: bytes) -> int:
with wave.open(io.BytesIO(data)) as wav:
frames = wav.readframes(wav.getnframes())
return max(
abs(int.from_bytes(frames[i : i + 2], "little", signed=True))
for i in range(0, len(frames), 2)
)
assert peak(tick_wav(gain=1.0)) == pytest.approx(32767, abs=1)
assert peak(tick_wav(gain=0.25)) == pytest.approx(32767 / 4, abs=1)

View File

@@ -207,7 +207,7 @@ async def test_playing_a_figure_album_reuses_the_figure_playlist(
await client.post("/api/play", json={"album_id": album["id"]})
await sim.bus.drain()
assert sim.player.playlist is sim.app.playlists["fuchs"]
assert sim.player.playlist is sim.app.playlists["fuchs"][0]
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None: