Files
musicmouse/python-backend/tests/test_config.py
Martin Bauer 459599d2ba Number keys: the keyboard's digits work like the remote's; a key can hold one song
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>
2026-10-03 19:41:34 +02:00

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)