Compare commits

...
7 Commits
Author SHA1 Message Date
ChuckBuildsandClaude Sonnet 5 706d372dc7 test(celebrations): make the expiry tests actually test expiry
CodeRabbit (Major) on the merge re-review: celebration_duration is clamped to
a 1.0s floor, so the two expiry tests that configured 0 and expected instant
expiration never actually hit the expiry branch. They passed only because
_draw_celebration_layout raises in the harness (no real fonts) and its
exception branch clears the celebration the same way -- so they were really
re-testing the render-failure path, not expiry.

Now use a valid 1s duration, backdate started_at past the window, and mock
_draw_celebration_layout with assert_not_called() so an expired celebration
provably does NOT render. Verified discriminating: both fail if
has_active_celebration is forced to never expire.

Production code unchanged -- the expiry logic was already correct; only the
tests were mismodelling it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-08-02 12:26:27 -04:00
ChuckBuildsandClaude Sonnet 5 f60e9f0033 Merge origin/main into #426 after #424/#425 landed
#425 was squash-merged, so #426's base content now lives in main under a
different SHA. Reconciled the three resulting conflicts:

- .github/workflows/test.yml: kept #426's superset test list (its unification
  suites plus test_element_style.py); main had only element_style from #425.
- CHANGELOG.md: kept #426's version, which promotes main's "Unreleased"
  section into the "3.2.0" release and is a verified superset of main's
  content (no lines dropped).
- test_sports_base_characterization.py: kept #426's side for all three blocks.
  Two are semantic, not cosmetic: #425 pinned the pre-fix behaviour
  (hockey/baseball events dropped -> *_returns_none), while #426 carries the
  actual fixes (2486bdb2, 2eea7a7f) and updated the tests to *_still_extracts.
  Taking main's side would fail against #426's fixed code. The third is the
  get_background_service import path, which is .core in #426 after the package
  split. Also refreshed a stale comment that still named the removed test.

