Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-08-04",
"last_updated": "2026-08-05",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down Expand Up @@ -76,7 +76,7 @@
"last_updated": "2026-07-31",
"verified": true,
"screenshot": "",
"latest_version": "1.22.0"
"latest_version": "1.22.1"
},
{
"id": "basketball-scoreboard",
Expand All @@ -101,7 +101,7 @@
"last_updated": "2026-07-31",
"verified": true,
"screenshot": "",
"latest_version": "1.10.0"
"latest_version": "1.10.1"
},
{
"id": "calendar",
Expand Down Expand Up @@ -240,7 +240,7 @@
"last_updated": "2026-07-31",
"verified": true,
"screenshot": "",
"latest_version": "2.11.0"
"latest_version": "2.11.1"
},
{
"id": "geochron",
Expand Down Expand Up @@ -335,7 +335,7 @@
"last_updated": "2026-07-31",
"verified": true,
"screenshot": "",
"latest_version": "1.7.0",
"latest_version": "1.7.1",
"icon": "fas fa-hockey-puck"
},
{
Expand All @@ -359,7 +359,7 @@
"last_updated": "2026-07-31",
"verified": true,
"screenshot": "",
"latest_version": "1.7.0",
"latest_version": "1.7.1",
"icon": "fas fa-baseball-ball"
},
{
Expand Down Expand Up @@ -735,7 +735,7 @@
"last_updated": "2026-07-31",
"verified": true,
"screenshot": "",
"latest_version": "2.6.0"
"latest_version": "2.6.1"
},
{
"id": "static-image",
Expand Down Expand Up @@ -1023,7 +1023,7 @@
"downloads": 0,
"verified": true,
"screenshot": "",
"latest_version": "1.3.0",
"latest_version": "1.3.1",
"last_updated": "2026-07-31"
},
{
Expand Down Expand Up @@ -1070,7 +1070,7 @@
"last_updated": "2026-07-31",
"verified": true,
"screenshot": "",
"latest_version": "1.3.0"
"latest_version": "1.3.1"
},
{
"id": "jellyfin-now-playing",
Expand Down
8 changes: 7 additions & 1 deletion plugins/afl-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "afl-scoreboard",
"name": "AFL Scoreboard",
"version": "1.3.0",
"version": "1.3.1",
"author": "ChuckBuilds",
"description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.",
"category": "sports",
Expand All @@ -18,6 +18,12 @@
"afl_upcoming"
],
"versions": [
{
"version": "1.3.1",
"released": "2026-08-05",
"notes": "Fix scroll mode rendering nothing on LEDMatrix 3.2.0. The game-renderer cache (_game_renderer/_game_renderer_card_width) was seeded by the bundled class's __init__ but not by the adopted one, so prepare_scroll_content raised AttributeError on its first line. The core base catches exceptions from that method, so there was no error on screen -- scroll mode simply stayed blank. Switch mode and the fallback path were unaffected.",
"ledmatrix_min_version": "2.0.0"
},
{
"released": "2026-08-04",
"version": "1.3.0",
Expand Down
7 changes: 7 additions & 0 deletions plugins/afl-scoreboard/scroll_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ def __init__(self, *args, **kwargs):
# _load_separator_icons() from its __init__.
self.plugin_dir = kwargs.pop('plugin_dir', None) or str(Path(__file__).parent)
super().__init__(*args, **kwargs)
# The renderer cache the legacy __init__ seeded. prepare_scroll_content
# was lifted verbatim and opens with `if self._game_renderer is None`,
# so without these it raises AttributeError on the very first call --
# and the core base catches exceptions out of prepare_scroll_content,
# so the only symptom was scroll mode silently rendering nothing.
self._game_renderer: Optional[GameRenderer] = None
self._game_renderer_card_width: Optional[int] = None

def _load_separator_icons(self) -> None:
"""Load league separator icons from assets directory."""
Expand Down
141 changes: 140 additions & 1 deletion plugins/afl-scoreboard/test_core_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,141 @@ def test_fallback_content_methods_can_resolve_what_they_use():
f"its module cannot resolve"
)


class _StubMatrix:
width = 128
height = 32


class _StubDisplayManager:
"""The minimum a scroll display needs to be built.

Carries a `matrix` as well as bare width/height because the two lineages
read the size differently: the core base prefers `matrix` and falls back to
getattr, while the soccer lineage's bundled manager goes straight for
`display_manager.matrix.width`. A real display manager always has both, so
a stub missing one tests a configuration that never ships.

Nothing here draws, because nothing needs to: the bug this guards against
fires in __init__, long before a frame is rendered.
"""

width = 128
height = 32
matrix = _StubMatrix()


def _args_for(cls):
"""Build kwargs for a constructor by parameter NAME.

The two implementations do not share a signature. The core base takes
``(display_manager, config, custom_logger, global_config)``; the soccer
lineage's bundled class takes ``(display_manager, display_width,
display_height, config, plugin_dir, global_config)``. Both are correct for
their own caller, so this supplies whatever each one asks for rather than
assuming one shape -- which is also why it keeps working if a plugin's
constructor grows a parameter.
"""
import inspect
import logging
import os

known = {
"display_manager": _StubDisplayManager(),
"display_width": 128,
"display_height": 32,
"config": {},
"custom_logger": logging.getLogger("test_core_fallback"),
"logger": logging.getLogger("test_core_fallback"),
"global_config": {},
"plugin_dir": os.path.dirname(os.path.abspath(__file__)),
}
# Union the named parameters across the MRO, not just the class's own
# __init__. Several plugins declare `__init__(self, *args, **kwargs)` purely
# to set an attribute before delegating up, so inspecting that one alone
# yields no parameters at all and constructs nothing. Passing the base's
# names as keywords works because those wrappers forward **kwargs.
kwargs = {}
for klass in cls.__mro__:
init = klass.__dict__.get("__init__")
if init is None:
continue
for name, param in inspect.signature(init).parameters.items():
if name == "self" or param.kind in (
param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue
if name in known:
kwargs.setdefault(name, known[name])
elif param.default is param.empty:
raise AssertionError(
f"{klass.__name__}.__init__ needs an unrecognised argument "
f"{name!r}; teach _args_for about it"
)
return kwargs


def _build(mod):
"""Construct both classes the way the plugin's manager does."""
display = mod.ScrollDisplay(**_args_for(mod.ScrollDisplay))
manager = mod.ScrollDisplayManager(**_args_for(mod.ScrollDisplayManager))
# get_scroll_display() is where the manager first builds a display, so a
# constructor that raises shows up here rather than at first render.
manager.get_scroll_display("recent")
return display


def test_scroll_display_constructs_on_both_paths():
"""Building the display must work on the core path and the fallback.

This is the check that would have caught the separator-icon constants being
left behind on the legacy class: `_load_separator_icons` was lifted verbatim
into the new class and reads them off `self`, and the core base calls it
from `__init__` -- so the miss was not a degraded icon, it was an
AttributeError that stopped the display being constructed at all. Scroll
mode was dead for three plugins while every other gate stayed green.
"""
import logging

logging.disable(logging.CRITICAL)
try:
core_display = _build(_fresh_scroll_display())
core_icons = {k: v.size for k, v in core_display._separator_icons.items()}

with _BlockModules(CORE_MODULE):
legacy_display = _build(_fresh_scroll_display())
legacy_icons = {
k: v.size for k, v in legacy_display._separator_icons.items()
}
finally:
logging.disable(logging.NOTSET)

# Adopting core code must not change what gets drawn. Comparing the two
# paths needs no per-sport knowledge of the right answer -- only that the
# answer did not change.
assert core_icons == legacy_icons, (
f"separator icons differ between paths: core={core_icons} "
f"legacy={legacy_icons}"
)

# Attributes the bundled __init__ seeded but the adopted class does not.
# Construction alone cannot catch this: the object builds fine and only
# fails later, when a lifted method reads the attribute that was never set.
# afl shipped exactly that -- prepare_scroll_content opens with
# `if self._game_renderer is None`, the legacy __init__ set it to None and
# the new one did not, and because the core base CATCHES exceptions out of
# prepare_scroll_content the only symptom was scroll mode quietly drawing
# nothing. Checked one way only: extra attributes on the core path are the
# base class doing its job, not a defect.
missing = sorted(
name for name in vars(legacy_display)
if not hasattr(core_display, name)
)
assert not missing, (
f"the adopted class never sets {missing}, which the bundled one "
f"initialised — any lifted method that reads them raises AttributeError"
)


if __name__ == "__main__":
# Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an
# exception raised *during* a test is what this suite is guarding against:
Expand All @@ -241,11 +376,15 @@ def test_fallback_content_methods_can_resolve_what_they_use():
test_core_absent_falls_back_and_still_works,
test_content_methods_can_resolve_what_they_use,
test_fallback_content_methods_can_resolve_what_they_use,
test_scroll_display_constructs_on_both_paths,
test_sunset_state_fails_specifically):
try:
t()
print(f"PASS {t.__name__}")
except (AssertionError, ModuleNotFoundError) as e:
# Any exception is a failure. Narrower clauses let the construction
# test's AttributeError escape and kill the runner mid-suite, so the
# bug it caught was reported as a crash rather than against its name.
except Exception as e:
failures.append(t.__name__)
print(f"FAIL {t.__name__}: {e}")
print("=" * 55)
Expand Down
8 changes: 7 additions & 1 deletion plugins/baseball-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "baseball-scoreboard",
"name": "Baseball Scoreboard",
"version": "1.22.0",
"version": "1.22.1",
"author": "ChuckBuilds",
"description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules",
"category": "sports",
Expand Down Expand Up @@ -30,6 +30,12 @@
"branch": "main",
"plugin_path": "plugins/baseball-scoreboard",
"versions": [
{
"version": "1.22.1",
"released": "2026-08-05",
"notes": "Test-only: the scroll display is now constructed on both the core and fallback paths, and its separator icons compared between them. The previous checks verified that methods existed and that their globals resolved, which could not see a constant read off self -- the miss that broke scroll mode in three sibling plugins.",
"ledmatrix_min_version": "2.0.0"
},
{
"released": "2026-08-03",
"version": "1.22.0",
Expand Down
Loading
Loading