mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00456ebe99 |
@@ -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
|
||||
|
||||
@@ -34,6 +34,7 @@ from src.common.fetch_service import (
|
||||
plugin_scope,
|
||||
share_connection_pool,
|
||||
)
|
||||
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
|
||||
from src.common.espn_dates import (
|
||||
RANGE_RETRY_SECONDS,
|
||||
_note_range_rejected,
|
||||
@@ -83,6 +84,10 @@ class FetchRequest:
|
||||
# the cache with the callbacks suppressed -- joiners waiting forever for a
|
||||
# fetch that did, in fact, succeed.
|
||||
commit_claimed: bool = False
|
||||
# Trim an ESPN scoreboard response before it is cached and delivered
|
||||
# (src/common/espn_payload.py). Set by whoever created the request; a
|
||||
# submitter that joins the fetch gets the same payload.
|
||||
slim_payload: bool = True
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
# The plugin that submitted the request, so the fetch service counts the
|
||||
@@ -249,7 +254,8 @@ class BackgroundDataService:
|
||||
timeout: Optional[int] = None,
|
||||
max_retries: int = 3,
|
||||
priority: int = 1,
|
||||
callback: Optional[Callable] = None) -> str:
|
||||
callback: Optional[Callable] = None,
|
||||
slim_payload: bool = True) -> str:
|
||||
"""
|
||||
Submit a background fetch request.
|
||||
|
||||
@@ -265,6 +271,11 @@ class BackgroundDataService:
|
||||
priority: Accepted for compatibility and ignored; requests run in
|
||||
submission order.
|
||||
callback: Optional callback function when request completes
|
||||
slim_payload: Drop the parts of an ESPN scoreboard response no
|
||||
scoreboard reads (stat leaders, athlete cards, links,
|
||||
headlines, highlights) before caching it; see
|
||||
src/common/espn_payload.py. Only ESPN /scoreboard URLs are
|
||||
touched. Pass False to cache the response whole.
|
||||
|
||||
Returns:
|
||||
Request ID for tracking the fetch operation
|
||||
@@ -336,6 +347,7 @@ class BackgroundDataService:
|
||||
priority=priority,
|
||||
callback=callback,
|
||||
owner=owner,
|
||||
slim_payload=slim_payload,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
@@ -497,6 +509,13 @@ class BackgroundDataService:
|
||||
)
|
||||
return result
|
||||
|
||||
# Most of an ESPN scoreboard response is never drawn, and the
|
||||
# cached copy stays parsed in the memory tier while it is fresh.
|
||||
# Trimmed before the write so the cache, request.result and the
|
||||
# callbacks all see the same payload. See src/common/espn_payload.py.
|
||||
if request.slim_payload and is_espn_scoreboard_url(request.url):
|
||||
slim_scoreboard_payload(data)
|
||||
|
||||
# Cache the data
|
||||
self.cache_manager.set(request.cache_key, data)
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Drop the parts of an ESPN scoreboard payload no scoreboard reads.
|
||||
|
||||
The sports scoreboards cache their Recent/Upcoming window (14 days back, 7
|
||||
ahead) as the raw ESPN response, and that record stays parsed in the memory
|
||||
cache for as long as it is fresh. Most of it is never drawn. Measured on hdpi
|
||||
(2026-10-02) the MLB window was 3.35MB of JSON and 13.5MB of Python objects,
|
||||
and the five windows together ~40MB, mostly in:
|
||||
|
||||
* ``competitors[].leaders`` / ``competitions[].leaders`` -- per-team and
|
||||
per-game stat leaders (28% of the MLB window)
|
||||
* ``competitors[].team.links`` / ``event.links`` -- web and app URLs
|
||||
* ``status.featuredAthletes`` and ``competitors[].probables`` -- athlete
|
||||
cards with headshots and season stats
|
||||
* ``competitions[].headlines`` / ``highlights`` -- article and video blurbs
|
||||
(28% of the college-football window)
|
||||
* ``competitions[].geoBroadcasts``
|
||||
|
||||
None of those keys is read by core or by any plugin in ledmatrix-plugins
|
||||
(checked 2026-10-02 across every scoreboard, the odds ticker and the
|
||||
leaderboard), while everything that is read -- odds, records, linescores,
|
||||
situation, statistics, notes, broadcasts, venue -- is kept. Dropping them
|
||||
takes the five windows from ~40MB to ~12MB of parsed objects and the files from
|
||||
10.6MB to 3.0MB, so the reads that parse an expired window on the render
|
||||
thread get 3-4x cheaper too.
|
||||
|
||||
:func:`slim_scoreboard_payload` changes the payload in place, and only ever
|
||||
removes the keys listed here: anything it does not know about is left alone.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# Per level of the payload, the keys removed. Kept deliberately explicit:
|
||||
# adding a key here means checking that nothing reads it first.
|
||||
_EVENT_DROP = ("links",)
|
||||
_COMPETITION_DROP = ("leaders", "headlines", "highlights", "geoBroadcasts")
|
||||
_STATUS_DROP = ("featuredAthletes",)
|
||||
_COMPETITOR_DROP = ("leaders", "probables")
|
||||
_TEAM_DROP = ("links",)
|
||||
|
||||
|
||||
def is_espn_scoreboard_url(url: Any) -> bool:
|
||||
"""Whether ``url`` is an ESPN site-API scoreboard endpoint."""
|
||||
if not isinstance(url, str):
|
||||
return False
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return False
|
||||
host = (parts.hostname or "").lower()
|
||||
if host != "espn.com" and not host.endswith(".espn.com"):
|
||||
return False
|
||||
return parts.path.rstrip("/").endswith("/scoreboard")
|
||||
|
||||
|
||||
def _drop(obj: Any, keys) -> None:
|
||||
if isinstance(obj, dict):
|
||||
for key in keys:
|
||||
obj.pop(key, None)
|
||||
|
||||
|
||||
def slim_scoreboard_payload(payload: Any) -> Any:
|
||||
"""Remove the unread parts of an ESPN scoreboard payload, in place.
|
||||
|
||||
Returns ``payload`` for convenience. Anything that is not shaped like a
|
||||
scoreboard (not a dict, no ``events`` list, odd entries) is passed over
|
||||
untouched rather than raising.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
events = payload.get("events")
|
||||
if not isinstance(events, list):
|
||||
return payload
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
_drop(event, _EVENT_DROP)
|
||||
competitions = event.get("competitions")
|
||||
if not isinstance(competitions, list):
|
||||
continue
|
||||
for competition in competitions:
|
||||
if not isinstance(competition, dict):
|
||||
continue
|
||||
_drop(competition, _COMPETITION_DROP)
|
||||
_drop(competition.get("status"), _STATUS_DROP)
|
||||
competitors = competition.get("competitors")
|
||||
if not isinstance(competitors, list):
|
||||
continue
|
||||
for competitor in competitors:
|
||||
if not isinstance(competitor, dict):
|
||||
continue
|
||||
_drop(competitor, _COMPETITOR_DROP)
|
||||
_drop(competitor.get("team"), _TEAM_DROP)
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["is_espn_scoreboard_url", "slim_scoreboard_payload"]
|
||||
@@ -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():
|
||||
|
||||
@@ -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"]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for src/common/espn_payload.py and its use by BackgroundDataService."""
|
||||
|
||||
import copy
|
||||
import time
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.background_data_service import BackgroundDataService, shutdown_background_service
|
||||
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
|
||||
|
||||
SCOREBOARD = "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard"
|
||||
|
||||
|
||||
def _event():
|
||||
"""One event carrying every key the slimming drops and a sample of the
|
||||
keys scoreboards read, at the depth ESPN puts them."""
|
||||
competitor = {
|
||||
"id": "10",
|
||||
"homeAway": "home",
|
||||
"score": "5",
|
||||
"team": {"abbreviation": "NYY", "logo": "https://a/l.png",
|
||||
"links": [{"href": "https://espn.com/team"}]},
|
||||
"records": [{"summary": "90-60"}],
|
||||
"linescores": [{"value": 1}],
|
||||
"statistics": [{"name": "hits", "displayValue": "9"}],
|
||||
"leaders": [{"name": "avg", "leaders": [{"athlete": {"id": "1"}}]}],
|
||||
"probables": [{"athlete": {"id": "2"}, "statistics": []}],
|
||||
}
|
||||
return {
|
||||
"id": "401",
|
||||
"date": "2026-10-01T23:05Z",
|
||||
"links": [{"href": "https://espn.com/game"}],
|
||||
"status": {"type": {"state": "post"}},
|
||||
"competitions": [{
|
||||
"status": {"type": {"state": "post", "shortDetail": "Final"},
|
||||
"featuredAthletes": [{"athlete": {"id": "3"}}]},
|
||||
"competitors": [competitor, dict(copy.deepcopy(competitor), homeAway="away")],
|
||||
"odds": [{"details": "NYY -150", "overUnder": 8.5}],
|
||||
"situation": {"outs": 2},
|
||||
"notes": [{"headline": "Game 1"}],
|
||||
"broadcasts": [{"names": ["FOX"]}],
|
||||
"venue": {"fullName": "Yankee Stadium"},
|
||||
"leaders": [{"name": "hits"}],
|
||||
"headlines": [{"description": "recap"}],
|
||||
"highlights": [{"links": {"source": {}}}],
|
||||
"geoBroadcasts": [{"media": {"shortName": "FOX"}}],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
class TestSlimScoreboardPayload:
|
||||
def test_drops_exactly_the_listed_keys(self):
|
||||
payload = {"leagues": [{"id": "10"}], "events": [_event()]}
|
||||
slim_scoreboard_payload(payload)
|
||||
event = payload["events"][0]
|
||||
competition = event["competitions"][0]
|
||||
assert "links" not in event
|
||||
for key in ("leaders", "headlines", "highlights", "geoBroadcasts"):
|
||||
assert key not in competition
|
||||
assert "featuredAthletes" not in competition["status"]
|
||||
for competitor in competition["competitors"]:
|
||||
assert "leaders" not in competitor
|
||||
assert "probables" not in competitor
|
||||
assert "links" not in competitor["team"]
|
||||
|
||||
def test_keeps_everything_else_unchanged(self):
|
||||
"""Removing the dropped keys from the original by hand gives exactly
|
||||
the slimmed payload: nothing else moved, changed or went missing."""
|
||||
original = {"leagues": [{"id": "10"}], "events": [_event(), _event()]}
|
||||
expected = copy.deepcopy(original)
|
||||
for event in expected["events"]:
|
||||
del event["links"]
|
||||
competition = event["competitions"][0]
|
||||
for key in ("leaders", "headlines", "highlights", "geoBroadcasts"):
|
||||
del competition[key]
|
||||
del competition["status"]["featuredAthletes"]
|
||||
for competitor in competition["competitors"]:
|
||||
del competitor["leaders"], competitor["probables"]
|
||||
del competitor["team"]["links"]
|
||||
assert slim_scoreboard_payload(original) == expected
|
||||
|
||||
def test_in_place_and_returns_payload(self):
|
||||
payload = {"events": [_event()]}
|
||||
assert slim_scoreboard_payload(payload) is payload
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
None, [], "x", {}, {"events": None}, {"events": "x"},
|
||||
{"events": [None, 1, "x", {"competitions": None}]},
|
||||
{"events": [{"competitions": [None, {"status": None, "competitors": None}]}]},
|
||||
{"events": [{"competitions": [{"competitors": [None, {"team": None}]}]}]},
|
||||
])
|
||||
def test_odd_shapes_pass_through(self, payload):
|
||||
before = copy.deepcopy(payload)
|
||||
assert slim_scoreboard_payload(payload) == before
|
||||
|
||||
|
||||
class TestIsEspnScoreboardUrl:
|
||||
@pytest.mark.parametrize("url", [
|
||||
SCOREBOARD,
|
||||
SCOREBOARD + "/",
|
||||
"http://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard",
|
||||
])
|
||||
def test_scoreboards(self, url):
|
||||
assert is_espn_scoreboard_url(url)
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
None, "", 12,
|
||||
"https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams",
|
||||
"https://site.api.espn.com/apis/site/v2/sports/football/nfl/summary",
|
||||
"https://example.com/scoreboard",
|
||||
"https://espn.com.evil.example/apis/x/scoreboard",
|
||||
"https://notespn.com/apis/x/scoreboard",
|
||||
])
|
||||
def test_not_scoreboards(self, url):
|
||||
assert not is_espn_scoreboard_url(url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
shutdown_background_service()
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
svc = BackgroundDataService(cache, max_workers=1, request_timeout=5)
|
||||
yield svc
|
||||
svc.shutdown(wait=False)
|
||||
shutdown_background_service()
|
||||
|
||||
|
||||
def _run(service, url, **kwargs):
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"events": [_event()]}
|
||||
response.raise_for_status.return_value = None
|
||||
delivered = []
|
||||
with patch.object(service.session, "get", return_value=response):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="mlb", year=2026, url=url, cache_key="mlb_schedule_window_14_7",
|
||||
callback=lambda result: delivered.append(result.data), **kwargs)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
cached = service.cache_manager.set.call_args[0][1]
|
||||
return cached, delivered
|
||||
|
||||
|
||||
class TestBackgroundServiceSlims:
|
||||
def test_espn_scoreboard_is_cached_and_delivered_slimmed(self, service):
|
||||
cached, delivered = _run(service, SCOREBOARD)
|
||||
competition = cached["events"][0]["competitions"][0]
|
||||
assert "leaders" not in competition
|
||||
assert "probables" not in competition["competitors"][0]
|
||||
assert competition["odds"] and competition["situation"]
|
||||
# The callback sees the very payload that was cached.
|
||||
assert delivered and delivered[0] is cached
|
||||
|
||||
def test_opt_out_caches_whole_response(self, service):
|
||||
cached, _ = _run(service, SCOREBOARD, slim_payload=False)
|
||||
assert cached == {"events": [_event()]}
|
||||
|
||||
def test_other_urls_untouched(self, service):
|
||||
cached, _ = _run(service, "https://example.com/feed")
|
||||
assert cached == {"events": [_event()]}
|
||||
@@ -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