"""The screen service: runs the configured command per toggle, and never takes the bus down.""" from __future__ import annotations import asyncio import io import os import shlex import sys import wave from collections.abc import AsyncIterator from pathlib import Path import pytest from musicmouse.blubb import RATE, blubb_wav from musicmouse.bus import EventBus from musicmouse.config import ScreenConfig from musicmouse.events import ToggleScreenRequested from musicmouse.services.screen import ScreenService def _python(code: str) -> str: return f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}" def _appending(log: Path, *, then: str = "") -> str: """A command that appends a line to ``log`` each time it runs.""" return _python(f"open({str(log)!r}, 'a').write('ran\\n'); {then}") @pytest.fixture async def bus() -> AsyncIterator[EventBus]: async with EventBus() as bus: yield bus async def _settle(service: ScreenService) -> None: while service._running is not None and not service._running.done(): await asyncio.sleep(0.01) async def _started( bus: EventBus, config: ScreenConfig, *, alsa_device: str | None = None, volume: float = 0.5, ) -> tuple[ScreenService, asyncio.Task[None]]: service = ScreenService(bus, config, alsa_device=alsa_device, volume=lambda: volume) task = asyncio.create_task(service.run()) await asyncio.sleep(0) # let it subscribe return service, task async def test_each_press_runs_the_command(bus: EventBus, tmp_path: Path) -> None: log = tmp_path / "ran" service, task = await _started(bus, ScreenConfig(toggle_command=_appending(log))) try: for _ in range(2): await bus.emit_and_wait(ToggleScreenRequested(source="lirc")) await _settle(service) finally: task.cancel() assert log.read_text() == "ran\nran\n" async def test_a_press_while_the_command_runs_is_ignored(bus: EventBus, tmp_path: Path) -> None: log = tmp_path / "ran" command = _appending(log, then="import time; time.sleep(0.3)") service, task = await _started(bus, ScreenConfig(toggle_command=command)) try: await bus.emit_and_wait(ToggleScreenRequested(source="lirc")) await bus.emit_and_wait(ToggleScreenRequested(source="lirc")) await _settle(service) finally: task.cancel() assert log.read_text() == "ran\n" async def test_a_failing_command_is_logged(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None: command = _python("import sys; print('no display'); sys.exit(3)") service, task = await _started(bus, ScreenConfig(toggle_command=command)) try: await bus.emit_and_wait(ToggleScreenRequested(source="lirc")) await _settle(service) finally: task.cancel() assert "exit 3" in caplog.text assert "no display" in caplog.text async def test_a_missing_command_is_logged( bus: EventBus, caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: service, task = await _started( bus, ScreenConfig(toggle_command=str(tmp_path / "no-such-command")) ) try: await bus.emit_and_wait(ToggleScreenRequested(source="lirc")) await _settle(service) finally: task.cancel() assert "could not start" in caplog.text async def test_a_hung_command_is_killed(bus: EventBus, caplog: pytest.LogCaptureFixture) -> None: command = _python("import time; time.sleep(30)") service, task = await _started(bus, ScreenConfig(toggle_command=command, timeout=0.2)) try: await bus.emit_and_wait(ToggleScreenRequested(source="lirc")) await asyncio.wait_for(_settle(service), 5) finally: task.cancel() assert "killed" in caplog.text # ------------------------------------------------------------------------ the blubb def test_the_blubb_is_a_short_mono_wav() -> None: with wave.open(io.BytesIO(blubb_wav(on=True, gain=0.5))) as wav: assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE) assert 0.1 < wav.getnframes() / RATE < 0.5 def test_on_and_off_sound_different() -> None: assert blubb_wav(on=True, gain=0.5) != blubb_wav(on=False, gain=0.5) @pytest.fixture def fake_aplay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """An ``aplay`` on PATH that records its arguments and what it was fed.""" bin_dir = tmp_path / "bin" bin_dir.mkdir() record = tmp_path / "aplay" script = bin_dir / "aplay" script.write_text( f'#!/bin/sh\necho "$@" > {shlex.quote(str(record))}.args\n' f"cat > {shlex.quote(str(record))}.wav\n" ) script.chmod(0o755) monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}") return record async def test_a_switch_plays_the_blubb_its_way(bus: EventBus, fake_aplay: Path) -> None: service, task = await _started( bus, ScreenConfig(toggle_command=_python("print('off')")), alsa_device="softvol_effects" ) try: await bus.emit_and_wait(ToggleScreenRequested(source="lirc")) await _settle(service) finally: task.cancel() assert Path(f"{fake_aplay}.args").read_text().split() == ["-q", "-D", "softvol_effects", "-"] assert Path(f"{fake_aplay}.wav").read_bytes() == blubb_wav(on=False, gain=0.35 * 0.5) @pytest.mark.parametrize( ("output", "alsa_device", "volume"), [ ("print('on')", None, 0.5), # simulated audio ("print('on')", "softvol_effects", 0.0), # muted ("print('done')", "softvol_effects", 0.5), # no direction to go by ], ) async def test_no_blubb( bus: EventBus, fake_aplay: Path, output: str, alsa_device: str | None, volume: float ) -> None: service, task = await _started( bus, ScreenConfig(toggle_command=_python(output)), alsa_device=alsa_device, volume=volume ) try: await bus.emit_and_wait(ToggleScreenRequested(source="lirc")) await _settle(service) finally: task.cancel() assert not Path(f"{fake_aplay}.wav").exists()