mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea1fd42df |
@@ -505,26 +505,6 @@ policies are unchanged.
|
||||
stored `ttl` was stretched the same way. A memory hit is now also checked against
|
||||
the record's own timestamp, and a stale one falls through to disk, which
|
||||
returns a newer write if there is one.
|
||||
- An on-demand request that names a `*_live` mode now shows that mode. On
|
||||
ledpi, `{"plugin_id": "football-scoreboard", "mode": "ncaa_fb_live"}` with
|
||||
15 college games on answered 200 and showed `nfl_recent`. The session's
|
||||
mode list kept a live mode only when the plugin's `has_live_content()`
|
||||
said so. That method answers the live-priority question, and the sports
|
||||
plugins answer it for favourite teams only. A mode the request names
|
||||
(not one resolved from a bare plugin id) now leads the session, with the
|
||||
plugin's other modes after it. If it has nothing to draw, the session
|
||||
moves on to the next of those modes, like any empty on-demand mode. The
|
||||
name is saved with the session (`named_mode` in
|
||||
`display_on_demand_config`), so a restart resumes on it.
|
||||
- A restart during an on-demand session whose plugin then fails to load no
|
||||
longer leaves a session with no modes. On ledpi, `clock-simple` failed
|
||||
config validation after a crash. The display logged `No valid display
|
||||
modes found for on-demand plugin 'clock-simple' after restoration` and
|
||||
kept reporting the session as active until its first pass ended it as
|
||||
`idle`. The cached request stayed behind for the next restart. The session
|
||||
now ends at startup with status `error` and error `restore-failed`, which
|
||||
`/display/on-demand/status` reports, and the cached request is dropped. The
|
||||
same applies when the plugin system itself fails to start.
|
||||
- The garbage-collection timer (`GcMonitor`, above) no longer prints
|
||||
`Exception ignored while calling GC callback ... 'NoneType' object has no
|
||||
attribute 'perf_counter'` when the display service or a test run exits.
|
||||
|
||||
@@ -181,14 +181,13 @@ that the harness patches in today.
|
||||
- dynamic duration (cycle complete, plugin cap, global cap)
|
||||
- live priority taking over and handing back; live round-robin
|
||||
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
|
||||
a restart, and one that cannot resume (its plugin did not load); a
|
||||
request naming a live mode the plugin's live check would drop
|
||||
a restart
|
||||
- schedule off and dim, with an on-demand override during downtime
|
||||
- WiFi notice; sync follower
|
||||
- Vegas, with and without `live_in_ticker`
|
||||
- Each trace row is `[start, mode, duration, exit_reason, frames,
|
||||
force_clear]`. The exit reason is the event that decided what came next.
|
||||
- All 18 tests run in under a second. The goldens were generated from
|
||||
- All 16 tests run in under a second. The goldens were generated from
|
||||
main's `run()` before any code moved.
|
||||
- Vegas uses `FakeVegas`, which implements only the contract the controller
|
||||
depends on: `run_iteration()` returns True after its duration and False
|
||||
|
||||
+145
-2
@@ -46,19 +46,39 @@ entry older than the reader's own ``max_age``, whoever wrote it and whatever
|
||||
ttl they stored with it. Old keys are passed as ``legacy_keys`` and read
|
||||
after the canonical one, so an upgrade does not refetch everything at once;
|
||||
they can go one release after the one that added this.
|
||||
|
||||
Chunks whose days are long over are kept in memory between fetches. The
|
||||
scoreboards re-fetch their whole Recent/Upcoming window (14 days back, 7
|
||||
ahead) every hour, and since ranges went away that is 22 day requests per
|
||||
league. Measured on hdpi on 2026-10-02 (NFL, college football, MLB, college
|
||||
baseball, NHL): the hourly window refresh was ~270 of 321 ESPN requests and
|
||||
~21 of 24.6MB in the hour, and the 12 days that ended three or more days ago
|
||||
were 68% of those bytes (6.9 of 10.2MB per copy of the five windows). A
|
||||
settled chunk is answered from memory for ``SETTLED_CHUNK_TTL_SECONDS``,
|
||||
stored as zlib-compressed JSON (~13x smaller than the body, and far smaller
|
||||
than the parsed objects), so the hourly refresh only goes to ESPN for the
|
||||
days that can still change.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError: # optional; the stdlib parser gives the same objects
|
||||
orjson = None
|
||||
|
||||
try:
|
||||
from src.common.json_body import response_json
|
||||
except ImportError:
|
||||
@@ -103,11 +123,37 @@ ESPN_CHUNK_WORKERS = 6
|
||||
_range_lock = threading.Lock()
|
||||
_ranges_rejected_until = 0.0
|
||||
|
||||
# A chunk is "settled" once its last day is this many UTC days back. ESPN
|
||||
# files games under the US Eastern date, and a late West-coast game ends after
|
||||
# midnight UTC; three days leaves a full day of margin past both, so nothing
|
||||
# still being played, finalised or rescheduled is ever served from memory.
|
||||
SETTLED_AFTER_DAYS = 3
|
||||
|
||||
# How long a settled chunk is trusted. A day's finals do not change, but a
|
||||
# rare correction (or an empty answer during an ESPN outage) should not live
|
||||
# forever: once a day is plenty, and still skips 23 of every 24 hourly asks.
|
||||
SETTLED_CHUNK_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
# Bounds on the settled-chunk memory. A settled day measured 90KB (NHL) to
|
||||
# 990KB (a college-football Saturday) of JSON and 9-74KB compressed; the five
|
||||
# windows on hdpi need 60 entries and ~0.55MB. The caps only matter for a
|
||||
# board fetching whole past seasons.
|
||||
SETTLED_CACHE_MAX_ENTRIES = 512
|
||||
SETTLED_CACHE_MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
_settled_lock = threading.Lock()
|
||||
# key -> (stored_at monotonic, compressed JSON)
|
||||
_settled_chunks: "OrderedDict[Any, Tuple[float, bytes]]" = OrderedDict()
|
||||
_settled_bytes = 0
|
||||
|
||||
__all__ = [
|
||||
"ESPN_MAX_LIMIT",
|
||||
"ESPN_CHUNK_WORKERS",
|
||||
"RANGE_RETRY_SECONDS",
|
||||
"SETTLED_AFTER_DAYS",
|
||||
"SETTLED_CHUNK_TTL_SECONDS",
|
||||
"clamp_espn_limit",
|
||||
"clear_settled_chunk_cache",
|
||||
"parse_espn_date_range",
|
||||
"espn_date_chunks",
|
||||
"merge_scoreboard_payloads",
|
||||
@@ -199,6 +245,88 @@ def _days_of_month(chunk: str) -> List[str]:
|
||||
return days
|
||||
|
||||
|
||||
def _utc_today() -> date:
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def _chunk_last_day(chunk: str) -> Optional[date]:
|
||||
try:
|
||||
if len(chunk) == 8:
|
||||
return date(int(chunk[:4]), int(chunk[4:6]), int(chunk[6:]))
|
||||
if len(chunk) == 6:
|
||||
first = date(int(chunk[:4]), int(chunk[4:6]), 1)
|
||||
return _first_of_next_month(first) - timedelta(days=1)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _settled_key(url: str, params: Dict[str, Any], chunk: str) -> Optional[Any]:
|
||||
"""Memory key for a chunk that can no longer change, else None."""
|
||||
last_day = _chunk_last_day(chunk)
|
||||
if last_day is None:
|
||||
return None
|
||||
if last_day > _utc_today() - timedelta(days=SETTLED_AFTER_DAYS):
|
||||
return None
|
||||
# dates is the chunk itself and limit is always ESPN_MAX_LIMIT here;
|
||||
# anything else (groups=80 for FBS, a team filter) changes the answer.
|
||||
rest = tuple(sorted(
|
||||
(str(k), str(v)) for k, v in params.items() if k not in ("dates", "limit")
|
||||
))
|
||||
return (url, rest, chunk)
|
||||
|
||||
|
||||
def _settled_get(key: Any) -> Optional[Dict[str, Any]]:
|
||||
with _settled_lock:
|
||||
entry = _settled_chunks.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() - entry[0] > SETTLED_CHUNK_TTL_SECONDS:
|
||||
_settled_drop(key)
|
||||
return None
|
||||
_settled_chunks.move_to_end(key)
|
||||
blob = entry[1]
|
||||
# Decompress and parse outside the lock: every hit gets its own objects,
|
||||
# so a caller mutating its payload cannot reach another caller's.
|
||||
body = zlib.decompress(blob)
|
||||
return cast(Dict[str, Any], orjson.loads(body) if orjson else json.loads(body))
|
||||
|
||||
|
||||
def _settled_drop(key: Any) -> None:
|
||||
"""Remove one entry. Caller holds _settled_lock."""
|
||||
global _settled_bytes
|
||||
entry = _settled_chunks.pop(key, None)
|
||||
if entry is not None:
|
||||
_settled_bytes -= len(entry[1])
|
||||
|
||||
|
||||
def _settled_put(key: Any, response: Any, payload: Dict[str, Any]) -> None:
|
||||
global _settled_bytes
|
||||
body = getattr(response, "content", None)
|
||||
if not isinstance(body, (bytes, bytearray)):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
blob = zlib.compress(bytes(body), 6)
|
||||
if len(blob) > SETTLED_CACHE_MAX_BYTES:
|
||||
return
|
||||
with _settled_lock:
|
||||
_settled_drop(key)
|
||||
_settled_chunks[key] = (time.monotonic(), blob)
|
||||
_settled_bytes += len(blob)
|
||||
while _settled_chunks and (
|
||||
len(_settled_chunks) > SETTLED_CACHE_MAX_ENTRIES
|
||||
or _settled_bytes > SETTLED_CACHE_MAX_BYTES
|
||||
):
|
||||
_settled_drop(next(iter(_settled_chunks)))
|
||||
|
||||
|
||||
def clear_settled_chunk_cache() -> None:
|
||||
"""Forget every remembered settled chunk (tests, or a manual refresh)."""
|
||||
global _settled_bytes
|
||||
with _settled_lock:
|
||||
_settled_chunks.clear()
|
||||
_settled_bytes = 0
|
||||
|
||||
|
||||
def espn_date_chunks(start: date, end: date) -> List[str]:
|
||||
"""Cover ``[start, end]`` inclusive with ``dates=`` values ESPN accepts.
|
||||
|
||||
@@ -255,8 +383,16 @@ def _fetch_one_chunk(
|
||||
|
||||
One bad chunk must not sink the rest of the season, so every error is
|
||||
logged and swallowed here rather than raised to the gather below.
|
||||
|
||||
A chunk whose days are settled (see ``SETTLED_AFTER_DAYS``) is answered
|
||||
from memory when it was fetched in the last day.
|
||||
"""
|
||||
try:
|
||||
settled = _settled_key(url, params, chunk)
|
||||
if settled is not None:
|
||||
cached = _settled_get(settled)
|
||||
if cached is not None:
|
||||
return cached
|
||||
response = fetch_get(
|
||||
session,
|
||||
url,
|
||||
@@ -266,7 +402,14 @@ def _fetch_one_chunk(
|
||||
**_memo_kwargs(cache_max_age),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return cast(Optional[Dict[str, Any]], response_json(response))
|
||||
payload = response_json(response)
|
||||
if settled is not None and isinstance(payload, dict):
|
||||
events = payload.get("events")
|
||||
# A capped month is truncated and gets re-asked day by day;
|
||||
# remembering it would only cost memory.
|
||||
if isinstance(events, list) and len(events) < ESPN_MAX_LIMIT:
|
||||
_settled_put(settled, response, payload)
|
||||
return cast(Optional[Dict[str, Any]], payload)
|
||||
except Exception as exc: # noqa: BLE001 - see docstring
|
||||
if logger:
|
||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||
|
||||
@@ -368,10 +368,6 @@ class DisplayController:
|
||||
self.on_demand_last_error: Optional[str] = None
|
||||
self.on_demand_last_event: Optional[str] = None
|
||||
self.on_demand_schedule_override = False
|
||||
# The mode the request named, when it named one (not a mode resolved
|
||||
# from a bare plugin id). Shown even when the plugin's live checks
|
||||
# would leave it out of the session (_on_demand_modes_for_plugin).
|
||||
self._on_demand_named_mode: Optional[str] = None
|
||||
# Plugins that are disabled in config and loaded only because an
|
||||
# on-demand request named them. The main loop unloads each one once
|
||||
# on-demand has moved off it (_release_on_demand_plugins).
|
||||
@@ -565,10 +561,6 @@ class DisplayController:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Plugin system initialization failed")
|
||||
self.plugin_manager = None
|
||||
if self.on_demand_active:
|
||||
# A restored session has no plugin to resume on.
|
||||
self.cache_manager.clear_cache('display_on_demand_config')
|
||||
self._set_on_demand_error('restore-failed')
|
||||
# Its state machine no longer describes what runs; let the last
|
||||
# snapshot go stale (readers then say unknown) rather than keep
|
||||
# refreshing it.
|
||||
@@ -1673,7 +1665,6 @@ class DisplayController:
|
||||
self.on_demand_expires_at = None
|
||||
self.on_demand_pinned = False
|
||||
self.on_demand_schedule_override = False
|
||||
self._on_demand_named_mode = None
|
||||
# While the session ran, _evaluate_schedule may have forced
|
||||
# is_display_active on over a scheduled-off answer. Drop the minute
|
||||
# gate so the next _check_schedule recomputes it; otherwise the panel
|
||||
@@ -1840,7 +1831,6 @@ class DisplayController:
|
||||
self.on_demand_pinned = on_demand_config.get('pinned', False)
|
||||
self.on_demand_requested_at = on_demand_config.get('requested_at')
|
||||
self.on_demand_expires_at = on_demand_config.get('expires_at')
|
||||
self._on_demand_named_mode = on_demand_config.get('named_mode')
|
||||
self.on_demand_status = 'active'
|
||||
self.on_demand_schedule_override = True
|
||||
logger.info("On-demand mode detected during initialization: resuming on plugin '%s'; "
|
||||
@@ -2327,25 +2317,13 @@ class DisplayController:
|
||||
return modes[0]
|
||||
return plugin_id
|
||||
|
||||
def _on_demand_modes_for_plugin(self, plugin_id: str,
|
||||
named_mode: Optional[str] = None) -> List[str]:
|
||||
def _on_demand_modes_for_plugin(self, plugin_id: str) -> List[str]:
|
||||
"""Every loaded display mode belonging to `plugin_id`, in rotation order.
|
||||
|
||||
Live modes that actually have content lead, then the rest, then live
|
||||
modes with nothing to show -- so an on-demand request for a sports
|
||||
plugin opens on a game in progress rather than an empty live screen.
|
||||
Returns an empty list when the plugin has no loaded modes.
|
||||
|
||||
`named_mode` is a mode the request asked for by name. It is always
|
||||
in the list, first when the checks below would have dropped it.
|
||||
Those checks ask has_live_content(), which is the live-priority
|
||||
question -- "should this plugin take the panel from the rotation?"
|
||||
-- and the sports plugins answer it for favourite teams only. Asking
|
||||
for ncaa_fb_live with fifteen games on and no favourite playing got
|
||||
a 200 and nfl_recent on the panel. The plugin's display() is what
|
||||
knows whether the mode has anything to draw; when it has not, the
|
||||
session moves to the plugin's next mode like any empty on-demand
|
||||
mode.
|
||||
"""
|
||||
plugin_modes = self.plugin_display_modes.get(plugin_id, [])
|
||||
if not plugin_modes:
|
||||
@@ -2386,12 +2364,6 @@ class DisplayController:
|
||||
# Only live modes available but no content - use them anyway
|
||||
ordered_modes = live_modes
|
||||
|
||||
if (named_mode and named_mode in available_plugin_modes
|
||||
and named_mode not in ordered_modes):
|
||||
logger.info("On-demand: showing %s as requested; plugin '%s' reports no "
|
||||
"live-priority content for it", named_mode, plugin_id)
|
||||
ordered_modes = [named_mode] + ordered_modes
|
||||
|
||||
return ordered_modes
|
||||
|
||||
def _apply_on_demand_pin(self, ordered_modes: List[str], resolved_mode: Optional[str],
|
||||
@@ -2420,20 +2392,10 @@ class DisplayController:
|
||||
|
||||
plugin_id = self.on_demand_plugin_id
|
||||
|
||||
ordered_modes = self._on_demand_modes_for_plugin(plugin_id, self._on_demand_named_mode)
|
||||
ordered_modes = self._on_demand_modes_for_plugin(plugin_id)
|
||||
if not ordered_modes:
|
||||
# The plugin did not load this time (seen on a rig: its config
|
||||
# failed validation after the crash that caused the restart), so
|
||||
# there is nothing to resume. Leaving the session active with no
|
||||
# modes published it as active for a plugin that was not running
|
||||
# until the first pass ended it as an ordinary 'idle', and kept
|
||||
# the cached request for the next restart to trip over. End it
|
||||
# as a failure the status endpoint reports, and drop the cache.
|
||||
logger.error("On-demand session for plugin '%s' cannot resume after the "
|
||||
"restart: the plugin has no loaded display modes (did it "
|
||||
"fail to load?); ending it", plugin_id)
|
||||
self.cache_manager.clear_cache('display_on_demand_config')
|
||||
self._set_on_demand_error('restore-failed')
|
||||
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
|
||||
self.on_demand_modes = []
|
||||
return
|
||||
|
||||
# A restart must not silently un-pin: the pin is part of the request
|
||||
@@ -2634,10 +2596,7 @@ class DisplayController:
|
||||
if resolved_mode in self.available_modes:
|
||||
self.current_mode_index = self.available_modes.index(resolved_mode)
|
||||
|
||||
# Named: the request gave this mode itself, rather than a plugin id
|
||||
# (or a mode the plugin doesn't have) that resolved to a default.
|
||||
named_mode = resolved_mode if mode == resolved_mode else None
|
||||
ordered_modes = self._on_demand_modes_for_plugin(resolved_plugin_id, named_mode)
|
||||
ordered_modes = self._on_demand_modes_for_plugin(resolved_plugin_id)
|
||||
if not ordered_modes:
|
||||
logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id)
|
||||
self._set_on_demand_error("no-modes")
|
||||
@@ -2654,7 +2613,6 @@ class DisplayController:
|
||||
self.on_demand_requested_at = now
|
||||
self.on_demand_expires_at = (now + duration) if duration else None
|
||||
self.on_demand_pinned = pinned
|
||||
self._on_demand_named_mode = named_mode
|
||||
self.on_demand_status = 'active'
|
||||
self.on_demand_last_error = None
|
||||
self.on_demand_last_event = 'started'
|
||||
@@ -2683,7 +2641,6 @@ class DisplayController:
|
||||
'mode': resolved_mode,
|
||||
'duration': duration,
|
||||
'pinned': pinned,
|
||||
'named_mode': named_mode,
|
||||
'requested_at': now,
|
||||
'expires_at': self.on_demand_expires_at
|
||||
}
|
||||
|
||||
@@ -724,14 +724,11 @@ class RunLoopHarness:
|
||||
self.clock.at(t, post)
|
||||
|
||||
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None, pinned: bool = False,
|
||||
named_mode: Optional[str] = None):
|
||||
duration: Optional[float] = None, pinned: bool = False):
|
||||
"""Start with an on-demand session resumed from the cache, as after
|
||||
a restart: the state _select_startup_plugins restores, then
|
||||
_populate_on_demand_modes_from_plugin, as __init__ calls it. A
|
||||
session that cannot resume is logged as ``on-demand-error``."""
|
||||
_populate_on_demand_modes_from_plugin, as __init__ calls it."""
|
||||
dc = self.controller
|
||||
dc._on_demand_named_mode = named_mode
|
||||
dc.on_demand_active = True
|
||||
dc.on_demand_plugin_id = plugin_id
|
||||
dc.on_demand_mode = mode
|
||||
@@ -742,8 +739,6 @@ class RunLoopHarness:
|
||||
dc.on_demand_status = 'active'
|
||||
dc.on_demand_schedule_override = True
|
||||
dc._populate_on_demand_modes_from_plugin()
|
||||
if dc.on_demand_status == 'error':
|
||||
self.log("on-demand-error", dc.on_demand_last_error)
|
||||
|
||||
def wifi_message(self, t: float, message: str, duration: float = 5):
|
||||
def write():
|
||||
|
||||
@@ -343,6 +343,24 @@ def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
|
||||
monkeypatch.setattr(unit_refresh, 'SYSTEMD_DIR', str(tmp_path_factory.getbasetemp() / 'no-systemd'))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _forget_settled_espn_chunks(monkeypatch):
|
||||
"""src.common.espn_dates remembers past days process-wide; tests fake
|
||||
different answers for the same dates, so none may inherit another's.
|
||||
|
||||
"Today" is also pinned to 2000-01-01, so no date a test uses counts as
|
||||
settled unless the test says so (by pinning _utc_today itself). Without
|
||||
that, a test asking for last month twice passes while that month is
|
||||
recent and fails once it is three days old: the second ask is answered
|
||||
from memory."""
|
||||
from datetime import date
|
||||
from src.common import espn_dates
|
||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1))
|
||||
espn_dates.clear_settled_chunk_cache()
|
||||
yield
|
||||
espn_dates.clear_settled_chunk_cache()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_logging():
|
||||
"""Reset logging configuration before each test."""
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 5.0, "on-demand-start", 6, false],
|
||||
[5.0, "sports_live", 15.0, "duration", 15, true],
|
||||
[20.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 6, true],
|
||||
[40.0, "clock", 20.0, "duration", 20, true],
|
||||
[60.0, "sports_live", 15.0, "display-false", 11, true],
|
||||
[75.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[90.0, "sports_upcoming", 10.0, "on-demand-start", 11, true],
|
||||
[100.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[100.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[115.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[130.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||
[140.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[155.0, "clock", 5.0, "horizon", 5, true]
|
||||
],
|
||||
"events": [
|
||||
[5.0, "request", "start:n1"],
|
||||
[5.0, "on-demand-start", "sports"],
|
||||
[40.0, "request", "stop:n2"],
|
||||
[40.0, "on-demand-requested-stop"],
|
||||
[100.0, "request", "start:n3"],
|
||||
[100.0, "on-demand-start", "sports"],
|
||||
[140.0, "request", "stop:n4"],
|
||||
[140.0, "on-demand-requested-stop"]
|
||||
]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 20.0, "duration", 20, true],
|
||||
[40.0, "clock", 20.0, "horizon", 20, true]
|
||||
],
|
||||
"events": [
|
||||
[0.0, "on-demand-error", "restore-failed"]
|
||||
]
|
||||
}
|
||||
@@ -39,6 +39,14 @@ def forget_rejected_ranges(monkeypatch):
|
||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def nothing_is_settled_yet(monkeypatch):
|
||||
"""Pin "today" before every date these tests use, so the settled-chunk
|
||||
memory stays out of tests that are not about it whatever the real date.
|
||||
TestSettledChunkCache moves it forward."""
|
||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1))
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None):
|
||||
self.status_code = status_code
|
||||
@@ -489,3 +497,95 @@ class TestConcurrency:
|
||||
|
||||
assert live["peak"] <= espn_dates.ESPN_CHUNK_WORKERS
|
||||
assert live["peak"] > 1, "chunks should actually overlap"
|
||||
|
||||
|
||||
class TestSettledChunkCache:
|
||||
"""Days that ended three or more days ago are fetched once a day, not hourly.
|
||||
|
||||
The scoreboards re-fetch a 22-day window every hour; on hdpi (2026-10-02)
|
||||
the 12 settled days were 68% of that window's bytes.
|
||||
"""
|
||||
|
||||
TODAY = date(2026, 10, 2)
|
||||
# The scoreboards' default window on that day: 14 back, 7 ahead.
|
||||
WINDOW = "20260918-20261009"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def frozen_today(self, monkeypatch):
|
||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: self.TODAY)
|
||||
|
||||
def _events(self):
|
||||
days = [(9, d) for d in range(18, 31)] + [(10, d) for d in range(1, 10)]
|
||||
return {"2026%02d%02d" % (m, d): [{"id": f"{m}-{d}"}] for m, d in days}
|
||||
|
||||
def test_the_second_refresh_only_asks_for_unsettled_days(self):
|
||||
session = FakeSession(self._events())
|
||||
first = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW})
|
||||
session.calls.clear()
|
||||
|
||||
second = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW})
|
||||
|
||||
asked = sorted(call["dates"] for call in session.calls)
|
||||
# Sep 29 is the last settled day (today minus three).
|
||||
assert asked == ["20260930"] + ["202610%02d" % d for d in range(1, 10)]
|
||||
assert second["events"] == first["events"] # same events, same order
|
||||
assert len(second["events"]) == 22
|
||||
|
||||
def test_a_hit_is_a_fresh_copy(self):
|
||||
session = FakeSession(self._events())
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
hit = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
hit["events"][0]["id"] = "mutated"
|
||||
again = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert again["events"][0]["id"] == "9-18"
|
||||
|
||||
def test_other_params_are_part_of_the_key(self):
|
||||
session = FakeSession(self._events())
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW, "groups": 80})
|
||||
session.calls.clear()
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert len(session.calls) == 22 # a different question, nothing reused
|
||||
|
||||
def test_entries_expire_after_a_day(self, monkeypatch):
|
||||
clock = [1000.0]
|
||||
monkeypatch.setattr(espn_dates.time, "monotonic", lambda: clock[0])
|
||||
session = FakeSession(self._events())
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
clock[0] += espn_dates.SETTLED_CHUNK_TTL_SECONDS + 1
|
||||
session.calls.clear()
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert len(session.calls) == 22
|
||||
|
||||
def test_failed_chunks_are_not_remembered(self):
|
||||
session = FakeSession(self._events(), fail_chunks={"20260920"})
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
session.fail_chunks.clear()
|
||||
session.calls.clear()
|
||||
data = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert "20260920" in [call["dates"] for call in session.calls]
|
||||
assert len(data["events"]) == 22
|
||||
|
||||
def test_a_capped_month_is_not_remembered_but_its_days_are(self):
|
||||
full = [{"id": f"x{i}"} for i in range(ESPN_MAX_LIMIT)]
|
||||
session = FakeSession({"202608": full, "20260801": [{"id": "d1"}]})
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"})
|
||||
session.calls.clear()
|
||||
data = fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"})
|
||||
assert [call["dates"] for call in session.calls] == ["202608"]
|
||||
assert [event["id"] for event in data["events"]] == ["d1"]
|
||||
|
||||
def test_memory_is_bounded(self, monkeypatch):
|
||||
monkeypatch.setattr(espn_dates, "SETTLED_CACHE_MAX_ENTRIES", 5)
|
||||
session = FakeSession(self._events())
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert len(espn_dates._settled_chunks) == 5
|
||||
assert espn_dates._settled_bytes == sum(
|
||||
len(blob) for _, blob in espn_dates._settled_chunks.values())
|
||||
|
||||
def test_single_day_requests_are_untouched(self):
|
||||
# The live path asks for today (or one day) as a plain request; that
|
||||
# never goes through chunks or the memory.
|
||||
session = FakeSession(self._events())
|
||||
for _ in range(2):
|
||||
fetch_espn_scoreboard(session, URL, params={"dates": "20260918"})
|
||||
assert len(session.calls) == 2
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"""Two on-demand edges seen on a rig.
|
||||
|
||||
* A request naming a ``*_live`` mode got HTTP 200 and a different mode on
|
||||
the panel. The session's mode list kept live modes only when the plugin's
|
||||
has_live_content() said so, and that is the live-priority question,
|
||||
which the sports plugins answer for favourite teams only: fifteen college
|
||||
games on, no favourite playing, and ``ncaa_fb_live`` became
|
||||
``nfl_recent``.
|
||||
* A restart during a session whose plugin then failed to load (its config
|
||||
no longer validated) logged "No valid display modes found ... after
|
||||
restoration" and left the session active with no modes: published as
|
||||
active for a plugin that was not running, with its cached request kept
|
||||
for the next restart.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
SPORTS_MODES = ['nfl_live', 'nfl_recent', 'nfl_upcoming',
|
||||
'ncaa_fb_live', 'ncaa_fb_recent', 'ncaa_fb_upcoming']
|
||||
|
||||
|
||||
def _sports_plugin(has_live_content=False):
|
||||
plugin = MagicMock(spec=['display', 'has_live_content', 'has_live_priority',
|
||||
'get_live_modes'])
|
||||
plugin.has_live_content.return_value = has_live_content
|
||||
plugin.has_live_priority.return_value = True
|
||||
plugin.get_live_modes.return_value = []
|
||||
return plugin
|
||||
|
||||
|
||||
def _register(controller, plugin_id, modes, plugin):
|
||||
controller.plugin_display_modes[plugin_id] = list(modes)
|
||||
for mode in modes:
|
||||
controller.plugin_modes[mode] = plugin
|
||||
controller.mode_to_plugin_id[mode] = plugin_id
|
||||
if mode not in controller.available_modes:
|
||||
controller.available_modes.append(mode)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def football(test_display_controller):
|
||||
c = test_display_controller
|
||||
_register(c, 'football-scoreboard', SPORTS_MODES, _sports_plugin())
|
||||
return c
|
||||
|
||||
|
||||
class TestANamedLiveModeIsShown:
|
||||
|
||||
def test_it_is_the_first_screen(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
assert football.on_demand_active
|
||||
assert football.current_display_mode == 'ncaa_fb_live'
|
||||
assert football.on_demand_mode == 'ncaa_fb_live'
|
||||
|
||||
def test_the_plugins_other_modes_follow_it(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
assert football.on_demand_modes[0] == 'ncaa_fb_live'
|
||||
assert set(football.on_demand_modes[1:]) == {
|
||||
'nfl_recent', 'nfl_upcoming', 'ncaa_fb_recent', 'ncaa_fb_upcoming'}
|
||||
|
||||
def test_pinned_holds_it(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live', 'pinned': True})
|
||||
assert football.on_demand_modes == ['ncaa_fb_live']
|
||||
|
||||
def test_a_bare_plugin_request_still_skips_quiet_live_modes(self, football):
|
||||
"""Only a mode asked for by name is kept: a plugin-only request
|
||||
resolves to the plugin's first mode (nfl_live), and opening on an
|
||||
empty live screen there is what the ordering exists to avoid."""
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard'})
|
||||
assert not any(m.endswith('_live') for m in football.on_demand_modes)
|
||||
|
||||
def test_the_named_mode_survives_a_restart(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
saved = football.cache_manager.set.call_args_list[-1]
|
||||
assert saved.args[0] == 'display_on_demand_config'
|
||||
config = saved.args[1]
|
||||
assert config['named_mode'] == 'ncaa_fb_live'
|
||||
|
||||
football._reset_on_demand_fields()
|
||||
football._select_startup_plugins(['football-scoreboard'], config)
|
||||
football._populate_on_demand_modes_from_plugin()
|
||||
assert football.on_demand_modes[football.on_demand_mode_index] == 'ncaa_fb_live'
|
||||
|
||||
|
||||
class TestARestoreWithNothingToResume:
|
||||
|
||||
@pytest.fixture
|
||||
def restored(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
c.config['clock-simple'] = {'enabled': True}
|
||||
c._select_startup_plugins(['clock-simple'],
|
||||
{'plugin_id': 'clock-simple', 'mode': 'clock-simple'})
|
||||
assert c.on_demand_active
|
||||
# The plugin's load then fails: nothing is registered for it.
|
||||
c.cache_manager.clear_cache.reset_mock()
|
||||
c._populate_on_demand_modes_from_plugin()
|
||||
return c
|
||||
|
||||
def test_the_session_ends(self, restored):
|
||||
assert not restored.on_demand_active
|
||||
assert restored.on_demand_plugin_id is None
|
||||
assert not restored.on_demand_schedule_override
|
||||
|
||||
def test_it_is_reported_as_an_error(self, restored):
|
||||
assert restored.on_demand_status == 'error'
|
||||
assert restored.on_demand_last_error == 'restore-failed'
|
||||
published = restored.cache_manager.set.call_args_list[-1]
|
||||
assert published.args[0] == 'display_on_demand_state'
|
||||
assert published.args[1]['status'] == 'error'
|
||||
assert published.args[1]['error'] == 'restore-failed'
|
||||
|
||||
def test_the_cached_request_is_dropped(self, restored):
|
||||
restored.cache_manager.clear_cache.assert_any_call('display_on_demand_config')
|
||||
@@ -133,32 +133,6 @@ def scenario_on_demand_restored(h: RunLoopHarness):
|
||||
h.restore_on_demand("sports", mode="sports_upcoming", duration=40)
|
||||
|
||||
|
||||
def scenario_on_demand_named_live(h: RunLoopHarness):
|
||||
# Games are on until t=70, but none involves a favourite, so
|
||||
# has_live_content() (the live-priority answer) stays False throughout.
|
||||
# A request naming sports_live still opens on it (it opened on
|
||||
# sports_recent); asked for again after the games end, it has nothing to
|
||||
# draw and the session moves on to the plugin's next mode.
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin(
|
||||
"sports", ["sports_live", "sports_recent", "sports_upcoming"], duration=15,
|
||||
live_priority=True,
|
||||
content=lambda t, mode: mode != "sports_live" or t < 70))
|
||||
h.on_demand_request(5, "n1", plugin_id="sports", mode="sports_live")
|
||||
h.on_demand_request(40, "n2", action="stop")
|
||||
h.on_demand_request(100, "n3", plugin_id="sports", mode="sports_live")
|
||||
h.on_demand_request(140, "n4", action="stop")
|
||||
|
||||
|
||||
def scenario_on_demand_restore_failed(h: RunLoopHarness):
|
||||
# A restart during a session whose plugin then fails to load: the
|
||||
# session ends as an error before the first screen, and the rotation
|
||||
# runs normally from the top.
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.restore_on_demand("gone", mode="gone", duration=40)
|
||||
|
||||
|
||||
def scenario_schedule(h: RunLoopHarness):
|
||||
# The clock starts at 22:59:30. Off from 23:01 until 23:05 (the window
|
||||
# spans midnight); dimmed from 23:00 until 23:01.
|
||||
@@ -220,8 +194,6 @@ SCENARIOS = {
|
||||
"on_demand": (scenario_on_demand, 240),
|
||||
"on_demand_pinned": (scenario_on_demand_pinned, 160),
|
||||
"on_demand_restored": (scenario_on_demand_restored, 100),
|
||||
"on_demand_named_live": (scenario_on_demand_named_live, 160),
|
||||
"on_demand_restore_failed": (scenario_on_demand_restore_failed, 60),
|
||||
"schedule": (scenario_schedule, 400),
|
||||
"wifi_notice": (scenario_wifi_notice, 150),
|
||||
"follower": (scenario_follower, 80),
|
||||
|
||||
Reference in New Issue
Block a user