"""Parent mode: the small set of settings the UI may change, and how they are saved.""" from __future__ import annotations from collections.abc import AsyncIterator from pathlib import Path import httpx2 import pytest from musicmouse.config import WebConfig, load_config from musicmouse.services.web.service import build_app from musicmouse.services.web.settings import to_device_volume, to_percent from musicmouse.simulator.harness import Simulation, build_simulation from tests.conftest import VALID_CONFIG, write_config SETTINGS = { "min_volume": 0, "max_volume": 60, "initial_volume": 40, "volume_increment": 5, "button_leds_brightness": 0.5, } #: A config file with comments, to prove saving does not flatten them. COMMENTED = """\ # The MusicMouse config. general: library: root: music # where the shelves live cache: .cache serial_port: "/dev/ttyUSB0" alsa_device: simulate # no sound from the test suite, please # Volume, 0..100. min_volume: 0 max_volume: 60 initial_volume: 40 figures: fuchs: id: "04a1b2c3d4" colors: ["#ff6600", "#ffcc00", "#331100", "wff"] eule: id: "04b2c3d4e5" colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"] """ @pytest.fixture async def sim(config_dir: Path) -> AsyncIterator[Simulation]: config = load_config(write_config(config_dir, VALID_CONFIG)) simulation = await build_simulation(config) try: yield simulation finally: await simulation.aclose() @pytest.fixture async def client(sim: Simulation, config_dir: Path) -> AsyncIterator[httpx2.AsyncClient]: path = config_dir / "config.yml" path.write_text(COMMENTED, encoding="utf-8") api, hub = build_app(sim.app, WebConfig(), path) hub.start() transport = httpx2.ASGITransport(app=api) try: async with httpx2.AsyncClient(transport=transport, base_url="http://mouse") as http: yield http finally: hub.stop() # ------------------------------------------------------------------------- mapping def test_full_percent_is_the_configured_ceiling(config_dir: Path) -> None: general = load_config(write_config(config_dir, VALID_CONFIG)).general assert to_device_volume(100, general) == 60 assert to_device_volume(0, general) == 0 assert to_device_volume(50, general) == 30 def test_percent_round_trips(config_dir: Path) -> None: general = load_config(write_config(config_dir, VALID_CONFIG)).general for percent in (0, 25, 50, 75, 100): assert to_percent(to_device_volume(percent, general), general) == percent def test_a_degenerate_range_reads_as_full(config_dir: Path) -> None: """min == max is a legal config; it must not divide by zero.""" general = load_config(write_config(config_dir, VALID_CONFIG)).general general.min_volume = general.max_volume = 40 assert to_percent(40, general) == 100 assert to_device_volume(50, general) == 40 # -------------------------------------------------------------------------- reading async def test_settings_expose_only_the_editable_subset(client: httpx2.AsyncClient) -> None: body = (await client.get("/api/settings")).json() assert set(body) == set(SETTINGS) assert body["max_volume"] == 60 # -------------------------------------------------------------------------- writing async def test_saving_keeps_the_file_readable_and_commented( client: httpx2.AsyncClient, config_dir: Path ) -> None: response = await client.put("/api/settings", json={**SETTINGS, "max_volume": 45}) assert response.status_code == 200 text = (config_dir / "config.yml").read_text(encoding="utf-8") assert "max_volume: 45" in text assert "# The MusicMouse config." in text assert "# where the shelves live" in text assert "# Volume, 0..100." in text assert "# no sound from the test suite, please" in text # And it still loads. reloaded = load_config(config_dir / "config.yml") assert reloaded.general.max_volume == 45 assert set(reloaded.figures) == {"fuchs", "eule"} async def test_a_new_ceiling_applies_to_the_running_player( client: httpx2.AsyncClient, sim: Simulation ) -> None: """A parent lowering the ceiling expects the next song to be quieter, not the next boot.""" await client.post("/api/volume", json={"percent": 100}) await sim.bus.drain() assert sim.player.volume == 60 response = await client.put( "/api/settings", json={**SETTINGS, "max_volume": 30, "initial_volume": 20} ) assert response.status_code == 200 await sim.bus.drain() assert sim.player.volume == 30 assert (await client.get("/api/state")).json()["volume"] == 100 async def test_an_inverted_range_is_rejected(client: httpx2.AsyncClient) -> None: response = await client.put("/api/settings", json={**SETTINGS, "min_volume": 70}) assert response.status_code == 422 assert "min_volume" in response.json()["detail"] async def test_an_initial_volume_outside_the_range_is_rejected( client: httpx2.AsyncClient, ) -> None: response = await client.put("/api/settings", json={**SETTINGS, "initial_volume": 90}) assert response.status_code == 422 assert "initial_volume" in response.json()["detail"] async def test_out_of_bounds_values_are_rejected_by_the_schema( client: httpx2.AsyncClient, ) -> None: assert ( await client.put("/api/settings", json={**SETTINGS, "button_leds_brightness": 5}) ).status_code == 422 assert ( await client.put("/api/settings", json={**SETTINGS, "volume_increment": 0}) ).status_code == 422 async def test_a_rejected_save_leaves_the_file_alone( client: httpx2.AsyncClient, config_dir: Path ) -> None: before = (config_dir / "config.yml").read_text(encoding="utf-8") await client.put("/api/settings", json={**SETTINGS, "min_volume": 70}) assert (config_dir / "config.yml").read_text(encoding="utf-8") == before