mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 12:59:12 +00:00
Compare commits
3
Commits
300cdaa250
...
v3.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d637a446 | ||
|
|
bc2dbf3824 | ||
|
|
cb0545ecb3 |
@@ -17,6 +17,81 @@ release that ships it.
|
|||||||
accepts both, but the store flags the old spelling as deprecated
|
accepts both, but the store flags the old spelling as deprecated
|
||||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||||
|
|
||||||
|
## 3.3.0
|
||||||
|
|
||||||
|
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||||
|
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
|
||||||
|
enforceable; this ships the last three shared modules and completes the store
|
||||||
|
gate, so a scoreboard can now floor here and carry no fallback at all.
|
||||||
|
|
||||||
|
New modules a plugin may import via `src.*` and floor on 3.3.0 for:
|
||||||
|
|
||||||
|
- `src/common/sports_card.py` — settings, colour, font and date helpers for a
|
||||||
|
scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts`
|
||||||
|
explicitly, so nothing about the caller's class is assumed.
|
||||||
|
- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas
|
||||||
|
card geometry (centre gap, logo slot and cache key, layout offsets, the
|
||||||
|
upcoming-card date and time layout). No `__init__` and no state, so adoption
|
||||||
|
is one line on the class statement.
|
||||||
|
- `src/common/sports_shared.py` — `SportsCoreSharedMixin`,
|
||||||
|
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies
|
||||||
|
byte-identical in all eight lineage-sharing scoreboards.
|
||||||
|
|
||||||
|
All three sit under `src/common/` rather than `src/base_classes/sports/`,
|
||||||
|
deliberately: importing that package pulls `core.py` → `DisplayManager` →
|
||||||
|
`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a
|
||||||
|
hardware dependency.
|
||||||
|
|
||||||
|
Three notes for anyone adopting `sports_shared`:
|
||||||
|
|
||||||
|
- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the
|
||||||
|
mixin, so it reaches the host only when the mixin is listed **first** in the
|
||||||
|
bases. Reversing that order silently skips the host constructor.
|
||||||
|
- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`,
|
||||||
|
which walks the MRO rather than reading `__file__` — `__file__` is now
|
||||||
|
`src/common/`. It walks because `SportsCore` is an ABC: a subclass built with
|
||||||
|
`type(name, bases, ns)` reports `__module__` as `"abc"`.
|
||||||
|
- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical
|
||||||
|
across the eight but stay in the plugins. The first binds a per-plugin
|
||||||
|
timezone module whose contents differ; the other two are the abstract stubs
|
||||||
|
that define the sport.
|
||||||
|
|
||||||
|
**The store's compatibility gate is now on every registry-managed route.** 3.2.0
|
||||||
|
gated `install_plugin`. This release gates the git-pull update path and
|
||||||
|
`install_from_url`, so a plugin whose floor the core cannot meet is refused
|
||||||
|
after download with no partial directory left behind.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo
|
||||||
|
sent on 2026-08-04; every shared-data-source scoreboard returned
|
||||||
|
`403 Forbidden`. Requests now send an identifying token with a project URL —
|
||||||
|
browser-style strings and bare custom tokens are both refused.
|
||||||
|
- **Low-memory boards becoming unreachable under load** while still answering
|
||||||
|
pings and serving the web UI. Fetched payloads are released after delivery
|
||||||
|
rather than pinned on the completed request for up to an hour, malloc arenas
|
||||||
|
are capped, and log volume and SD writes are reduced. Available memory is now
|
||||||
|
reported in Tools diagnostics.
|
||||||
|
- **A failed logo download pinning a team to a grey box**: the placeholder was
|
||||||
|
written under the real logo's filename, so later attempts found a file and
|
||||||
|
reported success without retrying.
|
||||||
|
- **A plugin enabled but never loaded is retried** rather than staying absent
|
||||||
|
with `error = null`.
|
||||||
|
- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp
|
||||||
|
files; one cache-cleanup thread per directory rather than per manager.
|
||||||
|
- Odds are fetched for the games displayed, not the whole schedule window, and a
|
||||||
|
stalled ESPN no longer stalls the whole plugin update.
|
||||||
|
- Array-item secrets are no longer wiped or logged.
|
||||||
|
- A restored backup matches the device it was taken from.
|
||||||
|
- The web UI reports the real error instead of "unknown", rejects non-finite
|
||||||
|
JSON numbers, and stops checkbox groups posting back hidden options.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `display.hardware.orientation` for panels mounted upside down.
|
||||||
|
- Vegas keeps live content in the ticker rather than being preempted by it.
|
||||||
|
- Schemas can label enum dropdown options.
|
||||||
|
|
||||||
## 3.2.0
|
## 3.2.0
|
||||||
|
|
||||||
**The first release shipping the unified sports library.** This is the version
|
**The first release shipping the unified sports library.** This is the version
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.2.0"
|
__version__ = "3.3.0"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
# Below this, the core's self-reported version is not evidence of anything.
|
# Below this, the core's self-reported version is not evidence of anything.
|
||||||
@@ -35,6 +36,45 @@ from typing import Any, Dict, Optional, Tuple
|
|||||||
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
|
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/
|
||||||
|
_VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py"
|
||||||
|
_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def current_core_version() -> str:
|
||||||
|
"""The core version as it is on disk right now, not as it was at import.
|
||||||
|
|
||||||
|
``from src import __version__`` binds whatever the process loaded at start.
|
||||||
|
The web UI runs as its own long-lived service (``ledmatrix-web.service``),
|
||||||
|
and updating the core replaces files on disk without restarting it -- the
|
||||||
|
update route says so explicitly and asks the user to restart. Its prompt
|
||||||
|
names the *display* service, so a user who follows it leaves the web
|
||||||
|
process holding the old number.
|
||||||
|
|
||||||
|
The plugin store's gate lives in that web process. Stale by one release is
|
||||||
|
exactly the case that matters: every plugin flooring on the release you
|
||||||
|
just installed gets refused, with a message blaming a core version that is
|
||||||
|
already correct on disk. 3.3.0 is the first release where that hits a whole
|
||||||
|
plugin family at once -- all eight sports scoreboards floor there.
|
||||||
|
|
||||||
|
Reading the file costs one stat and a small read per call, and only on the
|
||||||
|
install/update path. Any failure falls back to the imported value, so this
|
||||||
|
can only ever be as wrong as before, never worse.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = _VERSION_FILE.read_text(encoding="utf-8")
|
||||||
|
match = _VERSION_RE.search(text)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from src import __version__ as imported
|
||||||
|
return imported
|
||||||
|
except Exception: # noqa: BLE001 - never let this raise
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||||
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
||||||
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
|
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
|
||||||
|
|||||||
@@ -772,8 +772,8 @@ class PluginLoader:
|
|||||||
newer than the running core. Advisory only — never raises — so a
|
newer than the running core. Advisory only — never raises — so a
|
||||||
plugin that guards optional features with try/except keeps working.
|
plugin that guards optional features with try/except keeps working.
|
||||||
"""
|
"""
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, _reason = compatibility.check(manifest, core_version)
|
compatible, _reason = compatibility.check(manifest, core_version)
|
||||||
if compatible:
|
if compatible:
|
||||||
|
|||||||
@@ -1467,8 +1467,11 @@ class PluginStoreManager:
|
|||||||
# already had. Allowing it costs them a plugin that raises
|
# already had. Allowing it costs them a plugin that raises
|
||||||
# ModuleNotFoundError at load and is reported only as one line
|
# ModuleNotFoundError at load and is reported only as one line
|
||||||
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
# On disk, not as imported: this process may predate the core
|
||||||
|
# update that made the plugin compatible. See
|
||||||
|
# compatibility.current_core_version.
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, reason = compatibility.check(manifest, core_version)
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
if not compatible:
|
if not compatible:
|
||||||
@@ -1612,8 +1615,8 @@ class PluginStoreManager:
|
|||||||
#
|
#
|
||||||
# Before the move, so the `finally` below removes the temp tree and
|
# Before the move, so the `finally` below removes the temp tree and
|
||||||
# nothing half-installed is left behind.
|
# nothing half-installed is left behind.
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, reason = compatibility.check(manifest, core_version)
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
if not compatible:
|
if not compatible:
|
||||||
@@ -2644,8 +2647,8 @@ class PluginStoreManager:
|
|||||||
manifest_path, plugin_id, e)
|
manifest_path, plugin_id, e)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
from src import __version__ as core_version
|
|
||||||
from src.plugin_system import compatibility
|
from src.plugin_system import compatibility
|
||||||
|
core_version = compatibility.current_core_version()
|
||||||
|
|
||||||
compatible, reason = compatibility.check(manifest, core_version)
|
compatible, reason = compatibility.check(manifest, core_version)
|
||||||
if compatible:
|
if compatible:
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""The gate must read the core version from disk, not from its own import.
|
||||||
|
|
||||||
|
`from src import __version__` binds whatever the process loaded at start. The
|
||||||
|
web UI is a long-lived service of its own (ledmatrix-web.service), and updating
|
||||||
|
the core replaces files on disk without restarting it -- the update route says
|
||||||
|
so and asks the user to restart, but its prompt named only the *display*
|
||||||
|
service, so a user who followed it left the web process holding the old number.
|
||||||
|
|
||||||
|
The plugin store's gate lives in that web process, so being stale by exactly
|
||||||
|
one release is the case that bites: every plugin flooring on the release you
|
||||||
|
just installed is refused, with a message blaming a core version that is
|
||||||
|
already correct on disk.
|
||||||
|
|
||||||
|
Observed on hardware: after updating a rig to 3.3.0 and restarting only the
|
||||||
|
display service, all eight sports scoreboards were refused with
|
||||||
|
"supports LEDMatrix >=3.3.0, but this system is running 3.2.0" while
|
||||||
|
src/__init__.py on that machine read 3.3.0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system import compatibility
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrentCoreVersion:
|
||||||
|
def test_it_reads_the_file_rather_than_the_imported_value(self, monkeypatch, tmp_path):
|
||||||
|
# Simulate a process whose import predates the update: the module
|
||||||
|
# object says 3.2.0 while the file on disk says 3.3.0.
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", "3.2.0")
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
assert compatibility.current_core_version() == "3.3.0"
|
||||||
|
|
||||||
|
def test_it_matches_the_real_file_by_default(self):
|
||||||
|
import src
|
||||||
|
assert compatibility.current_core_version() == src.__version__
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", [
|
||||||
|
"__version__ = '3.4.1'\n",
|
||||||
|
'__version__="3.4.1"\n',
|
||||||
|
'"""doc"""\n\n__version__ = "3.4.1" # trailing comment\n',
|
||||||
|
])
|
||||||
|
def test_it_tolerates_the_ways_that_line_gets_written(self, monkeypatch, tmp_path, body):
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text(body, encoding="utf-8")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
assert compatibility.current_core_version() == "3.4.1"
|
||||||
|
|
||||||
|
def test_a_missing_file_falls_back_to_the_import(self, monkeypatch, tmp_path):
|
||||||
|
# Never worse than before: an unreadable file returns what the old
|
||||||
|
# code would have returned.
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", "3.2.0")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", tmp_path / "gone.py")
|
||||||
|
assert compatibility.current_core_version() == "3.2.0"
|
||||||
|
|
||||||
|
def test_a_file_without_the_line_falls_back(self, monkeypatch, tmp_path):
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", "3.2.0")
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text("# no version here\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
assert compatibility.current_core_version() == "3.2.0"
|
||||||
|
|
||||||
|
def test_it_never_raises(self, monkeypatch, tmp_path):
|
||||||
|
# This runs on the install path; an exception here would surface as a
|
||||||
|
# failed update rather than a version mismatch.
|
||||||
|
bad = tmp_path / "__init__.py"
|
||||||
|
bad.write_bytes(b"\xff\xfe\x00 not utf-8 \xff")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", bad)
|
||||||
|
assert isinstance(compatibility.current_core_version(), str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheBugItFixes:
|
||||||
|
def test_a_stale_import_no_longer_refuses_a_compatible_plugin(self, monkeypatch, tmp_path):
|
||||||
|
"""The exact hardware failure, as a test."""
|
||||||
|
import src
|
||||||
|
monkeypatch.setattr(src, "__version__", "3.2.0") # what the process holds
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8") # what is on disk
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
|
||||||
|
manifest = {"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.3.0"}
|
||||||
|
|
||||||
|
stale_ok, _ = compatibility.check(manifest, src.__version__)
|
||||||
|
assert stale_ok is False, "precondition: the stale value is what refused it"
|
||||||
|
|
||||||
|
fresh_ok, reason = compatibility.check(
|
||||||
|
manifest, compatibility.current_core_version())
|
||||||
|
assert fresh_ok is True, f"the disk version must allow it, got: {reason}"
|
||||||
|
|
||||||
|
def test_it_still_refuses_when_the_core_really_is_too_old(self, monkeypatch, tmp_path):
|
||||||
|
# The gate must not become permissive: a genuinely old core still says no.
|
||||||
|
fake = tmp_path / "__init__.py"
|
||||||
|
fake.write_text('__version__ = "3.2.0"\n', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||||
|
ok, reason = compatibility.check(
|
||||||
|
{"name": "Hockey", "min_ledmatrix_version": "3.3.0"},
|
||||||
|
compatibility.current_core_version())
|
||||||
|
assert ok is False
|
||||||
|
assert "3.2.0" in (reason or "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallSites:
|
||||||
|
@pytest.mark.parametrize("module", [
|
||||||
|
"src.plugin_system.store_manager",
|
||||||
|
"src.plugin_system.plugin_loader",
|
||||||
|
])
|
||||||
|
def test_no_gate_binds_the_version_at_import(self, module):
|
||||||
|
"""Catch a future call site reintroducing the stale read."""
|
||||||
|
import inspect
|
||||||
|
mod = importlib.import_module(module)
|
||||||
|
source = inspect.getsource(mod)
|
||||||
|
assert "from src import __version__ as core_version" not in source, (
|
||||||
|
f"{module} binds __version__ at import; use "
|
||||||
|
f"compatibility.current_core_version() so a long-lived process "
|
||||||
|
f"sees a core update.")
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""The shared sports.py mixins: their host contract, and _plugin_dir.
|
||||||
|
|
||||||
|
Two things are worth testing here and the rest is not. The 45 method bodies
|
||||||
|
moved verbatim from the plugins, so they are covered by the plugins' own tests
|
||||||
|
and by 176 byte-identical safety-harness renders. What is genuinely new is:
|
||||||
|
|
||||||
|
1. The contract. Every ``self.<CONSTANT>`` a mixin reads must be defined on the
|
||||||
|
mixin, or a host that does not happen to declare it raises AttributeError at
|
||||||
|
runtime. Two were missed on the first pass (_QUALITY_CHOICES and
|
||||||
|
_RANKING_COVERAGE_SECONDS); the eight plugins all declare them, so nothing
|
||||||
|
failed -- it would only have bitten a ninth. The test derives the list rather
|
||||||
|
than restating it, so the next omission fails here instead of in the field.
|
||||||
|
|
||||||
|
2. ``_plugin_dir``. This is the only line of genuinely new logic in the move. In
|
||||||
|
sports.py these methods found config_schema.json with ``__file__``; here that
|
||||||
|
is src/common/, so the plugin directory has to be recovered from the
|
||||||
|
instance -- and getting it wrong is silent, costing grid-snapped font sizes
|
||||||
|
(measured at 81% anti-aliased edges) rather than raising.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from abc import ABC
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import sports_shared
|
||||||
|
from src.common.sports_shared import (
|
||||||
|
SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
|
||||||
|
|
||||||
|
MIXINS = (SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
|
||||||
|
|
||||||
|
|
||||||
|
def _constants_read_by_mixins():
|
||||||
|
"""Every ALL-CAPS ``self.X`` the mixin bodies read, found by parsing them."""
|
||||||
|
tree = ast.parse(open(sports_shared.__file__).read())
|
||||||
|
names = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if (isinstance(node, ast.Attribute)
|
||||||
|
and isinstance(node.value, ast.Name)
|
||||||
|
and node.value.id == "self"
|
||||||
|
and node.attr.upper() == node.attr):
|
||||||
|
names.add(node.attr)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostContract:
|
||||||
|
def test_every_constant_read_is_also_defined(self):
|
||||||
|
# Otherwise a host that does not declare it raises AttributeError the
|
||||||
|
# first time the code path runs -- which for these is mid-render.
|
||||||
|
missing = sorted(
|
||||||
|
name for name in _constants_read_by_mixins()
|
||||||
|
if not any(hasattr(m, name) for m in MIXINS))
|
||||||
|
assert missing == [], (
|
||||||
|
f"read but never defined on a mixin: {missing}. Give each a default "
|
||||||
|
f"on SportsCoreSharedMixin and document it in the module docstring.")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,expected", [
|
||||||
|
("_QUALITY_CHOICES", frozenset({"any", "ranked"})),
|
||||||
|
("_RANKING_COVERAGE_SECONDS", 3600),
|
||||||
|
("_SCORE_PROBE_TEXT", "00-00"),
|
||||||
|
("_FONT_DESIGN_HEIGHT", 32),
|
||||||
|
])
|
||||||
|
def test_defaults_match_what_the_plugins_ship(self, name, expected):
|
||||||
|
# The eight plugins declare their own copies, which shadow these. The
|
||||||
|
# values must still agree, or a ninth plugin inheriting the default
|
||||||
|
# behaves differently from the eight.
|
||||||
|
assert getattr(SportsCoreSharedMixin, name) == expected
|
||||||
|
|
||||||
|
def test_only_the_recent_mixin_carries_a_constructor(self):
|
||||||
|
# SportsCore and SportsLive keep their own __init__ -- those differ per
|
||||||
|
# plugin. SportsRecent.__init__ was one of the 48 byte-identical bodies,
|
||||||
|
# so it moved with the rest; that is deliberate, not an oversight.
|
||||||
|
assert "__init__" not in SportsCoreSharedMixin.__dict__
|
||||||
|
assert "__init__" not in SportsLiveSharedMixin.__dict__
|
||||||
|
assert "__init__" in SportsRecentSharedMixin.__dict__
|
||||||
|
|
||||||
|
def test_the_recent_constructor_still_chains_to_the_host(self):
|
||||||
|
"""Its zero-arg super() binds to where it is DEFINED, not where it is used.
|
||||||
|
|
||||||
|
Moving a body containing bare ``super()`` is the one move that can
|
||||||
|
change meaning: the compiler closes over __class__ = the defining class,
|
||||||
|
so after the move that is SportsRecentSharedMixin rather than the
|
||||||
|
plugin's SportsRecent. It still works only because the mixin is listed
|
||||||
|
first, leaving the host class next in the MRO -- adopt it in the other
|
||||||
|
order and the chain silently skips the host's __init__.
|
||||||
|
"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Host:
|
||||||
|
def __init__(self, config, display_manager, cache_manager, logger, sport_key):
|
||||||
|
calls.append(sport_key)
|
||||||
|
self.mode_config = {}
|
||||||
|
|
||||||
|
class Recent(SportsRecentSharedMixin, Host):
|
||||||
|
pass
|
||||||
|
|
||||||
|
inst = Recent({}, None, None, None, "nhl")
|
||||||
|
assert calls == ["nhl"], "the host constructor must still run"
|
||||||
|
assert inst.current_game_index == 0
|
||||||
|
assert inst.update_interval == 3600
|
||||||
|
assert inst._zero_clock_timestamps == {}
|
||||||
|
|
||||||
|
def test_adopting_the_recent_mixin_second_would_skip_the_host(self):
|
||||||
|
# The failure mode the ordering above prevents, pinned so nobody
|
||||||
|
# "tidies" the base list.
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Host:
|
||||||
|
def __init__(self, *a):
|
||||||
|
calls.append(a)
|
||||||
|
self.mode_config = {}
|
||||||
|
|
||||||
|
class Wrong(Host, SportsRecentSharedMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
Wrong({}, None, None, None, "nhl")
|
||||||
|
# Host.__init__ wins and the mixin's setup never runs at all.
|
||||||
|
assert not hasattr(Wrong({}, None, None, None, "nhl"), "current_game_index")
|
||||||
|
|
||||||
|
|
||||||
|
class _Host(SportsCoreSharedMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _write_plugin(tmp_path, name="fakeplug", schema=True):
|
||||||
|
"""A throwaway package on sys.path, with or without a config_schema.json."""
|
||||||
|
d = tmp_path / name
|
||||||
|
d.mkdir()
|
||||||
|
(d / "__init__.py").write_text("")
|
||||||
|
(d / "mod.py").write_text("class Leaf:\n pass\n")
|
||||||
|
if schema:
|
||||||
|
(d / "config_schema.json").write_text(
|
||||||
|
'{"properties": {"customization": {"properties": '
|
||||||
|
'{"score": {"properties": {"font_size": {"default": 16}}}}}}}')
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginDir:
|
||||||
|
def test_it_finds_the_directory_holding_config_schema_json(self, tmp_path, monkeypatch):
|
||||||
|
d = _write_plugin(tmp_path)
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("fakeplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._plugin_dir() == str(d)
|
||||||
|
|
||||||
|
def test_a_class_built_by_type_still_resolves(self, tmp_path, monkeypatch):
|
||||||
|
# SportsCore is an ABC, so type(name, bases, ns) reports __module__ as
|
||||||
|
# "abc" rather than the plugin -- which is exactly what the plugins'
|
||||||
|
# own tests build. Walking the MRO is what steps past it.
|
||||||
|
d = _write_plugin(tmp_path, "abcplug")
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("abcplug.mod", fromlist=["Leaf"])
|
||||||
|
|
||||||
|
class Base(SportsCoreSharedMixin, mod.Leaf, ABC):
|
||||||
|
pass
|
||||||
|
|
||||||
|
synthetic = type("Probe", (Base,), {})
|
||||||
|
assert synthetic.__module__ == "abc", "precondition: the trap this guards"
|
||||||
|
assert synthetic.__new__(synthetic)._plugin_dir() == str(d)
|
||||||
|
|
||||||
|
def test_it_returns_none_when_no_schema_is_anywhere_on_the_mro(self, tmp_path, monkeypatch):
|
||||||
|
d = _write_plugin(tmp_path, "noschema", schema=False)
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("noschema.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
# None rather than a wrong guess: _schema_font_size then caches empty
|
||||||
|
# and every element keeps its own default.
|
||||||
|
assert host._plugin_dir() is None
|
||||||
|
|
||||||
|
def test_it_never_returns_the_core_module_directory(self):
|
||||||
|
# The bug this replaced: __file__ pointed at src/common/, so the schema
|
||||||
|
# was never found and font sizes silently stopped snapping to the grid.
|
||||||
|
host = _Host()
|
||||||
|
core_common = os.path.dirname(os.path.abspath(sports_shared.__file__))
|
||||||
|
assert host._plugin_dir() != core_common
|
||||||
|
|
||||||
|
def test_a_module_with_no_file_is_skipped_not_crashed_on(self, monkeypatch):
|
||||||
|
# Namespace packages and some frozen/dynamic modules have no __file__.
|
||||||
|
ghost = types.ModuleType("ghost_no_file")
|
||||||
|
if hasattr(ghost, "__file__"):
|
||||||
|
del ghost.__file__
|
||||||
|
monkeypatch.setitem(sys.modules, "ghost_no_file", ghost)
|
||||||
|
cls = type("H", (SportsCoreSharedMixin,), {"__module__": "ghost_no_file"})
|
||||||
|
assert cls.__new__(cls)._plugin_dir() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaFontSize:
|
||||||
|
def test_it_reads_the_plugin_schema_not_the_cores(self, tmp_path, monkeypatch):
|
||||||
|
d = _write_plugin(tmp_path, "sizeplug")
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("sizeplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._schema_font_size("score") == 16
|
||||||
|
|
||||||
|
def test_an_unknown_element_is_none(self, tmp_path, monkeypatch):
|
||||||
|
_write_plugin(tmp_path, "unkplug")
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("unkplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._schema_font_size("nonesuch") is None
|
||||||
|
|
||||||
|
def test_an_empty_key_is_none_without_touching_the_disk(self):
|
||||||
|
assert _Host()._schema_font_size("") is None
|
||||||
|
|
||||||
|
def test_a_missing_schema_degrades_to_none_rather_than_raising(self, tmp_path, monkeypatch):
|
||||||
|
_write_plugin(tmp_path, "bareplug", schema=False)
|
||||||
|
monkeypatch.syspath_prepend(str(tmp_path))
|
||||||
|
mod = __import__("bareplug.mod", fromlist=["Leaf"])
|
||||||
|
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||||
|
assert host._schema_font_size("score") is None
|
||||||
|
|
||||||
|
|
||||||
|
class _LiveHost(SportsLiveSharedMixin):
|
||||||
|
"""The documented contract for the live mixin, and nothing else."""
|
||||||
|
|
||||||
|
def __init__(self, no_data_interval=300, stale_game_timeout=600, over=()):
|
||||||
|
self.no_data_interval = no_data_interval
|
||||||
|
self.stale_game_timeout = stale_game_timeout
|
||||||
|
self.game_update_timestamps = {}
|
||||||
|
self._over = set(over)
|
||||||
|
|
||||||
|
class _L:
|
||||||
|
def __getattr__(self, _n):
|
||||||
|
return lambda *a, **k: None
|
||||||
|
self.logger = _L()
|
||||||
|
|
||||||
|
def _is_game_really_over(self, game):
|
||||||
|
return game.get("id") in self._over
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveMixin:
|
||||||
|
"""These three moved to the core, so they are tested here.
|
||||||
|
|
||||||
|
They were already covered by hockey's and lacrosse's own tests, but those
|
||||||
|
two plugins disable live mode in their safety-harness fixtures, so the 176
|
||||||
|
renders never exercise this path. Testing the mixin directly means the
|
||||||
|
coverage no longer depends on which plugin happens to have a unit test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_stale_game_is_dropped_and_forgotten(self):
|
||||||
|
h = _LiveHost(stale_game_timeout=600)
|
||||||
|
import time as _t
|
||||||
|
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5000}
|
||||||
|
games = [{"id": "g1", "home_abbr": "H", "away_abbr": "A"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert games == []
|
||||||
|
assert "g1" not in h.game_update_timestamps, "its timestamp must go too"
|
||||||
|
|
||||||
|
def test_a_fresh_game_survives(self):
|
||||||
|
h = _LiveHost(stale_game_timeout=600)
|
||||||
|
import time as _t
|
||||||
|
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5}
|
||||||
|
games = [{"id": "g1"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert len(games) == 1
|
||||||
|
|
||||||
|
def test_a_game_never_seen_is_not_treated_as_stale(self):
|
||||||
|
# last_seen 0 means "no reading", not "seen at the epoch".
|
||||||
|
h = _LiveHost()
|
||||||
|
games = [{"id": "g1"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert len(games) == 1
|
||||||
|
|
||||||
|
def test_a_game_with_no_id_is_left_alone(self):
|
||||||
|
h = _LiveHost()
|
||||||
|
games = [{"home_abbr": "H"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert len(games) == 1
|
||||||
|
|
||||||
|
def test_a_finished_game_is_dropped_even_when_fresh(self):
|
||||||
|
h = _LiveHost(over=("g2",))
|
||||||
|
games = [{"id": "g1"}, {"id": "g2"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert [g["id"] for g in games] == ["g1"]
|
||||||
|
|
||||||
|
def test_removing_several_does_not_skip_any(self):
|
||||||
|
# It iterates a copy for exactly this reason; mutating the live list
|
||||||
|
# while looping would step over the element after each removal.
|
||||||
|
h = _LiveHost(over=("g1", "g2", "g3"))
|
||||||
|
games = [{"id": "g1"}, {"id": "g2"}, {"id": "g3"}]
|
||||||
|
h._detect_stale_games(games)
|
||||||
|
assert games == []
|
||||||
|
|
||||||
|
def test_the_idle_interval_escalates_with_the_empty_streak(self):
|
||||||
|
h = _LiveHost(no_data_interval=60)
|
||||||
|
h.live_idle_max_interval = 100000
|
||||||
|
base = h._idle_live_interval()
|
||||||
|
h._empty_live_streak = 6
|
||||||
|
short = h._idle_live_interval()
|
||||||
|
h._empty_live_streak = 24
|
||||||
|
long = h._idle_live_interval()
|
||||||
|
assert base < short < long
|
||||||
|
|
||||||
|
def test_the_ceiling_bounds_even_the_unescalated_interval(self):
|
||||||
|
# base > ceiling is a reachable config: the two settings are
|
||||||
|
# independent integers with no cross-validation. Returning base
|
||||||
|
# unclamped made the wait SHRINK as the streak grew.
|
||||||
|
h = _LiveHost(no_data_interval=3600)
|
||||||
|
h.live_idle_max_interval = 900
|
||||||
|
h._empty_live_streak = 0
|
||||||
|
assert h._idle_live_interval() == 900
|
||||||
|
h._empty_live_streak = 24
|
||||||
|
assert h._idle_live_interval() == 900
|
||||||
|
|
||||||
|
def test_finding_a_live_game_resets_the_streak(self):
|
||||||
|
h = _LiveHost()
|
||||||
|
h._note_live_fetch(False)
|
||||||
|
h._note_live_fetch(False)
|
||||||
|
assert h._empty_live_streak == 2
|
||||||
|
h._note_live_fetch(True)
|
||||||
|
assert h._empty_live_streak == 0
|
||||||
|
|
||||||
|
def test_the_streak_starts_from_absent_state(self):
|
||||||
|
# The host is not required to pre-declare _empty_live_streak.
|
||||||
|
h = _LiveHost()
|
||||||
|
assert not hasattr(h, "_empty_live_streak")
|
||||||
|
h._note_live_fetch(False)
|
||||||
|
assert h._empty_live_streak == 1
|
||||||
@@ -1164,9 +1164,16 @@
|
|||||||
// The pull replaced files on disk; the running services still
|
// The pull replaced files on disk; the running services still
|
||||||
// hold the code they loaded at boot. Ask for the restart that
|
// hold the code they loaded at boot. Ask for the restart that
|
||||||
// makes the update actually take effect.
|
// makes the update actually take effect.
|
||||||
|
//
|
||||||
|
// Both services, not just the display. The plugin store's
|
||||||
|
// compatibility gate runs in the web process, so a web service
|
||||||
|
// still holding the previous version refuses every plugin that
|
||||||
|
// floors on the release just installed -- blaming a core
|
||||||
|
// version that is already correct on disk.
|
||||||
if (data.restart_required && typeof window.showRestartPending === 'function') {
|
if (data.restart_required && typeof window.showRestartPending === 'function') {
|
||||||
window.showRestartPending(
|
window.showRestartPending(
|
||||||
'Update installed \u2014 restart the display to run the new code');
|
'Update installed \u2014 restart the display and web ' +
|
||||||
|
'services to run the new code');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
|
|||||||
Reference in New Issue
Block a user