The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.
A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
10 KiB
Python
302 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from musicmouse.color import ColorRGBW, parse_color
|
|
from musicmouse.config import ConfigError, load_config
|
|
from tests.conftest import VALID_CONFIG, write_config
|
|
|
|
|
|
def _config(**general: Any) -> dict[str, Any]:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["general"].update(general)
|
|
return data
|
|
|
|
|
|
def test_loads_valid_config(config_dir: Path) -> None:
|
|
config = load_config(write_config(config_dir, VALID_CONFIG))
|
|
|
|
assert set(config.figures) == {"fuchs", "eule"}
|
|
assert config.figures["fuchs"].id == bytes.fromhex("04a1b2c3d4")
|
|
assert config.figures["fuchs"].colors.primary == ColorRGBW(1.0, 0.4, 0.0, 0)
|
|
assert config.figures["fuchs"].colors.accent == ColorRGBW(0, 0, 0, 1.0)
|
|
assert config.general.max_volume == 60
|
|
|
|
|
|
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.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(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",
|
|
}
|
|
|
|
|
|
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))
|
|
assert config.general.web is not None
|
|
assert config.general.web.port == 9000
|
|
assert config.general.web.static_dir == (config_dir / "dist").resolve()
|
|
|
|
|
|
def test_ha_section_is_optional(config_dir: Path) -> None:
|
|
assert load_config(write_config(config_dir, VALID_CONFIG)).general.ha is None
|
|
|
|
|
|
def test_lirc_section_is_optional(config_dir: Path) -> None:
|
|
assert load_config(write_config(config_dir, VALID_CONFIG)).general.lirc is None
|
|
|
|
data = _config(lirc={"host": "musicmouse-pi.local"})
|
|
config = load_config(write_config(config_dir, data))
|
|
assert config.general.lirc is not None
|
|
assert config.general.lirc.port == 2222
|
|
assert config.general.lirc.remote_name == "Hauppauge"
|
|
|
|
|
|
def test_remote_mapping_is_optional_and_empty_by_default(config_dir: Path) -> None:
|
|
assert load_config(write_config(config_dir, VALID_CONFIG)).remote == {}
|
|
|
|
|
|
def test_remote_mapping_loads_album_and_series_slots(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["remote"] = {
|
|
"1": {"target_kind": "album", "target": "abc123"},
|
|
"7": {"target_kind": "series", "target": "Wissen macht Ah"},
|
|
}
|
|
config = load_config(write_config(config_dir, data))
|
|
assert config.remote["1"].target_kind == "album"
|
|
assert config.remote["1"].target == "abc123"
|
|
assert config.remote["7"].target_kind == "series"
|
|
|
|
|
|
def test_remote_mapping_rejects_an_out_of_range_digit(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["remote"] = {"10": {"target_kind": "album", "target": "abc123"}}
|
|
message = _error(config_dir, data)
|
|
assert "remote" in message
|
|
|
|
|
|
def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["remote"] = {"1": {"target_kind": "playlist", "target": "abc123"}}
|
|
message = _error(config_dir, data)
|
|
assert "remote" in message
|
|
|
|
|
|
def test_a_remote_slot_can_name_a_song(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["remote"] = {
|
|
"1": {"target_kind": "album", "target": "abc123", "track_index": 4},
|
|
"2": {"target_kind": "album", "target": "abc123"},
|
|
}
|
|
config = load_config(write_config(config_dir, data))
|
|
assert config.remote["1"].track_index == 4
|
|
assert config.remote["2"].track_index == 0
|
|
|
|
|
|
def test_a_series_slot_cannot_name_a_song(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["remote"] = {"7": {"target_kind": "series", "target": "Wissen", "track_index": 1}}
|
|
message = _error(config_dir, data)
|
|
assert "track_index" in message
|
|
|
|
|
|
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
|
|
data = _config(
|
|
ha={
|
|
"url": "http://homeassistant.local:8123",
|
|
"token": "abc123",
|
|
"devices": [{"entity_id": "light.a"}],
|
|
"scenes": [{"entity_id": "scene.a"}],
|
|
}
|
|
)
|
|
config = load_config(write_config(config_dir, data))
|
|
assert config.general.ha is not None
|
|
assert config.general.ha.devices[0].name is None
|
|
assert config.general.ha.scenes[0].name is None
|
|
|
|
|
|
def test_ha_devices_and_scenes_preserve_config_order(config_dir: Path) -> None:
|
|
data = _config(
|
|
ha={
|
|
"url": "http://homeassistant.local:8123",
|
|
"token": "abc123",
|
|
"devices": [
|
|
{"entity_id": "cover.rollo"},
|
|
{"entity_id": "light.deckenlampe"},
|
|
{"entity_id": "light.beyond_links"},
|
|
],
|
|
"scenes": [
|
|
{"entity_id": "scene.aufwachen"},
|
|
{"entity_id": "scene.lesen"},
|
|
],
|
|
}
|
|
)
|
|
config = load_config(write_config(config_dir, data))
|
|
ha = config.general.ha
|
|
assert ha is not None
|
|
assert [d.entity_id for d in ha.devices] == [
|
|
"cover.rollo",
|
|
"light.deckenlampe",
|
|
"light.beyond_links",
|
|
]
|
|
assert [s.entity_id for s in ha.scenes] == ["scene.aufwachen", "scene.lesen"]
|
|
|
|
|
|
def test_ha_requires_devices_or_scenes(config_dir: Path) -> None:
|
|
message = _error(
|
|
config_dir,
|
|
_config(ha={"url": "http://ha", "token": "abc", "devices": [], "scenes": []}),
|
|
)
|
|
assert "configure at least one device or scene" in message
|
|
|
|
|
|
def test_ha_rejects_unknown_key_on_a_device(config_dir: Path) -> None:
|
|
message = _error(
|
|
config_dir,
|
|
_config(
|
|
ha={
|
|
"url": "http://ha",
|
|
"token": "abc",
|
|
"devices": [{"entity_id": "light.a", "kind": "beyond"}],
|
|
"scenes": [],
|
|
}
|
|
),
|
|
)
|
|
assert "unknown option" in message
|
|
|
|
|
|
# --------------------------------------------------------------------- error paths
|
|
|
|
|
|
def _error(directory: Path, data: dict[str, Any]) -> str:
|
|
with pytest.raises(ConfigError) as excinfo:
|
|
load_config(write_config(directory, data))
|
|
return str(excinfo.value)
|
|
|
|
|
|
def test_unknown_option_is_rejected_with_its_path(config_dir: Path) -> None:
|
|
message = _error(config_dir, _config(buton_leds_brightness=0.5))
|
|
assert "general.buton_leds_brightness" in message
|
|
assert "unknown option" in message
|
|
|
|
|
|
def test_bad_color_names_the_figure_and_the_position(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["figures"]["fuchs"]["colors"] = ["#ff6600", "not-a-color", "#331100", "wff"]
|
|
|
|
message = _error(config_dir, data)
|
|
assert "figures.fuchs.colors.secondary" in message
|
|
assert "'#rrggbb' or 'wNN'" in message
|
|
|
|
|
|
def test_wrong_number_of_colors(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["figures"]["eule"]["colors"] = ["#ffffff", "#000000"]
|
|
|
|
message = _error(config_dir, data)
|
|
assert "figures.eule.colors" in message
|
|
assert "exactly 4 colors" in message
|
|
|
|
|
|
def test_duplicate_tag_ids_are_rejected(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["figures"]["eule"]["id"] = data["figures"]["fuchs"]["id"]
|
|
|
|
message = _error(config_dir, data)
|
|
assert "both use tag id 04a1b2c3d4" in message
|
|
|
|
|
|
def test_tag_id_length_is_checked(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["figures"]["fuchs"]["id"] = "04a1b2"
|
|
|
|
message = _error(config_dir, data)
|
|
assert "figures.fuchs.id" in message
|
|
assert "expected 5 bytes" in message
|
|
|
|
|
|
def test_all_zero_tag_id_is_reserved(config_dir: Path) -> None:
|
|
data = copy.deepcopy(VALID_CONFIG)
|
|
data["figures"]["fuchs"]["id"] = "0000000000"
|
|
|
|
assert "reserved" in _error(config_dir, data)
|
|
|
|
|
|
def test_volume_range_is_checked(config_dir: Path) -> None:
|
|
message = _error(config_dir, _config(min_volume=50, max_volume=20, initial_volume=30))
|
|
assert "must not exceed max_volume" in message
|
|
|
|
|
|
def test_initial_volume_must_lie_in_range(config_dir: Path) -> None:
|
|
message = _error(config_dir, _config(min_volume=10, max_volume=20, initial_volume=90))
|
|
assert "must lie between" 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
|
|
|
|
|
|
def test_every_problem_is_reported_at_once(config_dir: Path) -> None:
|
|
data = _config(volume_increment=0)
|
|
data["figures"]["fuchs"]["colors"] = ["#ff6600", "nope", "#331100", "wff"]
|
|
|
|
message = _error(config_dir, data)
|
|
assert "2 problems" in message
|
|
assert "general.volume_increment" in message
|
|
assert "figures.fuchs.colors.secondary" in message
|
|
|
|
|
|
def test_directory_instead_of_file_says_so(config_dir: Path) -> None:
|
|
with pytest.raises(ConfigError, match="Pass the config file itself"):
|
|
load_config(config_dir)
|
|
|
|
|
|
def test_missing_file(tmp_path: Path) -> None:
|
|
with pytest.raises(ConfigError, match="Cannot read config file"):
|
|
load_config(tmp_path / "nope.yml")
|
|
|
|
|
|
def test_malformed_yaml(tmp_path: Path) -> None:
|
|
path = tmp_path / "config.yml"
|
|
path.write_text("general: [unclosed\n", encoding="utf-8")
|
|
with pytest.raises(ConfigError, match="not valid YAML"):
|
|
load_config(path)
|
|
|
|
|
|
# --------------------------------------------------------------------------- colors
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("text", "expected"),
|
|
[
|
|
("#000000", ColorRGBW(0, 0, 0, 0)),
|
|
("#ffffff", ColorRGBW(1, 1, 1, 0)),
|
|
("w00", ColorRGBW(0, 0, 0, 0)),
|
|
("wff", ColorRGBW(0, 0, 0, 1)),
|
|
],
|
|
)
|
|
def test_parse_color(text: str, expected: ColorRGBW) -> None:
|
|
assert parse_color(text) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("text", ["#fff", "#gggggg", "orange", "", "#ff66000"])
|
|
def test_parse_color_rejects(text: str) -> None:
|
|
with pytest.raises(ValueError, match="unrecognized color format"):
|
|
parse_color(text)
|