#424's global_config plumbing merges in cleanly; verified the interlock end to
end on the merged tree (global_config target_fps -> SportsScrollDisplay 90.0).
435 tests pass across the affected suites; the lone failure
(test_get_system_status) is one of the four pre-existing on main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-08-02 12:09:29 -04:00
ChuckandClaude Fable 5 82a65ad2a2 Sports unification phase 0: safety net, cwd-independent fonts, element_style (#425)
* fix(fonts): resolve asset paths against the install root, not the cwd

FontManager built its catalog from cwd-relative paths ('assets/fonts'),
so any process started outside the install root — the plugin safety
harness on CI being the recurring case — found no fonts and silently
degraded every plugin to PIL's default face. Several plugins grew
per-plugin workarounds for exactly this (countdown, text-display,
tide-display in the plugins monorepo).

Catalog population now falls back to the install root derived from this
module's location when the cwd-relative path is missing; behavior when
running from the install root is unchanged. Verified: resolve_font
returns the real FreeType face from a foreign cwd, and the full unit
suites (266 tests) pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* docs: seed CHANGELOG.md with the module-availability release discipline

The plugins monorepo's sunset rule ('delete a bundled fallback copy only
when the manifest floors on the first core release shipping the module')
needs core module additions recorded against version numbers. Seeds the
changelog at 3.1.0 and documents the discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* ci: enroll the core unit suites in a dedicated job

The existing workflow ran only the three plugin-harness suites; the
skin-system, font-manager, data-source, extractor, scroll-helper,
adaptive-layout, and loader-compat suites (266 tests) existed but never
ran in CI, so a refactor of src/base_classes or src/common could regress
them silently. Also enrolls the new sports characterization and
element-style suites landing in this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* feat: ship src/element_style — the per-element style resolver plugins already expect

Three plugins (of-the-day, ledmatrix-music, football-scoreboard) import
src.element_style behind guarded try/except with classic fallbacks, but
the module never existed in core, so the richer per-element styling UI
those code paths implement has been dormant. This lands it:

- ElementStyleResolver.style() resolves per-element font/size/color with
  the key semantic the consumers encode: a config value counts as
  user-forced only when it differs from the schema default (the web UI
  bakes defaults into config.json on save), and untouched configs
  resolve to exactly the caller's classic values — byte-identical
  rendering, proven by of-the-day's committed goldens passing unchanged.
- defaults_from_schema_file parses both declaration forms (the compact
  x-style-elements map and hand-written customization blocks).
- expand_style_elements() expands x-style-elements into full config
  blocks; schema_manager.load_schema() applies it (guarded, no-op for
  schemas without the declaration) so the config form and defaults
  merging see the expanded UI.
- Fonts resolve cwd-independently with (path, size) caching; .bdf loads
  via freetype like FontManager; nothing in the module raises out of
  style().

Verified: 31 new unit tests; of-the-day's previously-skipped 9-test
spec suite now runs and passes; football's resolver tests pass (27);
music's 38 plugin tests pass; schema-manager suites pass (43).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* test: characterization suite for src/base_classes/sports.py ahead of unification

Pins current behavior before the planned merge of the nine drifted
plugin copies back into this ancestor: the _extract_game_details_common
key contract per sport (reusing GUARANTEED_KEYS from the skin tests),
update() flows for upcoming/recent/live against cache-seeded fixtures
under frozen time, rendering smoke per mode class, and guard rails on
the skin-system seam.

Five surprising behaviors are pinned AS-IS and flagged in comments so
the merge changes them knowingly or not at all: is_upcoming also
matching status.type.name; hockey dropping events whose competitors
lack 'statistics'; baseball reading the event-level status for innings;
no past-date filter in upcoming; and favorites-only mode with an empty
favorites list showing nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* ci: restrict the test workflow's GITHUB_TOKEN to contents:read

CodeQL flagged the new unit-tests job for running with the default
unrestricted token; the pre-existing job had the same exposure. Both
jobs only check out the repo and run pytest, so a workflow-level
contents:read is sufficient.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* Address CodeRabbit review: font-name traversal + offline test guard

Two Minor findings from CodeRabbit's first review of this PR.

- resolve_font_path: reject relative font names carrying path components.
  font_name comes from plugin config, which the web UI writes; a value like
  "../../config/config.json" escaped assets/fonts/ after os.path.join and let
  a config probe arbitrary paths for existence (disclosure unlikely, since
  Pillow/freetype reject non-font files, but the probe is real). Relative
  names must now be bare filenames (os.path.basename(name) == name); absolute
  paths keep their existing isfile() gate. Test confirms the traversal
  resolved the real config.json before the guard.

- build_manager fixture: patch requests.Session.get BEFORE constructing the
  manager. Construction creates both SportsCore.session and the
  ESPNDataSource.session; the old code only replaced manager.session after
  the fact, leaving data_source.session real and able to reach the network on
  an accidental fetch. Patching the class makes every session built in the
  fixture offline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-02 12:01:56 -04:00
ChuckBuilds a417eca2c8 Merge branch 'claude/sports-unification-safety-net' into claude/sports-unification-phase1 2026-08-02 10:45:29 -04:00
ChuckBuildsandClaude Sonnet 5 81a8dcda54 Address CodeRabbit review: font-name traversal + offline test guard
Two Minor findings from CodeRabbit's first review of this PR.

- resolve_font_path: reject relative font names carrying path components.
  font_name comes from plugin config, which the web UI writes; a value like
  "../../config/config.json" escaped assets/fonts/ after os.path.join and let
  a config probe arbitrary paths for existence (disclosure unlikely, since
  Pillow/freetype reject non-font files, but the probe is real). Relative
  names must now be bare filenames (os.path.basename(name) == name); absolute
  paths keep their existing isfile() gate. Test confirms the traversal
  resolved the real config.json before the guard.

- build_manager fixture: patch requests.Session.get BEFORE constructing the
  manager. Construction creates both SportsCore.session and the
  ESPNDataSource.session; the old code only replaced manager.session after
  the fact, leaving data_source.session real and able to reach the network on
  an accidental fetch. Patching the class makes every session built in the
  fixture offline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-08-02 10:45:11 -04:00
ChuckandClaude Sonnet 5 83f20b64fe Give plugins access to device-wide config, and a global scroll frame rate (#424)
* Give plugins access to device-wide config, and a global scroll frame rate

The sports scoreboards read `getattr(self, 'global_config', {})` to find a
shared scroll frame rate, but nothing ever set that attribute: the loader
constructs plugins with only plugin_id/config/display_manager/cache_manager/
plugin_manager (plugin_loader.py:671), `global_config` appears nowhere in
src/, no plugin manager assigns it, and BasePlugin has no __getattr__ to
synthesize it. The lookup always returned {}, so the ten scroll_display.py
copies that thread target_fps through to ScrollHelper could never fire on any
core. There was also no global target_fps to find -- the only one in the
template is display.vegas_scroll.target_fps, which is Vegas-scoped.

Adds the missing half:

- `BasePlugin.global_config` resolves the full config via
  plugin_manager.config_manager, then cache_manager.config_manager, then {}.
  Same order the sports timezone helpers already use. Exceptions are swallowed
  to debug so an unreadable config can never stop a plugin loading, and a
  non-dict result is rejected rather than handed to callers that will .get()
  it and feed the result to numeric code.
- A top-level `target_fps` (default 100), exposed on the General tab and
  validated 30-200 on save to match ScrollHelper.set_target_fps -- which
  clamps silently, so a rejected save reports a value that would otherwise
  appear to save and then behave differently.

The property has a setter deliberately. news, stock-news, ledmatrix-stocks,
ledmatrix-elections, ledmatrix-leaderboard and nfl-draft all assign
`self.global_config = config.get('global', {})`; without a setter that raises
"property has no setter" and those six plugins stop loading. Reproduced, then
pinned with a test.

target_fps is also kept out of the `is_general_update` key list: that branch
treats a missing web_display_autostart as an unchecked box, so counting a
target_fps-only POST as a General save would silently switch autostart off.

Verified end to end: config.json -> BasePlugin.global_config ->
scroll_display's existing block -> ScrollHelper.target_fps 120 -> 100, with no
plugin-side change needed. Suite 1441 passed; the 4 failures
(test_display_dirty_tracking, test_web_api::test_get_system_status, two in
test_state_reconciliation) are pre-existing and reproduce identically on a
clean tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Address review findings on target_fps validation and config resolution

- Reject floats and bools before int() in the target_fps save path. A JSON
  body can carry them, where int(90.5) silently stored 90 and true stored 1.
  Form posts send strings, so '90.5' already failed in int().
- Assert the template's target_fps is 100, not merely an int, so the
  documented default is actually pinned.
- Empty-config precedence: keeping the `and config` check deliberately, now
  spelled out in the comment and covered by a test. Both managers default to
  the same config/config.json, so falling through cannot pick up a different
  file's settings; treating {} as an answer would instead return {} when the
  first manager simply hasn't loaded yet, silently disabling every setting
  read through the property -- the failure this property exists to fix.

Suite 1446 passed. The float-rejection test was checked to fail without the
guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 10:40:12 -04:00
ChuckBuildsandClaude Sonnet 5 53f47b1537 docs(sports): type-hint game_update_timestamps to match its sibling
Addresses the last remaining sub-point on the modes.py review thread. The
design finding itself is already handled: the base class documents that it
only reads game_update_timestamps and that a subclass's update() owns writing
"last_seen" (and afl/etc. do, so stale-game eviction works in practice). The
one concrete gap was the missing annotation -- _zero_clock_timestamps is typed
Dict[str, float] while this nested map had none. Now Dict[str, Dict[str, float]].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-08-02 10:11:50 -04:00
11 changed files with 365 additions and 8 deletions
+1
View File
@@ -88,6 +88,7 @@
} }
}, },
"timezone": "America/New_York", "timezone": "America/New_York",
"target_fps": 100,
"location": { "location": {
"city": "Tampa", "city": "Tampa",
"state": "Florida", "state": "Florida",
+1 -1
View File
@@ -847,7 +847,7 @@ class SportsLive(SportsCore):
# feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}. # feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}.
# Until a subclass writes "last_seen", the staleness branch of # Until a subclass writes "last_seen", the staleness branch of
# _detect_stale_games is inert and only the game-over check applies. # _detect_stale_games is inert and only the game-over check applies.
self.game_update_timestamps = {} self.game_update_timestamps: Dict[str, Dict[str, float]] = {}
self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default
@abstractmethod @abstractmethod
+7
View File
@@ -106,6 +106,13 @@ def resolve_font_path(font_name: str) -> Optional[str]:
return None return None
if os.path.isabs(font_name): if os.path.isabs(font_name):
return font_name if os.path.isfile(font_name) else None return font_name if os.path.isfile(font_name) else None
# A relative name must be a bare filename. font_name comes from plugin
# config, which the web UI writes; a value like "../../config/config.json"
# would otherwise escape assets/fonts/ once joined and let a config probe
# arbitrary paths for existence. os.path.basename collapses any such value
# to its last component, so a name that isn't already bare is rejected.
if os.path.basename(font_name) != font_name:
return None
candidates = ( candidates = (
os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name), os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name),
os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name), os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name),
+71
View File
@@ -145,6 +145,77 @@ class BasePlugin(ABC):
""" """
raise NotImplementedError("Plugins must implement display()") raise NotImplementedError("Plugins must implement display()")
# -------------------------------------------------------------------------
# Global (whole-device) configuration
# -------------------------------------------------------------------------
@property
def global_config(self) -> Dict[str, Any]:
"""
The full LEDMatrix configuration, for reading device-wide settings.
``self.config`` is only this plugin's own slice, so cross-cutting
settings — ``target_fps``, ``timezone``, ``location`` — were previously
unreachable from a plugin without reaching into a manager by hand.
Resolution order mirrors the timezone helpers the sports plugins
already ship: ``plugin_manager.config_manager`` first (the cores that
hang it there), then ``cache_manager.config_manager``. Returns ``{}``
when neither is available, so callers can use plain ``.get()`` without
guarding, and a plugin on a core that predates this property still
loads — ``getattr(self, 'global_config', {})`` simply yields the
default.
Treat as read-only: the returned dict is the live config the core is
using, so mutating it edits every other consumer's view and can be
persisted back to disk.
Assignment is still allowed and wins over the resolved value. Several
shipped plugins (news, stock-news, ledmatrix-stocks, ledmatrix-
elections, ledmatrix-leaderboard, nfl-draft) set
``self.global_config`` to their own ``config['global']`` sub-dict; a
property without a setter would raise AttributeError and stop those
plugins loading.
Example:
fps = self.global_config.get('target_fps')
"""
override = getattr(self, '_global_config_override', None)
if override is not None:
return override
for owner in (self.plugin_manager, self.cache_manager):
config_manager = getattr(owner, 'config_manager', None)
if config_manager is None:
continue
try:
config = config_manager.get_config()
except Exception:
# A broken or unreadable config must never stop a plugin from
# loading; fall through to the next source, then to {}.
self.logger.debug(
"Could not read global config from %s",
type(owner).__name__, exc_info=True,
)
continue
# Only a real mapping is usable: callers do .get() on this and feed
# the result to numeric code, so handing back whatever a stub or a
# half-built manager returned would fail later and further away.
#
# An empty dict is treated as "nothing here yet" rather than a
# valid answer, so resolution continues to the next source. Both
# managers default to the same config/config.json, so falling
# through cannot pick up a different file's settings -- but it does
# rescue the case where the first manager simply hasn't loaded yet,
# which would otherwise return {} and silently disable every
# setting read through this property.
if isinstance(config, dict) and config:
return config
return {}
@global_config.setter
def global_config(self, value: Dict[str, Any]) -> None:
"""Let a plugin substitute its own view (see the getter's docstring)."""
self._global_config_override = value
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Adaptive layout support (opt-in) # Adaptive layout support (opt-in)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+16
View File
@@ -377,6 +377,22 @@ class TestResolverPlumbing:
font = load_font("5x7.bdf", 7) font = load_font("5x7.bdf", 7)
assert isinstance(font, freetype.Face) assert isinstance(font, freetype.Face)
@pytest.mark.parametrize("hostile", [
"../../config/config.json",
"../secrets.txt",
"sub/dir/font.ttf",
"..",
])
def test_relative_font_name_with_path_components_is_rejected(self, hostile):
# font_name comes from plugin config (web-UI writable); a relative name
# carrying path separators would escape assets/fonts/ after os.path.join
# and let a config probe arbitrary paths. Only bare filenames resolve.
assert resolve_font_path(hostile) is None
def test_bare_filename_still_resolves(self):
# The guard must not reject legitimate bare names.
assert resolve_font_path("PressStart2P-Regular.ttf") is not None
def test_schema_manager_expands_on_load(self, tmp_path): def test_schema_manager_expands_on_load(self, tmp_path):
# The web-UI form path: SchemaManager.load_schema serves the # The web-UI form path: SchemaManager.load_schema serves the
# expanded schema so the style blocks actually appear in the UI. # expanded schema so the style blocks actually appear in the UI.
+121 -1
View File
@@ -250,5 +250,125 @@ class TestBasePlugin:
config = {"enabled": True, "live_priority": True} config = {"enabled": True, "live_priority": True}
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None) plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
assert plugin.has_live_priority() is True assert plugin.has_live_priority() is True
class TestBasePluginGlobalConfig:
"""global_config exposes device-wide settings that self.config cannot.
The sports scoreboards read `getattr(self, 'global_config', {})` to find
the shared target_fps; before this property existed nothing ever set that
attribute, so the lookup silently returned {} and the setting could never
take effect on any core.
"""
@staticmethod
def _plugin(display_manager, cache_manager, plugin_manager=None):
from src.plugin_system.base_plugin import BasePlugin
class ConcretePlugin(BasePlugin):
def update(self): pass
def display(self, force_clear=False): pass
return ConcretePlugin(
"test", {"enabled": True}, display_manager, cache_manager, plugin_manager
)
@staticmethod
def _manager_with(config):
"""A stand-in manager exposing config_manager.get_config()."""
manager = MagicMock()
manager.config_manager.get_config.return_value = config
return manager
def test_reads_config_from_plugin_manager(self, mock_display_manager, mock_cache_manager):
plugin = self._plugin(
mock_display_manager, mock_cache_manager,
self._manager_with({"target_fps": 100}),
)
assert plugin.global_config["target_fps"] == 100
def test_falls_back_to_cache_manager(self, mock_display_manager):
# The core that hangs config_manager off the cache manager instead.
cache_manager = self._manager_with({"target_fps": 75})
plugin = self._plugin(mock_display_manager, cache_manager, plugin_manager=None)
assert plugin.global_config["target_fps"] == 75
def test_plugin_manager_wins_over_cache_manager(self, mock_display_manager):
plugin = self._plugin(
mock_display_manager,
self._manager_with({"target_fps": 75}),
self._manager_with({"target_fps": 100}),
)
assert plugin.global_config["target_fps"] == 100
def test_empty_plugin_manager_config_falls_through(self, mock_display_manager):
"""An empty first source means "not loaded yet", not "the answer".
Both managers default to the same config/config.json, so falling
through cannot pick up a different file. Returning {} here instead
would silently disable every setting read through this property --
the exact failure this property exists to fix.
"""
plugin = self._plugin(
mock_display_manager,
self._manager_with({"target_fps": 100}), # cache_manager
self._manager_with({}), # plugin_manager: empty
)
assert plugin.global_config["target_fps"] == 100
def test_returns_empty_dict_when_no_config_manager(self, mock_display_manager):
# Plain objects: no config_manager attribute at all.
plugin = self._plugin(mock_display_manager, object(), object())
assert plugin.global_config == {}
def test_unreadable_config_does_not_raise(self, mock_display_manager):
# A plugin must still load when the config on disk is broken.
broken = MagicMock()
broken.config_manager.get_config.side_effect = OSError("unreadable")
plugin = self._plugin(mock_display_manager, broken, broken)
assert plugin.global_config == {}
def test_non_dict_config_is_rejected(self, mock_display_manager):
# A stub or half-built manager can return a non-mapping; handing that
# back would blow up later in numeric code, far from the cause.
plugin = self._plugin(
mock_display_manager, object(), self._manager_with("not-a-dict")
)
assert plugin.global_config == {}
def test_missing_property_degrades_to_default(self, mock_display_manager, mock_cache_manager):
# How plugins actually call it, so a plugin written against this core
# still loads on one that predates the property.
plugin = self._plugin(mock_display_manager, mock_cache_manager, object())
assert getattr(plugin, "global_config", {}).get("target_fps") is None
def test_plugin_may_still_assign_global_config(self, mock_display_manager, mock_cache_manager):
# news, stock-news, ledmatrix-stocks, ledmatrix-elections,
# ledmatrix-leaderboard and nfl-draft all do exactly this. Without a
# setter the property raises "has no setter" and those plugins stop
# loading entirely.
from src.plugin_system.base_plugin import BasePlugin
class AssigningPlugin(BasePlugin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.global_config = self.config.get("global", {})
def update(self): pass
def display(self, force_clear=False): pass
plugin = AssigningPlugin(
"news", {"enabled": True, "global": {"scroll_speed": 2}},
mock_display_manager, mock_cache_manager, self._manager_with({"target_fps": 100}),
)
# The plugin's own value wins over the resolved config.
assert plugin.global_config == {"scroll_speed": 2}
def test_template_ships_a_global_target_fps(self):
# The plumbing is useless if the setting isn't in the shipped config.
import json
with open("config/config.template.json") as fh:
template = json.load(fh)
assert template.get("target_fps") == 100
+15 -3
View File
@@ -79,9 +79,10 @@ def _competitor(abbr, team_id, score, home_away, record="30-10-5"):
"logo": None, "logo": None,
}, },
"records": [{"summary": record}], "records": [{"summary": record}],
# The hockey extractor iterates competitor["statistics"] and # The hockey extractor reads competitor["statistics"] for shot counts;
# returns None for the whole event when the key is absent (see # it now defaults to an empty list when the key is absent rather than
# test_hockey_event_without_statistics_returns_none). # dropping the whole event (see
# test_hockey_event_without_statistics_still_extracts).
"statistics": [], "statistics": [],
} }
@@ -434,6 +435,17 @@ def build_manager(monkeypatch, tmp_path):
"src.base_classes.sports.core.get_background_service", "src.base_classes.sports.core.get_background_service",
lambda *args, **kwargs: MagicMock()) lambda *args, **kwargs: MagicMock())
# Rig requests.Session BEFORE any manager is built. Construction creates
# both SportsCore.session and the ESPNDataSource.session; replacing only
# manager.session after the fact (below) leaves data_source.session real,
# so an accidental fetch during or right after construction could reach
# the network. Patching the class makes every session created here raise.
def _offline_get(*args, **kwargs):
raise requests.exceptions.ConnectionError(
"characterization tests are offline")
monkeypatch.setattr(requests.Session, "get", _offline_get)
def build(cls, schedule, **mode_cfg): def build(cls, schedule, **mode_cfg):
config = { config = {
"timezone": "UTC", "timezone": "UTC",
+18 -2
View File
@@ -16,6 +16,7 @@ See docs/SPORTS_UNIFICATION.md.
""" """
import sys import sys
import time
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
@@ -754,20 +755,35 @@ class TestDisplayTakeover:
manager._draw_celebration_layout.assert_called_once() manager._draw_celebration_layout.assert_called_once()
def test_expired_celebration_clears_and_defers(self, celebrating): def test_expired_celebration_clears_and_defers(self, celebrating):
manager = celebrating(mode_config={"celebration_duration": 0}) # A short-but-valid duration: celebration_duration is clamped to a 1.0s
# floor, so a config of 0 does NOT expire on the next frame. Backdate
# started_at past the window to exercise the real expiry branch —
# otherwise the celebration is still active and this only passes
# because _draw_celebration_layout happens to raise in the harness
# (that path is covered by test_a_render_failure_falls_through).
manager = celebrating(mode_config={"celebration_duration": 1})
manager._check_for_score(game("g1", home_score=0)) manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1)) manager._check_for_score(game("g1", home_score=1))
manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window
# Mock the layout so a render can't raise: otherwise the exception
# branch clears the celebration too, and this test would pass whether
# or not expiry actually fired. An expired celebration must NOT render.
manager._draw_celebration_layout = MagicMock()
assert manager.display() is True assert manager.display() is True
manager._draw_celebration_layout.assert_not_called()
assert manager.active_celebration is None assert manager.active_celebration is None
assert manager.display_calls == [False] assert manager.display_calls == [False]
def test_expiry_resets_the_dwell_clock(self, celebrating): def test_expiry_resets_the_dwell_clock(self, celebrating):
"""So the scorebug resumes on the scoring game for a full duration """So the scorebug resumes on the scoring game for a full duration
before rotation can move on.""" before rotation can move on."""
manager = celebrating(mode_config={"celebration_duration": 0}) manager = celebrating(mode_config={"celebration_duration": 1})
manager._check_for_score(game("g1", home_score=0)) manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1)) manager._check_for_score(game("g1", home_score=1))
manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window
manager._draw_celebration_layout = MagicMock() # expiry, not a render failure
manager.display() manager.display()
manager._draw_celebration_layout.assert_not_called()
assert manager.last_game_switch > 0 assert manager.last_game_switch > 0
def test_a_render_failure_falls_through_to_the_scorebug(self, celebrating): def test_a_render_failure_falls_through_to_the_scorebug(self, celebrating):
+72
View File
@@ -167,6 +167,78 @@ class TestConfigAPI:
'enabled': True, 'copies': 2, 'axis': 'vertical', 'enabled': True, 'copies': 2, 'axis': 'vertical',
} }
def test_save_target_fps(self, client, mock_config_manager):
"""The device-wide scroll frame rate persists as a top-level int."""
response = client.post(
'/api/v3/config/main',
data={'target_fps': '90'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
saved = mock_config_manager.save_config_atomic.call_args[0][0]
# Must be the coerced int, not the raw form string -- the generic
# remaining-keys loop would otherwise write '90' back over it.
assert saved['target_fps'] == 90
def test_save_target_fps_alone_does_not_reset_other_general_settings(
self, client, mock_config_manager):
"""A target_fps-only POST must not be treated as a full General-tab save.
The general branch reads web_display_autostart as an unchecked-checkbox
(absent means False), so counting target_fps as a general update would
silently switch autostart off for anyone setting only the frame rate.
"""
mock_config_manager.load_config.return_value['web_display_autostart'] = True
response = client.post(
'/api/v3/config/main',
data={'target_fps': '90'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
saved = mock_config_manager.save_config_atomic.call_args[0][0]
assert saved['web_display_autostart'] is True
@pytest.mark.parametrize('value', [90.5, 90.0, True])
def test_save_target_fps_rejects_non_integer_json(self, client, mock_config_manager, value):
"""int() would truncate silently: 90.5 -> 90, True -> 1.
Only JSON can carry these; a form post sends '90.5', which int()
already rejects.
"""
response = client.post(
'/api/v3/config/main',
data=json.dumps({'target_fps': value}),
content_type='application/json',
)
assert response.status_code == 400
@pytest.mark.parametrize('value', ['20', '250', 'fast'])
def test_save_target_fps_rejects_out_of_range(self, client, mock_config_manager, value):
"""Values ScrollHelper would silently clamp are reported instead."""
response = client.post(
'/api/v3/config/main',
data={'target_fps': value},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
def test_save_target_fps_accepts_bounds(self, client, mock_config_manager):
"""Both endpoints of the documented range are valid."""
for value in ('30', '200'):
response = client.post(
'/api/v3/config/main',
data={'target_fps': value},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200, f"{value} should be accepted"
saved = mock_config_manager.save_config_atomic.call_args[0][0]
assert saved['target_fps'] == int(value)
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager): def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
"""An omitted 'enabled' checkbox is saved as disabled, not left stale.""" """An omitted 'enabled' checkbox is saved as disabled, not left stale."""
response = client.post( response = client.post(
+31 -1
View File
@@ -747,6 +747,36 @@ def save_main_config():
if 'timezone' in data: if 'timezone' in data:
current_config['timezone'] = data['timezone'] current_config['timezone'] = data['timezone']
# Device-wide scroll frame rate, read by plugins via
# BasePlugin.global_config. Bounds match ScrollHelper.set_target_fps,
# which clamps silently -- rejecting here instead means a value that
# would have been quietly altered is reported rather than appearing to
# save and then behaving differently.
if 'target_fps' in data and data['target_fps'] not in ('', None):
raw_target_fps = data['target_fps']
# A JSON body can carry real floats and bools, where int() would
# silently truncate: 90.5 would save as 90, and true as 1. Reject
# them rather than storing a value the user did not ask for. Form
# posts arrive as strings, so '90.5' still fails in int() below.
if isinstance(raw_target_fps, (bool, float)):
return jsonify({
'status': 'error',
'message': "Invalid value for target_fps: must be an integer"
}), 400
try:
target_fps = int(raw_target_fps)
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': "Invalid value for target_fps: must be an integer"
}), 400
if not (30 <= target_fps <= 200):
return jsonify({
'status': 'error',
'message': "Invalid value for target_fps: must be between 30 and 200"
}), 400
current_config['target_fps'] = target_fps
# Handle location settings # Handle location settings
if 'city' in data or 'state' in data or 'country' in data: if 'city' in data or 'state' in data or 'country' in data:
if 'location' not in current_config: if 'location' not in current_config:
@@ -1282,7 +1312,7 @@ def save_main_config():
if key in ['timezone', 'city', 'state', 'country', if key in ['timezone', 'city', 'state', 'country',
'web_display_autostart', 'auto_discover', 'web_display_autostart', 'auto_discover',
'auto_load_enabled', 'development_mode', 'auto_load_enabled', 'development_mode',
'plugins_directory']: 'plugins_directory', 'target_fps']:
continue continue
# Skip fields that are already handled above in their own named sections. # Skip fields that are already handled above in their own named sections.
# Without this, every form field name lands as a top-level config key too. # Without this, every form field name lands as a top-level config key too.
@@ -49,6 +49,18 @@
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label> <label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
<div id="timezone_container" class="mt-1"></div> <div id="timezone_container" class="mt-1"></div>
</div> </div>
<!-- Scroll frame rate (device-wide) -->
<div class="form-group" id="setting-general-target-fps" data-setting-key="target_fps">
<label for="target_fps" class="block text-sm font-medium text-gray-700">Scroll Frame Rate{{ ui.help_tip('Frames per second for scrolling content, applied across plugins that scroll.\nHigher is smoother but uses more CPU; lower frees CPU but looks steppier.\nRange 30-200. Default: 100.', 'Scroll Frame Rate') }}</label>
<input type="number"
id="target_fps"
name="target_fps"
value="{{ main_config.target_fps or 100 }}"
min="30"
max="200"
class="form-control">
</div>
<script> <script>
(function() { (function() {
// Track if already initialized to prevent re-render // Track if already initialized to prevent re-render