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)