Full rearchitecture using Claude

- event bus systen
- all components are independent
- preparation for web frontend
This commit is contained in:
2026-08-26 13:22:28 +02:00
parent 55bcc9448c
commit d44c24ec97
70 changed files with 7591 additions and 1011 deletions

View File

@@ -0,0 +1,202 @@
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, build_playlists, 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_figure_folder_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()
def test_tag_map_and_playlists(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}
config = load_config(write_config(config_dir, data))
playlists = build_playlists(config)
assert len(playlists["neu"]) == 0
assert "no media folder" in caplog.text
# --------------------------------------------------------------------- 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_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
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)