diff --git a/plugins.json b/plugins.json index 9af6e25b..c9d216bf 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-04", + "last_updated": "2026-08-05", "plugins": [ { "id": "cricket-scoreboard", @@ -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", @@ -101,7 +101,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.10.0" + "latest_version": "1.10.1" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "2.11.0" + "latest_version": "2.11.1" }, { "id": "geochron", @@ -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" }, { @@ -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" }, { @@ -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", @@ -1023,7 +1023,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.3.0", + "latest_version": "1.3.1", "last_updated": "2026-07-31" }, { @@ -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", diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index c6087659..40d32b25 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -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", @@ -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", diff --git a/plugins/afl-scoreboard/scroll_display.py b/plugins/afl-scoreboard/scroll_display.py index c00022a7..0dc80f73 100644 --- a/plugins/afl-scoreboard/scroll_display.py +++ b/plugins/afl-scoreboard/scroll_display.py @@ -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.""" diff --git a/plugins/afl-scoreboard/test_core_fallback.py b/plugins/afl-scoreboard/test_core_fallback.py index 67ae7730..fcb3f700 100644 --- a/plugins/afl-scoreboard/test_core_fallback.py +++ b/plugins/afl-scoreboard/test_core_fallback.py @@ -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: @@ -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) diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index a8817909..d1b79896 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -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", @@ -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", diff --git a/plugins/baseball-scoreboard/test_core_fallback.py b/plugins/baseball-scoreboard/test_core_fallback.py index ce369154..f60df273 100644 --- a/plugins/baseball-scoreboard/test_core_fallback.py +++ b/plugins/baseball-scoreboard/test_core_fallback.py @@ -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: @@ -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) diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 97e5f332..054afd05 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.10.0", + "version": "1.10.1", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.10.1", + "released": "2026-08-05", + "notes": "Fix scroll mode failing to start on LEDMatrix 3.2.0. The NBA, WNBA and NCAA separator-icon paths stayed behind on the bundled fallback class when scroll display adopted the core orchestration, so building the scroll display raised AttributeError and scroll mode did not run at all on a 3.2.0 core. Switch mode and the fallback path were unaffected.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-04", "version": "1.10.0", diff --git a/plugins/basketball-scoreboard/scroll_display.py b/plugins/basketball-scoreboard/scroll_display.py index 169edf6e..14a39bc4 100644 --- a/plugins/basketball-scoreboard/scroll_display.py +++ b/plugins/basketball-scoreboard/scroll_display.py @@ -778,6 +778,16 @@ class ScrollDisplay(_ScrollDisplayBase): # The ladder the legacy _get_scroll_settings walked, same order. SCROLL_LEAGUE_KEYS = ("nba", "wnba", "ncaam", "ncaaw") + # Paths to league separator icons. These must live on THIS class, not + # only on the legacy one: _load_separator_icons below was lifted verbatim + # and reads them off self, and the core base calls it from __init__ -- + # so a missing constant is not a degraded icon, it is an AttributeError + # that stops the scroll display being constructed at all. + NBA_SEPARATOR_ICON = "assets/sports/nba_logos/NBA.png" + WNBA_SEPARATOR_ICON = "assets/sports/wnba_logos/WNBA.png" + NCAA_SEPARATOR_ICON = "assets/sports/ncaa_logos/NCAA.png" # Generic NCAA logo, or use league-specific if available + MARCH_MADNESS_SEPARATOR_ICON = "assets/sports/ncaa_logos/MARCH_MADNESS.png" + def scroll_settings_defaults(self): # Where this plugin's defaults differ from core's. return { diff --git a/plugins/basketball-scoreboard/test_core_fallback.py b/plugins/basketball-scoreboard/test_core_fallback.py index 0b3fa247..577dc455 100644 --- a/plugins/basketball-scoreboard/test_core_fallback.py +++ b/plugins/basketball-scoreboard/test_core_fallback.py @@ -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: @@ -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) diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 09ba2311..1f5b52d6 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.11.0", + "version": "2.11.1", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,12 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.11.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-04", "version": "2.11.0", diff --git a/plugins/football-scoreboard/test_core_fallback.py b/plugins/football-scoreboard/test_core_fallback.py index c668d8c2..d5599fe3 100644 --- a/plugins/football-scoreboard/test_core_fallback.py +++ b/plugins/football-scoreboard/test_core_fallback.py @@ -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: @@ -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) diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 9b123f18..676d51cf 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.7.0", + "version": "1.7.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.7.1", + "released": "2026-08-05", + "notes": "Fix scroll mode failing to start on LEDMatrix 3.2.0. The NHL and NCAA separator-icon paths stayed behind on the bundled fallback class when scroll display adopted the core orchestration, so building the scroll display raised AttributeError and scroll mode did not run at all on a 3.2.0 core. Switch mode and the fallback path were unaffected.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-04", "version": "1.7.0", diff --git a/plugins/hockey-scoreboard/scroll_display.py b/plugins/hockey-scoreboard/scroll_display.py index d07ce8e1..cc6db85f 100644 --- a/plugins/hockey-scoreboard/scroll_display.py +++ b/plugins/hockey-scoreboard/scroll_display.py @@ -737,6 +737,16 @@ class ScrollDisplay(_ScrollDisplayBase): # The ladder the legacy _get_scroll_settings walked, same order. SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens", "ncaam_hockey", "ncaa_womens", "ncaaw_hockey") + # Paths to league separator icons. These must live on THIS class, not + # only on the legacy one: _load_separator_icons below was lifted verbatim + # and reads them off self, and the core base calls it from __init__ -- + # so a missing constant is not a degraded icon, it is an AttributeError + # that stops the scroll display being constructed at all. + NHL_SEPARATOR_ICON = "assets/sports/nhl_logos/NHL.png" + NCAA_SEPARATOR_ICON = "assets/sports/ncaa_logos/NCAA.png" + NCAAM_HOCKEY_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_hockey.png" + NCAAW_HOCKEY_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_hockey.png" + def scroll_settings_defaults(self): # Where this plugin's defaults differ from core's. return { diff --git a/plugins/hockey-scoreboard/test_core_fallback.py b/plugins/hockey-scoreboard/test_core_fallback.py index 97fa0ad8..b5c56d79 100644 --- a/plugins/hockey-scoreboard/test_core_fallback.py +++ b/plugins/hockey-scoreboard/test_core_fallback.py @@ -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: @@ -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) diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index d9d02321..ee91a52f 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.7.0", + "version": "1.7.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "version": "1.7.1", + "released": "2026-08-05", + "notes": "Fix scroll mode failing to start on LEDMatrix 3.2.0. The NCAA separator-icon paths stayed behind on the bundled fallback class when scroll display adopted the core orchestration, so building the scroll display raised AttributeError and scroll mode did not run at all on a 3.2.0 core. Switch mode and the fallback path were unaffected.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-04", "version": "1.7.0", diff --git a/plugins/lacrosse-scoreboard/scroll_display.py b/plugins/lacrosse-scoreboard/scroll_display.py index 4033bc0c..c902ebaa 100644 --- a/plugins/lacrosse-scoreboard/scroll_display.py +++ b/plugins/lacrosse-scoreboard/scroll_display.py @@ -718,6 +718,16 @@ class ScrollDisplay(_ScrollDisplayBase): # The ladder the legacy _get_scroll_settings walked, same order. SCROLL_LEAGUE_KEYS = ("ncaa_mens", "ncaam_lacrosse", "ncaa_womens", "ncaaw_lacrosse") + # Paths to league separator icons. Lacrosse uses a single NCAA lacrosse + # logo for both men's and women's since ESPN does not ship separate + # gendered marks for the sport. These must live on THIS class, not only + # on the legacy one: _load_separator_icons below was lifted verbatim and + # reads them off self, and the core base calls it from __init__ -- so a + # missing constant is not a degraded icon, it is an AttributeError that + # stops the scroll display being constructed at all. + NCAA_SEPARATOR_ICON = "assets/sports/ncaa_logos/NCAA.png" + NCAA_LACROSSE_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_lacrosse.png" + def scroll_settings_defaults(self): # Where this plugin's defaults differ from core's. return { diff --git a/plugins/lacrosse-scoreboard/test_core_fallback.py b/plugins/lacrosse-scoreboard/test_core_fallback.py index 4dd1b4e8..fb95f12e 100644 --- a/plugins/lacrosse-scoreboard/test_core_fallback.py +++ b/plugins/lacrosse-scoreboard/test_core_fallback.py @@ -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: @@ -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) diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 5b782b7e..2894b6fe 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.3.0", + "version": "1.3.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.3.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-04", "version": "1.3.0", diff --git a/plugins/nrl-scoreboard/test_core_fallback.py b/plugins/nrl-scoreboard/test_core_fallback.py index ce090eb8..a9d71deb 100644 --- a/plugins/nrl-scoreboard/test_core_fallback.py +++ b/plugins/nrl-scoreboard/test_core_fallback.py @@ -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: @@ -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) diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index e8e5c9cc..c82593b8 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.6.0", + "version": "2.6.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.6.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-04", "version": "2.6.0", diff --git a/plugins/soccer-scoreboard/test_core_fallback.py b/plugins/soccer-scoreboard/test_core_fallback.py index c993ac82..bd659d6a 100644 --- a/plugins/soccer-scoreboard/test_core_fallback.py +++ b/plugins/soccer-scoreboard/test_core_fallback.py @@ -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: @@ -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)