Implements the room-control page from the design mockup: a scenes row above cards for shutters, color lamps, and brightness-only lamps, all driven by a new `general.ha` config section (server URL, token, ordered device/scene lists). The backend proxies every Home Assistant call server-side (GET/POST /api/ha/...) rather than the browser calling Home Assistant directly, so the long-lived token never leaves the LAN device and Home Assistant's own CORS settings don't need to know about musicmouse at all. Card kind (shutter/color/brightness-only) is inferred at runtime from what Home Assistant reports about each entity, not configured explicitly. Also stops tracking python-backend/config.yml, which had drifted into the repo despite its own header saying it shouldn't be - it now carries real credentials locally and needs to stay untracked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
244 lines
8.0 KiB
Python
244 lines
8.0 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_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)
|