feat: add pluggable alarm styles and migrate wecker.py
Introduce a styles/ plugin package: - styles/base.py: AlarmStyle ABC (injected set_led, update()->bool contract) - styles/blink.py: BlinkStyle, the existing count-the-blinks puzzle moved out of wecker.py's AlarmClock - styles/simple.py: SimpleStyle, press-once-to-stop (the new default style) - styles/__init__.py: STYLES registry + get_style() validator + LEGACY_STYLE wecker.py now resolves the style via the registry and owns only the shared music/button/cleanup loop; the --style CLI arg defaults to blink so existing cron entries keep their behaviour. Unknown styles raise before hardware init.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
from styles.blink import (
|
||||
STATE_BLINKING,
|
||||
STATE_EVALUATING,
|
||||
STATE_WAIT_BEFORE_BLINK,
|
||||
STATE_WAIT_BEFORE_RETRY,
|
||||
STATE_WAIT_FOR_INPUT,
|
||||
BlinkStyle,
|
||||
)
|
||||
|
||||
|
||||
def _led():
|
||||
"""Return (calls list, set_led callable recording each call)."""
|
||||
calls = []
|
||||
return calls, lambda on: calls.append(on)
|
||||
|
||||
|
||||
def test_press_starts_puzzle():
|
||||
_, set_led = _led()
|
||||
style = BlinkStyle(set_led)
|
||||
assert style.update(100.0, True) is True
|
||||
assert style.state == STATE_WAIT_BEFORE_BLINK
|
||||
|
||||
|
||||
def test_blink_correct_sequence_stops(monkeypatch):
|
||||
_, set_led = _led()
|
||||
style = BlinkStyle(set_led)
|
||||
|
||||
style.update(100.0, True) # press -> start puzzle
|
||||
assert style.state == STATE_WAIT_BEFORE_BLINK
|
||||
|
||||
monkeypatch.setattr("styles.blink.random.randint", lambda a, b: 3)
|
||||
style.update(103.1, False) # 3s passed -> blinking, 3 blinks
|
||||
assert style.state == STATE_BLINKING
|
||||
assert style.target_blinks == 3
|
||||
|
||||
style.update(105.0, False) # run through the blink sequence
|
||||
assert style.state == STATE_WAIT_FOR_INPUT
|
||||
|
||||
# Press exactly 3 times
|
||||
for t in (105.5, 106.0, 106.5):
|
||||
style.update(t, True)
|
||||
style.update(t + 0.05, False)
|
||||
assert style.user_presses == 3
|
||||
|
||||
style.update(109.7, False) # 3s idle -> evaluating
|
||||
assert style.state == STATE_EVALUATING
|
||||
assert style.update(109.8, False) is False # correct -> stop
|
||||
|
||||
|
||||
def test_blink_incorrect_retries():
|
||||
_, set_led = _led()
|
||||
style = BlinkStyle(set_led)
|
||||
style.state = STATE_WAIT_FOR_INPUT
|
||||
style.target_blinks = 3
|
||||
|
||||
style.update(100.0, True) # one press
|
||||
style.update(100.1, False)
|
||||
|
||||
style.last_interaction_time = 100.1
|
||||
style.update(103.2, False) # -> evaluating
|
||||
keep_running = style.update(103.3, False) # 1 != 3 -> retry
|
||||
|
||||
assert keep_running is True
|
||||
assert style.state == STATE_WAIT_BEFORE_RETRY
|
||||
|
||||
|
||||
def test_blinking_non_blocking():
|
||||
calls, set_led = _led()
|
||||
style = BlinkStyle(set_led)
|
||||
style.state = STATE_BLINKING
|
||||
style.target_blinks = 2
|
||||
|
||||
style.update(100.0, False) # start of blinking: LED on
|
||||
assert style.state == STATE_BLINKING
|
||||
assert calls[-1] is True
|
||||
|
||||
style.update(100.3, False) # mid-blink toggles by elapsed time
|
||||
assert calls[-1] is False
|
||||
|
||||
# 2 blinks * 2 phases * 0.3s = 1.2s
|
||||
style.update(101.2, False)
|
||||
assert style.state == STATE_WAIT_FOR_INPUT
|
||||
assert calls[-1] is False
|
||||
|
||||
|
||||
def test_blinking_advances_time():
|
||||
_, set_led = _led()
|
||||
style = BlinkStyle(set_led)
|
||||
style.state = STATE_BLINKING
|
||||
style.target_blinks = 4
|
||||
|
||||
# 4 blinks * 2 phases * 0.3s = 2.4s
|
||||
style.update(100.0, False)
|
||||
style.update(102.4, False)
|
||||
|
||||
assert style.state == STATE_WAIT_FOR_INPUT
|
||||
assert style.last_interaction_time == 102.4
|
||||
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
|
||||
from styles import STYLES, get_style
|
||||
from styles.blink import BlinkStyle
|
||||
from styles.simple import SimpleStyle
|
||||
|
||||
|
||||
def test_registry_has_blink_and_simple():
|
||||
assert STYLES["blink"] is BlinkStyle
|
||||
assert STYLES["simple"] is SimpleStyle
|
||||
|
||||
|
||||
def test_get_style_returns_class():
|
||||
assert get_style("blink") is BlinkStyle
|
||||
assert get_style("simple") is SimpleStyle
|
||||
|
||||
|
||||
def test_get_style_unknown_raises():
|
||||
with pytest.raises(ValueError, match="Unknown alarm style"):
|
||||
get_style("nope")
|
||||
@@ -0,0 +1,23 @@
|
||||
from styles.simple import SimpleStyle
|
||||
|
||||
|
||||
def test_simple_keeps_ringing_when_not_pressed():
|
||||
calls = []
|
||||
style = SimpleStyle(lambda on: calls.append(on))
|
||||
assert style.update(0.0, False) is True
|
||||
assert calls[-1] is True # LED solid on while ringing
|
||||
|
||||
|
||||
def test_simple_stops_on_first_press():
|
||||
calls = []
|
||||
style = SimpleStyle(lambda on: calls.append(on))
|
||||
assert style.update(0.0, True) is False # press -> stop
|
||||
|
||||
|
||||
def test_simple_led_off_on_release_after_press():
|
||||
# After a press the style stops; verify it never turns the LED off itself
|
||||
# (the runner's cleanup handles that). It only ever asserts LED on.
|
||||
calls = []
|
||||
style = SimpleStyle(lambda on: calls.append(on))
|
||||
style.update(0.0, False)
|
||||
assert calls == [True]
|
||||
+17
-89
@@ -33,104 +33,32 @@ def test_set_led(mock_gpio):
|
||||
|
||||
|
||||
@patch("wecker.time.time")
|
||||
def test_run_alarm_start_to_wait(mock_time, mock_gpio, mock_pygame):
|
||||
# Mock pygame.mixer.get_init() to return True so music plays
|
||||
def test_run_alarm_plays_music(mock_time, mock_gpio, mock_pygame):
|
||||
mock_pygame.mixer.get_init.return_value = True
|
||||
|
||||
# Just run it in test mode, it should execute the loop once and exit
|
||||
wecker.run_alarm(test_mode=True)
|
||||
assert mock_pygame.mixer.music.play.called
|
||||
|
||||
|
||||
def test_state_machine_evaluation(mock_gpio, mock_pygame):
|
||||
clock = wecker.AlarmClock()
|
||||
|
||||
# Transition to ringing -> wait before blink
|
||||
clock.update(100.0, True) # button press
|
||||
assert clock.state == wecker.STATE_WAIT_BEFORE_BLINK
|
||||
|
||||
# Wait 3 seconds -> blinking
|
||||
with patch("wecker.random.randint", return_value=3):
|
||||
clock.update(103.1, False)
|
||||
assert clock.state == wecker.STATE_BLINKING
|
||||
assert clock.target_blinks == 3
|
||||
|
||||
# Advance time past the blink sequence (3 blinks * 2 phases * 0.3s = 1.8s)
|
||||
clock.update(105.0, False)
|
||||
assert clock.state == wecker.STATE_WAIT_FOR_INPUT
|
||||
|
||||
# Press 1
|
||||
clock.update(105.5, True)
|
||||
clock.update(105.6, False)
|
||||
# Press 2
|
||||
clock.update(106.0, True)
|
||||
clock.update(106.1, False)
|
||||
# Press 3
|
||||
clock.update(106.5, True)
|
||||
clock.update(106.6, False)
|
||||
|
||||
assert clock.user_presses == 3
|
||||
|
||||
# Wait 3 seconds to evaluate
|
||||
clock.update(109.7, False) # Triggers state change
|
||||
keep_running = clock.update(109.8, False) # Triggers evaluation
|
||||
|
||||
# It should evaluate, see it's correct, and return False (stop running)
|
||||
assert clock.state == wecker.STATE_EVALUATING
|
||||
assert not keep_running
|
||||
def test_run_alarm_unknown_style_raises(mock_gpio, mock_pygame):
|
||||
"""An unknown style is rejected before any hardware is touched."""
|
||||
with pytest.raises(ValueError, match="Unknown alarm style"):
|
||||
wecker.run_alarm(style_name="nope", test_mode=True)
|
||||
mock_pygame.mixer.init.assert_not_called()
|
||||
|
||||
|
||||
def test_state_machine_incorrect(mock_gpio, mock_pygame):
|
||||
clock = wecker.AlarmClock()
|
||||
clock.state = wecker.STATE_WAIT_FOR_INPUT
|
||||
clock.target_blinks = 3
|
||||
def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame):
|
||||
"""run_alarm looks up the style by name and drives the returned style."""
|
||||
mock_pygame.mixer.get_init.return_value = True
|
||||
fake = MagicMock()
|
||||
fake.update.return_value = True # keep ringing
|
||||
fake_style_cls = MagicMock(return_value=fake)
|
||||
|
||||
# Only press once
|
||||
clock.update(100.0, True)
|
||||
clock.update(100.1, False)
|
||||
with patch("wecker.get_style", return_value=fake_style_cls) as get_style:
|
||||
wecker.run_alarm(style_name="whatever", test_mode=True)
|
||||
|
||||
# Wait to evaluate
|
||||
clock.last_interaction_time = 100.1
|
||||
clock.update(103.2, False) # triggers eval state
|
||||
keep_running = clock.update(103.3, False) # evals to incorrect
|
||||
|
||||
# Evaluated incorrectly, should wait before retry
|
||||
assert keep_running
|
||||
assert clock.state == wecker.STATE_WAIT_BEFORE_RETRY
|
||||
|
||||
|
||||
def test_blinking_is_non_blocking(mock_gpio, mock_pygame):
|
||||
clock = wecker.AlarmClock()
|
||||
clock.state = wecker.STATE_BLINKING
|
||||
clock.target_blinks = 2
|
||||
|
||||
# Start of blinking: LED on
|
||||
clock.update(100.0, False)
|
||||
assert clock.state == wecker.STATE_BLINKING
|
||||
mock_gpio.output.assert_called_with(wecker.LED_PIN, mock_gpio.LOW)
|
||||
|
||||
# Mid-blink: LED toggles based on elapsed time
|
||||
clock.update(100.3, False)
|
||||
mock_gpio.output.assert_called_with(wecker.LED_PIN, mock_gpio.HIGH)
|
||||
|
||||
# After sequence completes (2 blinks * 2 phases * 0.3s = 1.2s)
|
||||
clock.update(101.2, False)
|
||||
assert clock.state == wecker.STATE_WAIT_FOR_INPUT
|
||||
mock_gpio.output.assert_called_with(wecker.LED_PIN, mock_gpio.HIGH)
|
||||
|
||||
|
||||
def test_blinking_updates_time_correctly(mock_gpio, mock_pygame):
|
||||
clock = wecker.AlarmClock()
|
||||
clock.state = wecker.STATE_BLINKING
|
||||
clock.target_blinks = 4
|
||||
|
||||
# Start blinking, then advance to the end of the sequence.
|
||||
# 4 blinks * 2 phases * 0.3s = 2.4s
|
||||
clock.update(100.0, False)
|
||||
clock.update(102.4, False)
|
||||
|
||||
assert clock.state == wecker.STATE_WAIT_FOR_INPUT
|
||||
assert clock.last_interaction_time == 102.4
|
||||
get_style.assert_called_once_with("whatever")
|
||||
fake_style_cls.assert_called_once_with(wecker.set_led)
|
||||
fake.update.assert_called()
|
||||
|
||||
|
||||
def test_setup_uses_env_music_file(mock_gpio, mock_pygame, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user