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_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)