mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
refactor(display): run() stage 2 - an Arbiter decides the scheduled-off blank, follower and WiFi notice, no behaviour change (#733)
run() stage 2: a pure Arbiter.decide() (src/display_arbiter.py) chooses scheduled-off, follower and WiFi notices; everything else takes the existing path. Golden traces byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"""Arbiter.decide (src/display_arbiter.py): the stage-2 Sources as tables.
|
||||
|
||||
decide() is pure, so every case is one row of (inputs) -> expected Source.
|
||||
The rows are written out, not computed, so a change to the priority order
|
||||
has to change a row here as well. The last class checks the controller's
|
||||
side: the snapshot run() gathers gives the same scheduled-off answer as
|
||||
is_display_active did before the Arbiter, through an on-demand session
|
||||
that overrides the schedule and ends (#714).
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from src import display_arbiter # noqa: E402
|
||||
from src.display_arbiter import ( # noqa: E402
|
||||
SCHEDULED_OFF_DWELL,
|
||||
WIFI_NOTICE_DWELL,
|
||||
Arbiter,
|
||||
ArbiterInputs,
|
||||
ArbiterState,
|
||||
ScreenPlan,
|
||||
Source,
|
||||
WifiNotice,
|
||||
wifi_notice_preempts,
|
||||
)
|
||||
|
||||
NOTICE = WifiNotice(message="Connected to HomeNet", expires_at=1_000.0)
|
||||
|
||||
OFF = Source.SCHEDULED_OFF
|
||||
FOLLOW = Source.FOLLOWER
|
||||
WIFI = Source.WIFI
|
||||
LEGACY = Source.LEGACY
|
||||
|
||||
# (schedule_on, on_demand_active, follower_active, notice) -> Source.
|
||||
# Every combination of the stage-2 inputs: 2 x 2 x 2 x 2 = 16 rows.
|
||||
DECIDE_TABLE = [
|
||||
# The gate: scheduled off and no on-demand session -- blank, whatever
|
||||
# else is pending, a follower and a WiFi notice included.
|
||||
(False, False, False, None, OFF),
|
||||
(False, False, False, NOTICE, OFF),
|
||||
(False, False, True, None, OFF),
|
||||
(False, False, True, NOTICE, OFF),
|
||||
# Scheduled off, but on-demand overrides the gate.
|
||||
(False, True, False, None, LEGACY), # on-demand: run() decides
|
||||
(False, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
|
||||
(False, True, True, None, FOLLOW), # follower outranks on-demand
|
||||
(False, True, True, NOTICE, FOLLOW),
|
||||
# Scheduled on.
|
||||
(True, False, False, None, LEGACY), # live / Vegas / rotation
|
||||
(True, False, False, NOTICE, WIFI),
|
||||
(True, False, True, None, FOLLOW),
|
||||
(True, False, True, NOTICE, FOLLOW), # follower outranks WiFi
|
||||
(True, True, False, None, LEGACY),
|
||||
(True, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
|
||||
(True, True, True, None, FOLLOW),
|
||||
(True, True, True, NOTICE, FOLLOW),
|
||||
]
|
||||
|
||||
|
||||
def _inputs(schedule_on, on_demand, follower, notice):
|
||||
return ArbiterInputs(schedule_on=schedule_on, on_demand_active=on_demand,
|
||||
follower_active=follower, wifi_notice=notice)
|
||||
|
||||
|
||||
def test_the_table_covers_every_combination_once():
|
||||
keys = [row[:4] for row in DECIDE_TABLE]
|
||||
combos = list(itertools.product([False, True], [False, True],
|
||||
[False, True], [None, NOTICE]))
|
||||
assert sorted(keys, key=repr) == sorted(combos, key=repr)
|
||||
|
||||
|
||||
class TestDecide:
|
||||
|
||||
@pytest.mark.parametrize("schedule_on,on_demand,follower,notice,expected",
|
||||
DECIDE_TABLE)
|
||||
def test_source(self, schedule_on, on_demand, follower, notice, expected):
|
||||
plan = Arbiter.decide(ArbiterState(),
|
||||
_inputs(schedule_on, on_demand, follower, notice),
|
||||
500.0)
|
||||
assert plan.source is expected
|
||||
|
||||
@pytest.mark.parametrize("schedule_on,on_demand,follower,notice,expected",
|
||||
DECIDE_TABLE)
|
||||
def test_plan_fields(self, schedule_on, on_demand, follower, notice, expected):
|
||||
plan = Arbiter.decide(ArbiterState(),
|
||||
_inputs(schedule_on, on_demand, follower, notice),
|
||||
500.0)
|
||||
if expected is OFF:
|
||||
assert plan == ScreenPlan(OFF, max_duration=SCHEDULED_OFF_DWELL)
|
||||
elif expected is WIFI:
|
||||
assert plan == ScreenPlan(WIFI, max_duration=WIFI_NOTICE_DWELL,
|
||||
notice=NOTICE)
|
||||
else:
|
||||
# A follower paces itself; LEGACY is run()'s existing code.
|
||||
assert plan == ScreenPlan(expected)
|
||||
|
||||
def test_dwells_are_todays(self):
|
||||
# The constants that run() used to hard-code.
|
||||
assert SCHEDULED_OFF_DWELL == 60.0
|
||||
assert WIFI_NOTICE_DWELL == 0.5
|
||||
|
||||
@pytest.mark.parametrize("now", [0.0, NOTICE.expires_at - 1,
|
||||
NOTICE.expires_at, NOTICE.expires_at + 1e6])
|
||||
def test_top_of_pass_wifi_takes_the_notice_as_read(self, now):
|
||||
"""Before the Arbiter, the top of the pass drew whatever notice
|
||||
_check_wifi_status_message returned without comparing the expiry;
|
||||
decide() must not start comparing it with ``now``."""
|
||||
plan = Arbiter.decide(ArbiterState(), _inputs(True, False, False, NOTICE), now)
|
||||
assert plan.source is WIFI
|
||||
|
||||
|
||||
class TestPurity:
|
||||
|
||||
def test_decide_reads_no_clock(self):
|
||||
boom = MagicMock(side_effect=AssertionError("decide read the clock"))
|
||||
with patch("time.time", boom), patch("time.monotonic", boom), \
|
||||
patch("time.perf_counter", boom):
|
||||
for *key, expected in DECIDE_TABLE:
|
||||
assert Arbiter.decide(ArbiterState(), _inputs(*key), 1.0).source is expected
|
||||
|
||||
def test_the_module_imports_no_io_or_clock(self):
|
||||
for name in ("time", "os", "datetime", "threading", "json", "pathlib"):
|
||||
assert not hasattr(display_arbiter, name), name
|
||||
|
||||
def test_same_inputs_same_plan(self):
|
||||
for *key, _expected in DECIDE_TABLE:
|
||||
inputs = _inputs(*key)
|
||||
first = Arbiter.decide(ArbiterState(), inputs, 1.0)
|
||||
assert Arbiter.decide(ArbiterState(), inputs, 1.0) == first
|
||||
assert inputs == _inputs(*key) # not mutated
|
||||
|
||||
def test_inputs_and_plans_are_frozen(self):
|
||||
inputs = _inputs(True, False, False, NOTICE)
|
||||
with pytest.raises(Exception):
|
||||
inputs.schedule_on = False # type: ignore[misc]
|
||||
plan = Arbiter.decide(ArbiterState(), inputs, 1.0)
|
||||
with pytest.raises(Exception):
|
||||
plan.source = OFF # type: ignore[misc]
|
||||
|
||||
|
||||
# (notice, on_demand_active, now) -> preempts. The mid-screen rule.
|
||||
PREEMPT_TABLE = [
|
||||
(None, False, 0.0, False),
|
||||
(None, True, 0.0, False),
|
||||
(NOTICE, False, NOTICE.expires_at - 0.001, True),
|
||||
(NOTICE, False, NOTICE.expires_at, False), # expired at expires_at
|
||||
(NOTICE, False, NOTICE.expires_at + 5, False), # the throttle's stale copy
|
||||
(NOTICE, True, NOTICE.expires_at - 0.001, False), # on-demand outranks it
|
||||
(NOTICE, True, NOTICE.expires_at + 5, False),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("notice,on_demand,now,expected", PREEMPT_TABLE)
|
||||
def test_wifi_notice_preempts(notice, on_demand, now, expected):
|
||||
assert wifi_notice_preempts(notice, on_demand, now) is expected
|
||||
|
||||
|
||||
class TestControllerSnapshot:
|
||||
"""DisplayController._arbiter_inputs, on a stub controller."""
|
||||
|
||||
def _controller(self, *, display_active=True, override=False, on_demand=False,
|
||||
follower=False, status=None):
|
||||
from src.display_controller import DisplayController
|
||||
dc = object.__new__(DisplayController)
|
||||
dc.is_display_active = display_active
|
||||
dc.on_demand_schedule_override = override
|
||||
dc.on_demand_active = on_demand
|
||||
dc.sync_manager = MagicMock()
|
||||
dc.sync_manager.is_follower_active.return_value = follower
|
||||
dc._check_wifi_status_message = MagicMock(return_value=status)
|
||||
return dc
|
||||
|
||||
def test_reads_the_notice_when_it_can_win(self):
|
||||
dc = self._controller(status={"message": "AP mode", "expires_at": 12,
|
||||
"timestamp": 7, "duration": 5})
|
||||
inputs = dc._arbiter_inputs()
|
||||
assert inputs == ArbiterInputs(schedule_on=True, on_demand_active=False,
|
||||
follower_active=False,
|
||||
wifi_notice=WifiNotice("AP mode", 12.0))
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [
|
||||
{"display_active": False},
|
||||
{"follower": True},
|
||||
{"on_demand": True, "override": True},
|
||||
{"on_demand": True},
|
||||
])
|
||||
def test_does_not_read_the_notice_when_it_cannot_win(self, kwargs):
|
||||
"""Reading it deletes an expired file and moves the 1 Hz throttle,
|
||||
which these passes never did before the Arbiter."""
|
||||
dc = self._controller(status={"message": "x", "expires_at": 1e12}, **kwargs)
|
||||
assert dc._arbiter_inputs().wifi_notice is None
|
||||
dc._check_wifi_status_message.assert_not_called()
|
||||
|
||||
def test_an_on_demand_override_is_not_the_schedule(self):
|
||||
dc = self._controller(display_active=True, override=True, on_demand=True)
|
||||
assert dc._arbiter_inputs().schedule_on is False
|
||||
|
||||
def test_the_gate_matches_is_display_active_through_an_on_demand_session(self):
|
||||
"""Through _evaluate_schedule, as run() calls it: the Arbiter blanks
|
||||
exactly when is_display_active is False, which is what run() tested
|
||||
before. The schedule is 07:00-23:00."""
|
||||
from test.test_display_controller_schedule import at, make_controller
|
||||
dc = make_controller({"schedule": {"enabled": True, "start_time": "07:00",
|
||||
"end_time": "23:00"},
|
||||
"timezone": "UTC"})
|
||||
dc.on_demand_active = False
|
||||
dc.on_demand_schedule_override = False
|
||||
dc.sync_manager = MagicMock()
|
||||
dc.sync_manager.is_follower_active.return_value = False
|
||||
dc._check_wifi_status_message = MagicMock(return_value=None)
|
||||
|
||||
def step(time_str):
|
||||
p = at(time_str)
|
||||
try:
|
||||
dc._evaluate_schedule()
|
||||
finally:
|
||||
p.stop()
|
||||
plan = Arbiter.decide(ArbiterState(), dc._arbiter_inputs(), 0.0)
|
||||
assert (plan.source is OFF) is (not dc.is_display_active), time_str
|
||||
return plan.source
|
||||
|
||||
assert step("22:59:30") is LEGACY
|
||||
assert step("23:00:00") is OFF # window ends
|
||||
dc.on_demand_active = True
|
||||
assert step("23:00:10") is LEGACY # on-demand overrides
|
||||
assert dc.on_demand_schedule_override is True
|
||||
assert step("23:01:00") is LEGACY # next minute, still on
|
||||
dc._reset_on_demand_fields() # session ends
|
||||
assert step("23:01:20") is OFF # same minute: blanks
|
||||
dc.on_demand_active = True
|
||||
assert step("06:59:00") is LEGACY
|
||||
assert step("07:00:00") is LEGACY # schedule back on mid-session
|
||||
dc._reset_on_demand_fields()
|
||||
assert step("07:00:30") is LEGACY
|
||||
@@ -239,4 +239,4 @@ def test_pending_vegas_init_is_applied_by_the_helper_the_main_loop_calls():
|
||||
def test_main_loop_applies_pending_vegas_init_before_the_follower_branch():
|
||||
import inspect
|
||||
src = inspect.getsource(DisplayController.run)
|
||||
assert src.index("self._apply_pending_vegas_init()") < src.index("self.sync_manager.is_follower_active()")
|
||||
assert src.index("self._apply_pending_vegas_init()") < src.index("self._run_follower_frame()")
|
||||
|
||||
@@ -675,30 +675,40 @@ class TestTheControllersOwnScreens:
|
||||
_push(dm, (255, 0, 0)) # the ticker's last frame
|
||||
flags = _record_scrolling(dm, monkeypatch)
|
||||
c = _core_screen_controller(dm)
|
||||
DisplayController._blank_while_scheduled_off(c)
|
||||
DisplayController._blank_while_scheduled_off(c, 60.0)
|
||||
assert dm._presented[-1].getpixel((10, 30)) == (0, 0, 0)
|
||||
assert flags == [False] # a static frame, not a freeze
|
||||
assert not dm.is_currently_scrolling()
|
||||
c._sleep_with_plugin_updates.assert_called_once_with(60)
|
||||
|
||||
def test_the_wifi_notice_shows_none_of_the_ticker(self, dm, monkeypatch):
|
||||
from src.display_arbiter import WifiNotice
|
||||
from src.display_controller import DisplayController
|
||||
dm._scan_lag_bands = [(24, 48, 1)]
|
||||
dm.set_scrolling_state(True, 1)
|
||||
_push(dm, (255, 0, 0))
|
||||
flags = _record_scrolling(dm, monkeypatch)
|
||||
c = _core_screen_controller(dm)
|
||||
c._check_wifi_status_message.return_value = {"message": "x", "expires_at": 1e12}
|
||||
c._display_wifi_status_message.side_effect = lambda _s: _push(dm, (0, 0, 255)) and True
|
||||
assert DisplayController._show_wifi_notice(c) is True
|
||||
notice = WifiNotice(message="x", expires_at=1e12)
|
||||
assert DisplayController._show_wifi_notice(c, notice, 0.5) is True
|
||||
assert dm._presented[-1].getpixel((10, 30)) == (0, 0, 255)
|
||||
assert flags == [False]
|
||||
|
||||
def test_no_notice_leaves_the_scroll_alone(self, dm):
|
||||
"""With no notice the Arbiter does not pick the WiFi screen, so
|
||||
nothing ends the scroll."""
|
||||
from src.display_arbiter import Arbiter, ArbiterState, Source
|
||||
from src.display_controller import DisplayController
|
||||
dm.set_scrolling_state(True, 2)
|
||||
c = _core_screen_controller(dm)
|
||||
c.is_display_active = True
|
||||
c.on_demand_schedule_override = False
|
||||
c.sync_manager.is_follower_active.return_value = False
|
||||
c._read_wifi_notice = types.MethodType(DisplayController._read_wifi_notice, c)
|
||||
c._check_wifi_status_message.return_value = None
|
||||
assert DisplayController._show_wifi_notice(c) is False
|
||||
inputs = DisplayController._arbiter_inputs(c)
|
||||
assert inputs.wifi_notice is None
|
||||
assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.LEGACY
|
||||
assert dm.is_currently_scrolling()
|
||||
assert dm._frame_hold == 2
|
||||
|
||||
Reference in New Issue
Block a user