wake-alarm/wake_alarm/tests/test_curtains.py
Krzysztof kuhy Rudnicki f5eb7fc2bd Phase 7: curtains open on the alarm, no-op until hardware exists (item 5)
_curtains.py: a two-method driver protocol, a registry, and a logging no-op
default. prepare_hardware() calls open_curtains() next to turn_on_plug().

The no-op is not a placeholder awaiting hardware -- it is the correct
behaviour on a machine with no motor. Shipping it now means the call site and
the JSONL event are real, so buying a motor later is a config file rather than
a code change.

Shaped like _smart_plug.py, which solves the same problem: absent or malformed
config means "no curtains" rather than an error, an unknown driver name
degrades with a WARNING, and a driver that throws is caught -- a jammed motor
must never be the reason nobody is woken. The call sits on the startup path,
ahead of the first beep, so an exception escaping it would take the alarm down
entirely.

Why bother at all with no hardware: the audio failsafe silences the alarm at
FAILSAFE_SILENCE_SECONDS, and daylight is the one wake cue that outlives it --
and the only one independent of a sink, a speaker or an HDMI handshake, which
is the exact chain that made this alarm silent in the first place.

DOCS-curtains-hardware.md has the buy list, the three rules a new driver must
follow, and what is deliberately excluded (no cloud, no scheduling of its own,
no close-at-night -- closing belongs to the night lockdown, item 13).

CURTAINS_CONFIG_FILE and CURTAINS_DEFAULT_DRIVER live in _curtains.py rather
than _constants.py, since this is their only consumer -- the same call
_rounds.py and _alarm_session.py already make. That also keeps _constants.py
under the 250-line cap without dragging _sync.py's pre-existing overage into
this commit; splitting _sync.py belongs to the phase that legitimately touches
it (item 8).

465 tests pass, 100% branch coverage. vmbox harness 8/8, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 21:00:11 +02:00

119 lines
4.2 KiB
Python

"""Tests for the curtain driver the alarm calls on its wake sequence."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from wake_alarm import _curtains
from wake_alarm._curtains import NoOpCurtains, get_driver, open_curtains
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture(autouse=True)
def config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point the config at a temporary path, never the real one."""
path = tmp_path / "curtains.json"
monkeypatch.setattr(_curtains, "CURTAINS_CONFIG_FILE", path)
return path
def _write(path: Path, payload: object) -> None:
"""Write *payload* as the curtain config."""
path.write_text(json.dumps(payload))
class TestNoOpDriver:
"""The default driver on a machine with no curtain motor."""
def test_open_reports_success(self, caplog: pytest.LogCaptureFixture) -> None:
"""Logging the intent counts as success — there is nothing to fail."""
with caplog.at_level("INFO"):
assert NoOpCurtains().open() is True
assert "would open" in caplog.text
def test_close_reports_success(self, caplog: pytest.LogCaptureFixture) -> None:
"""The close half exists so a driver swap has both ends to implement."""
with caplog.at_level("INFO"):
assert NoOpCurtains().close() is True
assert "would close" in caplog.text
class TestDriverSelection:
"""Choosing a driver from config, and every way that can go wrong."""
def test_no_config_means_the_noop(self) -> None:
"""The ordinary case today: no file, no hardware, no error."""
assert isinstance(get_driver(), NoOpCurtains)
def test_explicit_noop(self, config_file: Path) -> None:
"""Naming the default explicitly works too."""
_write(config_file, {"driver": "noop"})
assert isinstance(get_driver(), NoOpCurtains)
def test_unknown_driver_falls_back(
self, config_file: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A typo must not stop the alarm; it degrades and says so loudly."""
_write(config_file, {"driver": "definitely-not-a-motor"})
with caplog.at_level("WARNING"):
assert isinstance(get_driver(), NoOpCurtains)
assert "Unknown curtain driver" in caplog.text
def test_non_string_driver_is_ignored(self, config_file: Path) -> None:
"""A JSON number where a name belongs is not a driver."""
_write(config_file, {"driver": 42})
assert isinstance(get_driver(), NoOpCurtains)
def test_malformed_json_falls_back(
self, config_file: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A half-written config means no curtains, not a crash."""
config_file.write_text("{not json")
with caplog.at_level("WARNING"):
assert isinstance(get_driver(), NoOpCurtains)
assert "Cannot read" in caplog.text
def test_non_dict_config_falls_back(
self, config_file: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A JSON list is not a config object."""
_write(config_file, ["noop"])
with caplog.at_level("WARNING"):
assert isinstance(get_driver(), NoOpCurtains)
assert "not a JSON object" in caplog.text
class TestOpenCurtains:
"""The entry point the alarm's startup path calls."""
def test_opens_via_the_configured_driver(self) -> None:
"""The ordinary path."""
assert open_curtains() is True
def test_a_failing_driver_never_stops_the_alarm(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A jammed motor is a curtain problem, not an alarm problem.
The call sits on the startup path, so an exception escaping here would
take the whole alarm down and the user would not be woken at all.
"""
class Jammed:
def open(self) -> bool:
msg = "the motor is stuck"
raise RuntimeError(msg)
def close(self) -> bool:
return False
monkeypatch.setattr(_curtains, "get_driver", Jammed)
with caplog.at_level("WARNING"):
assert open_curtains() is False
assert "the alarm continues" in caplog.text