mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 20:59:14 +00:00
Compare commits
11
Commits
v3.3.0
..
d9f6b110bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f6b110bc | ||
|
|
31bd0cb40a | ||
|
|
c07dbb0bff | ||
|
|
371bc69bd8 | ||
|
|
e50a100814 | ||
|
|
a645327c52 | ||
|
|
48a004a0ac | ||
|
|
91d15a8943 | ||
|
|
6bea1a7c21 | ||
|
|
0730d95200 | ||
|
|
32d637a446 |
@@ -681,12 +681,18 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
if app.is_enabled() and app.should_render(current_time):
|
||||
self._render_app(app, force=False)
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
"""
|
||||
Display current Starlark app.
|
||||
|
||||
This method is called during the display rotation.
|
||||
Displays frames from the currently active app.
|
||||
|
||||
Returns False when there is no app to show -- which is the state of
|
||||
every install without Pixlet, and of a fresh one before any app is
|
||||
added. The display controller only skips a mode on a boolean False
|
||||
(it checks isinstance(result, bool)), so returning None held a black
|
||||
panel for the full display_duration instead of rotating on.
|
||||
"""
|
||||
try:
|
||||
if force_clear:
|
||||
@@ -699,20 +705,22 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
if not self.current_app:
|
||||
# No apps available
|
||||
self.logger.debug("No Starlark apps to display")
|
||||
return
|
||||
return False
|
||||
|
||||
# Render app if needed
|
||||
if not self.current_app.frames:
|
||||
success = self._render_app(self.current_app, force=True)
|
||||
if not success:
|
||||
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
|
||||
return
|
||||
return False
|
||||
|
||||
# Display current frame
|
||||
self._display_frame()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying Starlark app: {e}")
|
||||
return False
|
||||
|
||||
def _select_next_app(self) -> None:
|
||||
"""Select the next enabled app for display."""
|
||||
|
||||
@@ -6,6 +6,9 @@ Discovers and runs tests for LEDMatrix plugins.
|
||||
Supports both unittest and pytest.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
@@ -68,6 +71,65 @@ def _find_tests_in_dir(directory: Path) -> list:
|
||||
return sorted(set(test_files))
|
||||
|
||||
|
||||
def _is_script_style(path) -> bool:
|
||||
"""True when a test file is a standalone script, not a pytest module.
|
||||
|
||||
Most plugin tests are written as `def main()` plus an `if __name__ ==
|
||||
"__main__"` guard and signal through an exit code. pytest collects zero
|
||||
items from those, so handing them to pytest printed "no tests ran" and this
|
||||
runner reported success over work it had not done -- 151 of 248 files on a
|
||||
fully populated rig.
|
||||
"""
|
||||
try:
|
||||
src = Path(path).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
has_pytest_items = re.search(r"^\s*(def test_|class Test|async def test_)", src, re.M)
|
||||
has_main_guard = "__main__" in src and "__name__" in src
|
||||
return bool(has_main_guard and not has_pytest_items)
|
||||
|
||||
|
||||
def run_script_tests(test_files: list, verbose: bool = False) -> int:
|
||||
"""Run standalone test scripts, honouring the 0 pass / 2 skip / 1 fail
|
||||
convention that ledmatrix-plugins' own runner established.
|
||||
|
||||
Scripts opt into skipping by printing "SKIP: <reason>" and exiting 2 --
|
||||
a script that needs a tty or an LED matrix is not a regression.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
env.setdefault("PYTHONPATH", str(PROJECT_ROOT))
|
||||
env.setdefault("LEDMATRIX_CORE", str(PROJECT_ROOT))
|
||||
|
||||
passed = skipped = failed = 0
|
||||
failures = []
|
||||
for path in test_files:
|
||||
try:
|
||||
proc = subprocess.run([sys.executable, str(path)], cwd=str(Path(path).parent),
|
||||
capture_output=True, text=True, env=env,
|
||||
stdin=subprocess.DEVNULL, timeout=300)
|
||||
rc = proc.returncode
|
||||
tail = " | ".join((proc.stdout or proc.stderr or "").strip().splitlines()[-2:])[:200]
|
||||
except subprocess.TimeoutExpired:
|
||||
rc, tail = 1, "timed out after 300s"
|
||||
if rc == 0:
|
||||
passed += 1
|
||||
label = "pass"
|
||||
elif rc == 2:
|
||||
skipped += 1
|
||||
label = "SKIP"
|
||||
else:
|
||||
failed += 1
|
||||
label = "FAIL"
|
||||
failures.append(f"{Path(path).name}: exit {rc} | {tail}")
|
||||
if verbose or rc != 0:
|
||||
print(f" [{label}] {Path(path).name}" + (f" -- {tail}" if rc != 0 else ""))
|
||||
|
||||
print(f"\n{passed} passed, {skipped} skipped, {failed} failed (scripts)")
|
||||
for f in failures:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
|
||||
"""
|
||||
Run tests using unittest.
|
||||
@@ -186,7 +248,12 @@ def main():
|
||||
print("No test files found in plugins directory")
|
||||
return 0
|
||||
|
||||
print(f"Found {len(test_files)} test file(s)")
|
||||
scripts = [f for f in test_files if _is_script_style(f)]
|
||||
modules = [f for f in test_files if f not in scripts]
|
||||
|
||||
print(f"Found {len(test_files)} test file(s)"
|
||||
+ (f" -- {len(modules)} collectable, {len(scripts)} standalone script(s)"
|
||||
if scripts else ""))
|
||||
for test_file in test_files:
|
||||
print(f" - {test_file}")
|
||||
print()
|
||||
@@ -200,11 +267,20 @@ def main():
|
||||
except ImportError:
|
||||
runner = 'unittest'
|
||||
|
||||
# Run tests
|
||||
if runner == 'pytest':
|
||||
return run_pytest_tests(test_files, args.verbose, args.coverage)
|
||||
else:
|
||||
return run_unittest_tests(test_files, args.verbose)
|
||||
# Standalone scripts cannot be collected by pytest or unittest -- run them
|
||||
# as the scripts they are. Doing this rather than silently collecting zero
|
||||
# items is the whole point: this runner used to report success having
|
||||
# executed nothing.
|
||||
rc = 0
|
||||
if scripts:
|
||||
rc |= run_script_tests(scripts, args.verbose)
|
||||
|
||||
if modules:
|
||||
if runner == 'pytest':
|
||||
rc |= run_pytest_tests(modules, args.verbose, args.coverage)
|
||||
else:
|
||||
rc |= run_unittest_tests(modules, args.verbose)
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -17,10 +17,13 @@ deliberate difference is `crisp_size`, which takes the seven-plugin guard
|
||||
the extra guard only stops a None size raising TypeError.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
||||
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
||||
@@ -395,7 +398,14 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||
if size is not None:
|
||||
cache[key] = int(size)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# See sports_shared._schema_font_size: an unreadable schema
|
||||
# silently disables the pixel-grid snap for every element.
|
||||
# Built once per schema path, so this cannot repeat per frame.
|
||||
logger.warning(
|
||||
"could not read %s (%s: %s); font sizes will skip their "
|
||||
"pixel grid snap and may render a pixel narrow",
|
||||
schema_path, type(exc).__name__, exc)
|
||||
cache = {}
|
||||
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
||||
return cache.get(element_key)
|
||||
|
||||
@@ -211,17 +211,52 @@ class SportsCoreSharedMixin:
|
||||
return desired
|
||||
return max(grid, int(round(float(desired) / grid)) * grid)
|
||||
|
||||
#: Absolute path of this plugin's directory, declared by the plugin
|
||||
#: itself. The mixin cannot work it out -- see _plugin_dir.
|
||||
_PLUGIN_DIR: ClassVar[Optional[str]] = None
|
||||
|
||||
def _plugin_dir(self) -> Optional[str]:
|
||||
"""Directory of the plugin that defines this class.
|
||||
"""Directory of the plugin that owns this instance.
|
||||
|
||||
In sports.py these methods could just use ``__file__``. Here that is
|
||||
src/common/, so the plugin's own directory has to be recovered from the
|
||||
instance. ``type(self).__module__`` alone is not enough: SportsCore is
|
||||
an ABC, so a subclass built with ``type(name, bases, ns)`` -- which the
|
||||
plugins' own tests do -- reports its module as "abc". Walking the MRO
|
||||
steps past those synthetic classes to the first one whose module sits
|
||||
next to a config_schema.json, which is the real plugin.
|
||||
src/common/, so the directory has to come from the plugin.
|
||||
|
||||
It is TOLD, not deduced. The first version walked the MRO for a class
|
||||
whose module sits beside a config_schema.json. That works when a test
|
||||
imports the plugin itself, and returns None under the real loader, for
|
||||
a specific reason worth recording:
|
||||
|
||||
PluginLoader._namespace_plugin_modules renames every bare module a
|
||||
plugin brought in (sports, game_renderer, ...) to
|
||||
"_plg_<plugin_id>_<module>" and REMOVES the bare entry, so two
|
||||
plugins owning a module of the same name cannot collide.
|
||||
|
||||
The class still reports ``__module__ == "sports"``, but
|
||||
``sys.modules["sports"]`` no longer exists, so the walk finds no
|
||||
__file__ and falls off the end. The failure was silent and expensive:
|
||||
|
||||
_plugin_dir() -> None
|
||||
_schema_font_size() -> None for every element
|
||||
-> a configured size equal to the schema default stops looking
|
||||
like a default and is treated as a deliberate user choice
|
||||
-> the snap to the font's pixel grid is skipped
|
||||
-> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs
|
||||
instead of 4px
|
||||
|
||||
On a 256x64 panel that made the odds, the team records and the date row
|
||||
hard to read. It was found by a user counting pixels on the panel. No
|
||||
gate here caught it: the tests imported plugins directly and the
|
||||
safety harness loads them its own way, so neither reproduced the
|
||||
loader's renaming.
|
||||
|
||||
The MRO walk stays as a fallback for hosts that declare no
|
||||
_PLUGIN_DIR -- the plugins' own probe harnesses build classes with
|
||||
``type()`` -- but it is no longer the primary answer.
|
||||
"""
|
||||
declared = getattr(self, "_PLUGIN_DIR", None)
|
||||
if declared and os.path.isfile(os.path.join(declared, "config_schema.json")):
|
||||
return declared
|
||||
|
||||
for cls in type(self).__mro__:
|
||||
module = sys.modules.get(getattr(cls, "__module__", ""), None)
|
||||
path = getattr(module, "__file__", None)
|
||||
@@ -253,7 +288,23 @@ class SportsCoreSharedMixin:
|
||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||
if size is not None:
|
||||
cache[key] = int(size)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# Say so. An unreadable schema is not cosmetic: every element's
|
||||
# configured size then stops matching "the schema default", is
|
||||
# treated as a deliberate user choice, and skips the snap to the
|
||||
# font's pixel grid -- which renders 4x6-font.ttf at 6 instead
|
||||
# of 7, a 3px-wide glyph instead of 4px. That shipped once,
|
||||
# silently, and was found by a user counting pixels on a photo
|
||||
# of the panel.
|
||||
#
|
||||
# Logged, not raised: a missing schema must not stop a plugin
|
||||
# rendering. The cache is built once per class, so this cannot
|
||||
# repeat per frame.
|
||||
logger.warning(
|
||||
"%s: could not read config_schema.json (%s: %s); every font "
|
||||
"size will be treated as user-chosen and will skip its pixel "
|
||||
"grid snap. Font sizes may render a pixel narrow.",
|
||||
type(self).__name__, type(exc).__name__, exc)
|
||||
cache = {}
|
||||
self.__class__._SCHEMA_FONT_SIZES = cache
|
||||
return cache.get(element_key)
|
||||
|
||||
@@ -1271,8 +1271,16 @@ class DisplayController:
|
||||
"""Poll cache for new on-demand requests from external controllers."""
|
||||
try:
|
||||
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
|
||||
# The request_id check prevents duplicate processing
|
||||
request = self.cache_manager.get('display_on_demand_request', max_age=3600)
|
||||
# The request_id check prevents duplicate processing.
|
||||
#
|
||||
# memory_ttl=0 is required, not optional: this key is a mailbox the
|
||||
# web process writes and this process reads. get() defaults the
|
||||
# in-memory TTL to max_age, so without it the first request read was
|
||||
# pinned in memory for the full hour and every later poll returned
|
||||
# that stale copy -- meaning no second on-demand request was honoured
|
||||
# for an hour, while the API still reported success.
|
||||
request = self.cache_manager.get('display_on_demand_request',
|
||||
max_age=3600, memory_ttl=0)
|
||||
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||
logger.error("Failed to read on-demand request: %s", err, exc_info=True)
|
||||
return
|
||||
@@ -1318,6 +1326,17 @@ class DisplayController:
|
||||
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
||||
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
||||
self.on_demand_request_id = request_id
|
||||
# Consume the mailbox entry. Leaving it on disk meant a restart replayed
|
||||
# the previous request: the fresh controller read it, activated it and
|
||||
# cached it, so the request the caller had just made was ignored and the
|
||||
# panel silently showed the earlier plugin. processed_id still guards
|
||||
# against double-processing if this delete fails.
|
||||
try:
|
||||
self.cache_manager.delete('display_on_demand_request')
|
||||
except (OSError, AttributeError, KeyError) as err:
|
||||
# Best-effort: processed_id still guards against reprocessing if the
|
||||
# mailbox cannot be cleared.
|
||||
logger.debug("Could not clear the on-demand request mailbox: %s", err)
|
||||
|
||||
if action == 'start':
|
||||
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
||||
|
||||
+27
-3
@@ -364,6 +364,7 @@ class DisplayManager:
|
||||
# Create image with the (logical) display dimensions
|
||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||
|
||||
# Initialize font with Press Start 2P
|
||||
@@ -403,6 +404,7 @@ class DisplayManager:
|
||||
|
||||
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
# Simple fallback visualization so web UI shows a realistic canvas
|
||||
try:
|
||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
||||
@@ -705,6 +707,7 @@ class DisplayManager:
|
||||
# self.image, so swapping the buffer below is enough on its own.
|
||||
self.image = Image.new('RGB', (target_w, target_h))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
yield
|
||||
finally:
|
||||
self.matrix = real_matrix
|
||||
@@ -814,6 +817,7 @@ class DisplayManager:
|
||||
|
||||
self.image = Image.new('RGB', (width, height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
logger.debug("Cleared display in fallback mode")
|
||||
return
|
||||
|
||||
@@ -825,6 +829,7 @@ class DisplayManager:
|
||||
# Create a new black image
|
||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
|
||||
if not self._capture_mode_active:
|
||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||
@@ -1258,6 +1263,7 @@ class DisplayManager:
|
||||
try:
|
||||
self.image = Image.new('RGB', (self.width, self.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||
# Reset the singleton state when cleaning up
|
||||
@@ -1447,12 +1453,30 @@ class DisplayManager:
|
||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||
self._snapshot_dir_prepared = True
|
||||
# Write atomically: temp then replace
|
||||
tmp_path = f"{self._snapshot_path}.tmp"
|
||||
self.image.save(tmp_path, format='PNG')
|
||||
# Write atomically: temp then replace. The temp name must be
|
||||
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
|
||||
# and this file is written by whichever user the display service
|
||||
# runs as while tests and tooling run as someone else. A leftover
|
||||
# fixed-name temp owned by another user is then unopenable even by
|
||||
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
|
||||
# sticky dir), which froze the preview and the health check's
|
||||
# liveness proxy until somebody deleted it by hand. Same pattern as
|
||||
# the hardware-status write above.
|
||||
_fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(snapshot_path_obj.parent),
|
||||
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(_fd, "wb") as _f:
|
||||
self.image.save(_f, format='PNG')
|
||||
os.chmod(tmp_path, 0o644)
|
||||
os.replace(tmp_path, self._snapshot_path)
|
||||
except Exception:
|
||||
# Never leave the temp behind -- that is what made the failure
|
||||
# permanent rather than transient.
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback to direct save if replace not supported
|
||||
self.image.save(self._snapshot_path, format='PNG')
|
||||
# Set proper file permissions after saving
|
||||
|
||||
+2
-1
@@ -96,7 +96,8 @@ class FontManager:
|
||||
self.common_fonts = {
|
||||
"press_start": "assets/fonts/PressStart2P-Regular.ttf",
|
||||
"four_by_six": "assets/fonts/4x6-font.ttf",
|
||||
"five_by_seven": "assets/fonts/5x7.bdf"
|
||||
"five_by_seven": "assets/fonts/5x7.bdf",
|
||||
"tom_thumb": "assets/fonts/tom-thumb.bdf"
|
||||
# Note: cozette_bdf removed - font file not available
|
||||
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
|
||||
# and add: "cozette_bdf": "assets/fonts/cozette.bdf"
|
||||
|
||||
@@ -28,6 +28,7 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# ``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]]:
|
||||
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
||||
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
|
||||
plugin that guards optional features with try/except keeps working.
|
||||
"""
|
||||
from src import __version__ as core_version
|
||||
from src.plugin_system import compatibility
|
||||
core_version = compatibility.current_core_version()
|
||||
|
||||
compatible, _reason = compatibility.check(manifest, core_version)
|
||||
if compatible:
|
||||
|
||||
@@ -1467,8 +1467,11 @@ class PluginStoreManager:
|
||||
# already had. Allowing it costs them a plugin that raises
|
||||
# ModuleNotFoundError at load and is reported only as one line
|
||||
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
||||
from src import __version__ as core_version
|
||||
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)
|
||||
if not compatible:
|
||||
@@ -1612,8 +1615,8 @@ class PluginStoreManager:
|
||||
#
|
||||
# Before the move, so the `finally` below removes the temp tree and
|
||||
# nothing half-installed is left behind.
|
||||
from src import __version__ as core_version
|
||||
from src.plugin_system import compatibility
|
||||
core_version = compatibility.current_core_version()
|
||||
|
||||
compatible, reason = compatibility.check(manifest, core_version)
|
||||
if not compatible:
|
||||
@@ -2644,8 +2647,8 @@ class PluginStoreManager:
|
||||
manifest_path, plugin_id, e)
|
||||
return True
|
||||
|
||||
from src import __version__ as core_version
|
||||
from src.plugin_system import compatibility
|
||||
core_version = compatibility.current_core_version()
|
||||
|
||||
compatible, reason = compatibility.check(manifest, core_version)
|
||||
if compatible:
|
||||
|
||||
@@ -14,6 +14,8 @@ via BoundsCheckingDisplayManager, and golden-image comparison.
|
||||
import contextlib
|
||||
import http.client
|
||||
import inspect
|
||||
import time
|
||||
from datetime import timedelta
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.error
|
||||
@@ -25,7 +27,7 @@ from PIL import Image, ImageChops
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||
from .loading import load_config_defaults, load_manifest
|
||||
from .loading import load_config_defaults, load_manifest, merge_config
|
||||
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
||||
|
||||
logger = get_logger("[Plugin Harness]")
|
||||
@@ -116,14 +118,23 @@ def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -
|
||||
|
||||
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||
config: Dict[str, Any], mock_data: Dict[str, Any],
|
||||
display_manager: Any) -> Any:
|
||||
"""Load and construct a plugin instance with mocked managers."""
|
||||
display_manager: Any, cache_manager: Any = None) -> Any:
|
||||
"""Load and construct a plugin instance with mocked managers.
|
||||
|
||||
Pass ``cache_manager`` to share one cache across the renders of a plugin.
|
||||
Building a fresh one per (size, mode) made every render a cold start, so a
|
||||
plugin that fetches per game or per player re-fetched everything N times --
|
||||
baseball-scoreboard took 840s for nine renders where ~72s was the arithmetic
|
||||
-- and the cache-hit path, which is what a running rig executes almost
|
||||
always, was never exercised.
|
||||
"""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
||||
|
||||
cache_manager = MockCacheManager()
|
||||
for key, value in (mock_data or {}).items():
|
||||
cache_manager.set(key, value)
|
||||
if cache_manager is None:
|
||||
cache_manager = MockCacheManager()
|
||||
for key, value in (mock_data or {}).items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
loader = PluginLoader()
|
||||
plugin_instance, _module = loader.load_plugin(
|
||||
@@ -160,6 +171,104 @@ def _render_mode(plugin_instance: Any, mode: str) -> Any:
|
||||
return plugin_instance.display(force_clear=False)
|
||||
|
||||
|
||||
# How many extra frames to drive before believing a mode really draws nothing.
|
||||
# A scroll starts with its content off-panel, so frame 1 is legitimately blank;
|
||||
# measured across the fleet, content appears by frame 2-4 (f1-scoreboard),
|
||||
# frame 4 (ledmatrix-elections) and frame 38 at 64px (ledmatrix-leaderboard).
|
||||
EMPTY_RECHECK_FRAMES = 48
|
||||
# Seconds to advance the clock between those frames. Scroll position is usually
|
||||
# driven by elapsed time, which a frozen clock never provides.
|
||||
EMPTY_RECHECK_STEP = 0.05
|
||||
|
||||
|
||||
def _has_content(image) -> bool:
|
||||
"""True when any pixel is lit above the threshold."""
|
||||
if image is None:
|
||||
return False
|
||||
return image.convert("L").point(
|
||||
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None
|
||||
|
||||
|
||||
def _render_mode_again(plugin_instance: Any, mode: str) -> Any:
|
||||
"""Draw one more frame WITHOUT force_clear.
|
||||
|
||||
_render_mode passes force_clear=True, which for a scrolling plugin means
|
||||
"reset the scroll to the start" -- so repeating it would redraw frame 1 for
|
||||
ever. The re-check needs the plugin to advance.
|
||||
"""
|
||||
sig = inspect.signature(plugin_instance.display)
|
||||
if "display_mode" in sig.parameters:
|
||||
return plugin_instance.display(force_clear=False, display_mode=mode)
|
||||
return plugin_instance.display(force_clear=False)
|
||||
|
||||
|
||||
def _settle_empty_frame(inst, mode, dm, result, freezer) -> None:
|
||||
"""Give an apparently-empty mode a few frames to draw before believing it.
|
||||
|
||||
One frame is not evidence: a scroll's first frame is its blank scroll-in
|
||||
buffer. Without this, every scrolling plugin was warned about -- 60 of 76
|
||||
warnings on a 44-plugin rig were false, which is the rate at which people
|
||||
stop reading a warning.
|
||||
"""
|
||||
if result.error is not None or result.display_returned is False:
|
||||
return
|
||||
if _has_content(result.image):
|
||||
return
|
||||
# The frozen clock is shared by every render in the matrix, so any time this
|
||||
# probe borrows has to be given back -- otherwise a mode that scrolls in
|
||||
# leaves the clock advanced and every later mode renders at the wrong
|
||||
# instant, drifting its golden. Seen as 5 spurious f1_upcoming drifts.
|
||||
resume_at = None
|
||||
if freezer is not None:
|
||||
try:
|
||||
resume_at = freezer()
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Not a freezegun factory, or a version whose factory is not
|
||||
# callable. Only used to restore the clock, never load-bearing.
|
||||
resume_at = None
|
||||
try:
|
||||
_settle_loop(inst, mode, dm, result, freezer)
|
||||
finally:
|
||||
if resume_at is not None:
|
||||
try:
|
||||
freezer.move_to(resume_at)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _settle_loop(inst, mode, dm, result, freezer) -> None:
|
||||
tick = getattr(freezer, "tick", None) if freezer is not None else None
|
||||
for _ in range(EMPTY_RECHECK_FRAMES):
|
||||
if tick is not None:
|
||||
# timedelta rather than a bare float: freezegun has accepted a
|
||||
# number only since 1.x, and a stale pin would raise here.
|
||||
try:
|
||||
tick(timedelta(seconds=EMPTY_RECHECK_STEP))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Pacing is best-effort; a freezegun that will not take a
|
||||
# timedelta just means this probe runs without advancing time.
|
||||
pass
|
||||
else:
|
||||
# No frozen clock, so the real one has to do the advancing. Without
|
||||
# this the 48 frames run in microseconds, elapsed time stays ~0, and
|
||||
# a scroll driven by elapsed time never moves -- which is exactly
|
||||
# the plugin this check is trying not to slander.
|
||||
time.sleep(EMPTY_RECHECK_STEP)
|
||||
try:
|
||||
result.display_returned = _render_mode_again(inst, mode)
|
||||
except Exception: # noqa: BLE001
|
||||
# Deliberately broad: this calls a plugin's display(), which can
|
||||
# raise anything. The first frame already rendered, so whatever
|
||||
# happens on a re-draw must not turn a good result into an error --
|
||||
# keep the frame we have and stop probing.
|
||||
return
|
||||
image = dm.get_image()
|
||||
if _has_content(image):
|
||||
result.image = image
|
||||
result.overflow = dm.check_overflow()
|
||||
return
|
||||
|
||||
|
||||
def _freeze(freeze_time: Optional[str]):
|
||||
"""Context manager that freezes wall-clock time when freeze_time is given,
|
||||
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
||||
@@ -193,7 +302,9 @@ def render_plugin_matrix(
|
||||
manifest = load_manifest(plugin_dir)
|
||||
# Start from config_schema.json defaults so the plugin behaves like a real
|
||||
# install; explicit caller config still wins over a schema default.
|
||||
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
|
||||
config = merge_config(
|
||||
merge_config({"enabled": True}, load_config_defaults(plugin_dir)),
|
||||
config or {})
|
||||
sizes = sizes or DEFAULT_TEST_SIZES
|
||||
results: List[RenderResult] = []
|
||||
|
||||
@@ -202,25 +313,35 @@ def render_plugin_matrix(
|
||||
# rendering a smaller one, instead of being clipped into a false pass.
|
||||
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
|
||||
|
||||
with _freeze(freeze_time):
|
||||
# One cache for the whole matrix: see _instantiate. The display manager
|
||||
# stays per-render (the bounds checking depends on that); only fetched data
|
||||
# is shared.
|
||||
from src.plugin_system.testing import MockCacheManager
|
||||
cache_manager = MockCacheManager()
|
||||
for key, value in (mock_data or {}).items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
with _freeze(freeze_time) as freezer:
|
||||
for width, height in sizes:
|
||||
results.extend(_render_size(
|
||||
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
||||
width, height, run_update, extent,
|
||||
width, height, run_update, extent, cache_manager, freezer,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||
width, height, run_update, extent) -> List[RenderResult]:
|
||||
width, height, run_update, extent,
|
||||
cache_manager=None, freezer=None) -> List[RenderResult]:
|
||||
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
||||
results: List[RenderResult] = []
|
||||
|
||||
# Discover modes once per size (instance build can depend on config).
|
||||
try:
|
||||
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
|
||||
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm,
|
||||
cache_manager)
|
||||
modes = list_modes(probe, manifest, plugin_id)
|
||||
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
||||
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
||||
@@ -229,7 +350,8 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||
result = RenderResult(plugin_id, width, height, mode)
|
||||
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||
try:
|
||||
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
|
||||
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm,
|
||||
cache_manager)
|
||||
if run_update:
|
||||
try:
|
||||
inst.update()
|
||||
@@ -248,6 +370,9 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||
result.display_returned = _render_mode(inst, mode)
|
||||
result.image = dm.get_image()
|
||||
result.overflow = dm.check_overflow()
|
||||
# A blank first frame is not proof of a blank mode; see
|
||||
# _settle_empty_frame.
|
||||
_settle_empty_frame(inst, mode, dm, result, freezer)
|
||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||
result.error = repr(e)
|
||||
results.append(result)
|
||||
|
||||
@@ -33,6 +33,45 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _defaults_from_properties(properties: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Defaults for one `properties` block, recursing into nested objects.
|
||||
|
||||
An object property carries its defaults on its children, not on itself, so
|
||||
reading only the top level dropped everything nested. That is most of the
|
||||
fleet: config organised by league, or under customization/display_options,
|
||||
lost 2,386 defaults across 37 of 44 plugins -- soccer-scoreboard alone lost
|
||||
539 of 565 -- and the harness rendered them with a config no install would
|
||||
ever have.
|
||||
"""
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in (properties or {}).items():
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
if prop.get('type') == 'object' and isinstance(prop.get('properties'), dict):
|
||||
nested = _defaults_from_properties(prop['properties'])
|
||||
if nested:
|
||||
defaults[key] = nested
|
||||
elif 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Deep-merge override onto base, without dropping sibling defaults.
|
||||
|
||||
A shallow merge would let `-c '{"nhl": {"enabled": true}}'` replace the whole
|
||||
nhl subtree and silently discard every other nhl default -- the same class of
|
||||
bug this function exists to fix.
|
||||
"""
|
||||
merged = dict(base)
|
||||
for key, value in (override or {}).items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = merge_config(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||
@@ -40,11 +79,7 @@ def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if isinstance(prop, dict) and 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
return _defaults_from_properties(schema.get('properties', {}))
|
||||
|
||||
|
||||
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
|
||||
@@ -70,6 +70,7 @@ class VisualTestDisplayManager:
|
||||
# Canvas
|
||||
self.image = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
|
||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||
self.matrix = _MatrixProxy(width, height)
|
||||
@@ -184,6 +185,7 @@ class VisualTestDisplayManager:
|
||||
self.clear_called = True
|
||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
|
||||
def update_display(self):
|
||||
"""No-op for hardware; marks that display was updated."""
|
||||
@@ -211,6 +213,7 @@ class VisualTestDisplayManager:
|
||||
self.matrix = _MatrixProxy(target_w, target_h)
|
||||
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
yield
|
||||
finally:
|
||||
self._width, self._height = prev_w, prev_h
|
||||
@@ -503,9 +506,16 @@ class VisualTestDisplayManager:
|
||||
# Scrolling state (no-op interface compat)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_scrolling_state(self, is_scrolling: bool):
|
||||
"""Set the current scrolling state (no-op for testing)."""
|
||||
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1):
|
||||
"""Set the current scrolling state (no-op for testing).
|
||||
|
||||
``frame_hold`` mirrors DisplayManager.set_scrolling_state so a plugin
|
||||
that paces its scroll can be rendered here. Without it every such
|
||||
plugin raised TypeError at render time and failed every size, which is
|
||||
invisible until a plugin happens to pass the argument.
|
||||
"""
|
||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||
self._scrolling_state['frame_hold'] = frame_hold
|
||||
if is_scrolling:
|
||||
self._scrolling_state['last_scroll_activity'] = time.time()
|
||||
|
||||
@@ -569,6 +579,7 @@ class VisualTestDisplayManager:
|
||||
self.draw_calls = []
|
||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
self._scrolling_state = {
|
||||
'is_scrolling': False,
|
||||
'last_scroll_activity': 0,
|
||||
@@ -582,3 +593,4 @@ class VisualTestDisplayManager:
|
||||
"""Clean up resources."""
|
||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
|
||||
@@ -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.")
|
||||
@@ -319,3 +319,58 @@ class TestLiveMixin:
|
||||
assert not hasattr(h, "_empty_live_streak")
|
||||
h._note_live_fetch(False)
|
||||
assert h._empty_live_streak == 1
|
||||
|
||||
|
||||
class TestPluginDirIsToldNotDeduced:
|
||||
"""The regression that shipped: _plugin_dir returned None in production.
|
||||
|
||||
The first version walked the MRO for a class whose module sits beside a
|
||||
config_schema.json. That passes when a test imports the plugin directly --
|
||||
which is how it was verified -- and returns None under the real plugin
|
||||
loader, which imports modules by a path that leaves no such entry on the
|
||||
MRO.
|
||||
|
||||
Silent, and expensive: no schema means _schema_font_size returns None for
|
||||
every element, so a configured size equal to the schema default stops
|
||||
looking like a default, is treated as a deliberate choice, and skips the
|
||||
grid snap. 4x6-font.ttf then renders at 6 rather than 7 -- 3px-wide glyphs
|
||||
instead of 4px. On a 256x64 panel that made the odds, the records and the
|
||||
date row illegible. A user counted the pixels; no gate here caught it.
|
||||
"""
|
||||
|
||||
def test_a_declared_directory_is_used(self, tmp_path):
|
||||
d = _write_plugin(tmp_path, "declared")
|
||||
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
|
||||
assert host._plugin_dir() == str(d)
|
||||
|
||||
def test_it_works_when_no_module_on_the_mro_helps(self, tmp_path, monkeypatch):
|
||||
"""The production case: nothing on the MRO sits beside a schema."""
|
||||
d = _write_plugin(tmp_path, "loaderstyle")
|
||||
# A class whose module is not importable by name, as the loader produces.
|
||||
cls = type("Loaded", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})
|
||||
cls.__module__ = "a.module.name.that.is.not.in.sys.modules"
|
||||
assert cls.__new__(cls)._plugin_dir() == str(d), (
|
||||
"the declared directory must win when the MRO walk cannot help")
|
||||
|
||||
def test_without_it_the_mro_walk_would_have_failed(self):
|
||||
# Pin the precondition, so this test still means something if the
|
||||
# fallback is ever changed.
|
||||
cls = type("Orphan", (SportsCoreSharedMixin,), {})
|
||||
cls.__module__ = "not.a.real.module"
|
||||
assert cls.__new__(cls)._plugin_dir() is None
|
||||
|
||||
def test_a_declared_directory_without_a_schema_is_not_trusted(self, tmp_path):
|
||||
# A stale or wrong path must not shadow the fallback.
|
||||
empty = tmp_path / "noschema"
|
||||
empty.mkdir()
|
||||
d = _write_plugin(tmp_path, "realone")
|
||||
monkey = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(empty)})
|
||||
assert monkey.__new__(monkey)._plugin_dir() != str(empty)
|
||||
|
||||
def test_the_font_size_consequence(self, tmp_path):
|
||||
"""End to end: a declared directory restores the schema lookup."""
|
||||
d = _write_plugin(tmp_path, "sizeconseq")
|
||||
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
|
||||
assert host._schema_font_size("score") == 16, (
|
||||
"without the schema this is None, which is what made a configured "
|
||||
"size look user-chosen and skipped the grid snap")
|
||||
|
||||
@@ -78,8 +78,11 @@ def _scrub_git_remote_url(url: str) -> str:
|
||||
return url
|
||||
|
||||
# Will be initialized when blueprint is registered
|
||||
config_manager = None
|
||||
plugin_manager = None
|
||||
# NOTE: the managers live on the blueprint object (app.py sets
|
||||
# api_v3.config_manager / api_v3.plugin_manager). Deliberately not
|
||||
# mirrored as module globals: a bare `config_manager` used to resolve to
|
||||
# a None that was never assigned, which silently disabled the /health
|
||||
# checks and made /display/current fall back to a hardcoded 128x64.
|
||||
plugin_store_manager = None
|
||||
saved_repositories_manager = None
|
||||
cache_manager = None
|
||||
@@ -1598,11 +1601,16 @@ def get_health():
|
||||
}
|
||||
|
||||
# Check web interface service
|
||||
# Stamp the start time before measuring against it -- reading it with a
|
||||
# fallback of time.time() and only assigning afterwards made the very
|
||||
# first call subtract two separate clock reads, reporting a small
|
||||
# negative uptime.
|
||||
if not hasattr(get_health, '_start_time'):
|
||||
get_health._start_time = time.time()
|
||||
health_status['services']['web_interface'] = {
|
||||
'status': 'running',
|
||||
'uptime_seconds': time.time() - (getattr(get_health, '_start_time', time.time()))
|
||||
'uptime_seconds': time.time() - get_health._start_time
|
||||
}
|
||||
get_health._start_time = getattr(get_health, '_start_time', time.time())
|
||||
|
||||
# Check display service
|
||||
display_service_status = _get_display_service_status()
|
||||
@@ -1613,8 +1621,8 @@ def get_health():
|
||||
|
||||
# Check config file accessibility
|
||||
try:
|
||||
if config_manager:
|
||||
test_config = config_manager.load_config()
|
||||
if api_v3.config_manager:
|
||||
test_config = api_v3.config_manager.load_config()
|
||||
health_status['checks']['config_file'] = {
|
||||
'status': 'accessible',
|
||||
'readable': True
|
||||
@@ -1633,9 +1641,9 @@ def get_health():
|
||||
|
||||
# Check plugin system
|
||||
try:
|
||||
if plugin_manager:
|
||||
if api_v3.plugin_manager:
|
||||
# Try to discover plugins (lightweight check)
|
||||
plugin_count = len(plugin_manager.get_available_plugins()) if hasattr(plugin_manager, 'get_available_plugins') else 0
|
||||
plugin_count = len(api_v3.plugin_manager.get_available_plugins()) if hasattr(api_v3.plugin_manager, 'get_available_plugins') else 0
|
||||
health_status['checks']['plugin_system'] = {
|
||||
'status': 'operational',
|
||||
'plugin_count': plugin_count
|
||||
@@ -2468,8 +2476,8 @@ def get_display_current():
|
||||
|
||||
# Get display dimensions from config
|
||||
try:
|
||||
if config_manager:
|
||||
main_config = config_manager.load_config()
|
||||
if api_v3.config_manager:
|
||||
main_config = api_v3.config_manager.load_config()
|
||||
hardware_config = main_config.get('display', {}).get('hardware', {})
|
||||
cols = hardware_config.get('cols', 64)
|
||||
chain_length = hardware_config.get('chain_length', 2)
|
||||
|
||||
@@ -1164,9 +1164,16 @@
|
||||
// The pull replaced files on disk; the running services still
|
||||
// hold the code they loaded at boot. Ask for the restart that
|
||||
// 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') {
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user