From 2a3f365a19ed427cfc6ec87059438326eae152ed Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:30:55 -0400 Subject: [PATCH] fix: plugin asset paths that miss on the Pi's case-sensitive filesystem leaderboard 1.5.6: league_config named nfl.png, nba.png, mlb.png and nhl.png while the core ships NFL.png etc. Core 3.3.0 (#506) dropped the lowercase duplicates, so on a Pi those leagues drew a blank logo column and warned on every rebuild (hdpi: three warnings an hour). A Windows checkout finds either spelling, which hid it. NCAA women's basketball now uses the core's NCAA basketball badge (ncaaw.png never shipped). The core has no NCAA baseball logo, so that column stays blank. A missing league logo is now warned once per path, then logged at debug. baseball 1.57.1: the core ships no MiLB or NCAA baseball icon, and its NCAA badges show a basketball or hockey player, so those separators are skipped at debug instead of warning on every load (ledpi). Adding the file at either path still turns that separator on. basketball 1.42.1 and hockey 1.42.3 had the same bug. Their NCAA separators named NCAA.png and ncaa_hockey.png, neither of which the core has ever shipped. They now use the core's ncaam.png and ncaah.png badges. Basketball's missing WNBA icon drops to debug as well. Tests: scripts/test_core_asset_paths.py fails on any assets/ literal that exists in the core only under another case (it compares against listdir, so it works on Windows too). It also fails on any separator icon that neither ships with the core nor is declared as a drop-in. Per-plugin tests cover the leaderboard's logo paths and warn-once behaviour, and build each scoreboard's real ScrollDisplay. Every one of them fails on main. Co-Authored-By: Claude Opus 5.5 --- docs/assets/ledmatrix-leaderboard/shots.json | 2 +- plugins.json | 28 +-- plugins/baseball-scoreboard/README.md | 2 +- plugins/baseball-scoreboard/manifest.json | 8 +- plugins/baseball-scoreboard/scroll_display.py | 9 +- .../test_separator_icons.py | 102 +++++++++ plugins/basketball-scoreboard/README.md | 2 +- plugins/basketball-scoreboard/manifest.json | 8 +- .../basketball-scoreboard/scroll_display.py | 16 +- .../test_separator_icons.py | 103 +++++++++ plugins/hockey-scoreboard/manifest.json | 8 +- plugins/hockey-scoreboard/scroll_display.py | 10 +- .../hockey-scoreboard/test_separator_icons.py | 102 +++++++++ plugins/ledmatrix-leaderboard/README.md | 5 + .../ledmatrix-leaderboard/image_renderer.py | 19 +- .../ledmatrix-leaderboard/league_config.py | 19 +- plugins/ledmatrix-leaderboard/manifest.json | 8 +- .../test_league_logo_paths.py | 142 ++++++++++++ .../test_pixel_perfect.py | 2 +- scripts/test_core_asset_paths.py | 208 ++++++++++++++++++ 20 files changed, 763 insertions(+), 40 deletions(-) create mode 100644 plugins/baseball-scoreboard/test_separator_icons.py create mode 100644 plugins/basketball-scoreboard/test_separator_icons.py create mode 100644 plugins/hockey-scoreboard/test_separator_icons.py create mode 100644 plugins/ledmatrix-leaderboard/test_league_logo_paths.py create mode 100644 scripts/test_core_asset_paths.py diff --git a/docs/assets/ledmatrix-leaderboard/shots.json b/docs/assets/ledmatrix-leaderboard/shots.json index baca8485..07083599 100644 --- a/docs/assets/ledmatrix-leaderboard/shots.json +++ b/docs/assets/ledmatrix-leaderboard/shots.json @@ -19,7 +19,7 @@ "sport": "football", "league": "nfl", "logo_dir": "assets/sports/nfl_logos", - "league_logo": "assets/sports/nfl_logos/nfl.png", + "league_logo": "assets/sports/nfl_logos/NFL.png", "top_teams": 6, "level": 1, "show_ranking": false diff --git a/plugins.json b/plugins.json index fcd23da6..1e93beea 100644 --- a/plugins.json +++ b/plugins.json @@ -77,12 +77,11 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-03", "verified": true, "screenshot": "", - "latest_version": "1.57.0", - "ledmatrix_min_version": "3.8.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "latest_version": "1.57.1", + "ledmatrix_min_version": "3.8.0" }, { "id": "basketball-scoreboard", @@ -104,12 +103,11 @@ "plugin_path": "plugins/basketball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-03", "verified": true, "screenshot": "", - "latest_version": "1.42.0", - "ledmatrix_min_version": "3.8.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "latest_version": "1.42.1", + "ledmatrix_min_version": "3.8.0" }, { "id": "calendar", @@ -382,13 +380,12 @@ "plugin_path": "plugins/hockey-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-03", "verified": true, "screenshot": "", - "latest_version": "1.42.2", + "latest_version": "1.42.3", "icon": "fas fa-hockey-puck", - "ledmatrix_min_version": "3.8.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "ledmatrix_min_version": "3.8.0" }, { "id": "lacrosse-scoreboard", @@ -465,15 +462,14 @@ "plugin_path": "plugins/ledmatrix-leaderboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-03", "verified": true, "screenshot": "", - "latest_version": "1.5.5", + "latest_version": "1.5.6", "ledmatrix_min_version": "3.4.0", "aliases": [ "ledmatrix-leaderboard" - ], - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + ] }, { "id": "ledmatrix-flights", diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index 535c19f5..458d671c 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -374,7 +374,7 @@ three league blocks, and all are **Advanced**. | `.scroll_settings.scroll_speed` | `50.0` | Pixels per second. Higher scrolls faster. | | `.scroll_settings.scroll_delay` | `0.01` | **Hidden.** Ignored; kept declared so saved configs still load. Scrolling is paced to the panel refresh; `scroll_speed` sets the speed. | | `.scroll_settings.gap_between_games` | `48` | Pixels between game cards. | -| `.scroll_settings.show_league_separators` | `true` | Draw a league icon between leagues in a mixed ticker. | +| `.scroll_settings.show_league_separators` | `true` | Draw a league icon between leagues in a mixed ticker. Only MLB has one: the core ships no MiLB or NCAA baseball icon, so those leagues scroll without one unless you add `assets/sports/milb_logos/MiLB.png` or `assets/sports/ncaa_logos/ncaa_baseball.png` to the LEDMatrix install. | | `.scroll_settings.dynamic_duration` | `true` | Size the mode's duration from how long the scroll actually takes, so a long slate is not cut off mid-scroll. | | `.scroll_settings.game_card_width` | `128` | Width of each card. Lower it on a multi-panel chain to fit more games on screen at once. | diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index eed7544f..9cbf3ceb 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.57.0", + "version": "1.57.1", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.57.1", + "released": "2026-10-03", + "ledmatrix_min_version": "3.8.0", + "notes": "The MiLB and NCAA baseball separator icons are skipped quietly (debug) instead of warning on every scroll display load: the core ships neither (MiLB.png, ncaa_baseball.png), and its NCAA badges show a basketball or hockey player. MLB keeps its separator. Adding either file to the LEDMatrix install still turns its separator on." + }, { "version": "1.57.0", "released": "2026-10-02", diff --git a/plugins/baseball-scoreboard/scroll_display.py b/plugins/baseball-scoreboard/scroll_display.py index 757c545a..8c08a185 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -46,7 +46,10 @@ class ScrollDisplay(_ScrollDisplayBase): # The ladder the legacy _get_scroll_settings walked, same order. SCROLL_LEAGUE_KEYS = ("mlb", "milb", "ncaa_baseball") - # Paths to league separator icons + # Paths to league separator icons. The core ships MLB.png only: it has no + # MiLB or NCAA baseball league logo (its NCAA badges show a basketball or + # hockey player), so those two leagues scroll without a separator unless + # a file is added at these paths. A missing icon is skipped quietly. MLB_SEPARATOR_ICON = "assets/sports/mlb_logos/MLB.png" MILB_SEPARATOR_ICON = "assets/sports/milb_logos/MiLB.png" NCAA_BASEBALL_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_baseball.png" @@ -163,7 +166,9 @@ def _load_separator_icon(self, icon_path: str, league_key: str, target_height: i target_height: Target height for the resized icon """ if not os.path.exists(icon_path): - self.logger.warning(f"{league_key.upper()} separator icon not found at {icon_path}") + # Debug, not warning: the core ships no MiLB or NCAA baseball + # icon, so every install logged these on every load. + self.logger.debug(f"{league_key.upper()} separator icon not found at {icon_path} (will skip separator)") return try: diff --git a/plugins/baseball-scoreboard/test_separator_icons.py b/plugins/baseball-scoreboard/test_separator_icons.py new file mode 100644 index 00000000..e5e715bf --- /dev/null +++ b/plugins/baseball-scoreboard/test_separator_icons.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Separator icons: the ones the core ships load, the rest are skipped quietly. + +The core ships MLB.png but no MiLB or NCAA baseball league logo, so +those two separators can never load. Each logged a warning on every +load of the scroll display, on every install. + +Builds the real ScrollDisplay with the working directory at a core checkout +(the icon paths are relative to it) and records what it logs. Needs a core +with src.common.sports_scroll (LEDMATRIX_CORE, the working directory, or +../LEDMatrix next to this repo); exits 2 without one. + +Run: /bin/python plugins/baseball-scoreboard/test_separator_icons.py +""" + +import logging +import os +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent + +#: League keys that must get a separator: the core ships their icon. +LOADED = {"mlb"} +#: League keys whose icon the core does not ship: no separator, no warning. +SKIPPED = {"milb", "ncaa_baseball"} + + +def core_checkout(): + for candidate in (os.environ.get("LEDMATRIX_CORE", ""), os.getcwd(), + str(PLUGIN_DIR.parents[2] / "LEDMatrix")): + if candidate and (Path(candidate) / "src" / "common" / "sports_scroll.py").is_file(): + return Path(candidate) + return None + + +class _Records(logging.Handler): + def __init__(self): + super().__init__(logging.DEBUG) + self.records = [] + + def emit(self, record): + self.records.append(record) + + +def main(): + core = core_checkout() + if core is None: + print("SKIP: no LEDMatrix core checkout with src.common.sports_scroll") + return 2 + sys.path.insert(0, str(PLUGIN_DIR)) + sys.path.insert(1, str(core)) + os.chdir(core) + + import test_core_scroll as helpers # the constructor stubs live there + + logger = logging.getLogger("test_separator_icons") + logger.setLevel(logging.DEBUG) + logger.propagate = False + handler = _Records() + logger.addHandler(handler) + + mod = helpers._fresh_scroll_display() + kwargs = helpers._args_for(mod.ScrollDisplay) + for name in ("custom_logger", "logger"): + if name in kwargs: + kwargs[name] = logger + display = mod.ScrollDisplay(**kwargs) + icons = display._separator_icons + height = display.display_height - 4 + + failures = [] + + def check(name, cond, detail=""): + print((" PASS " if cond else " FAIL ") + name + + ("" if cond or not detail else ": " + detail)) + if not cond: + failures.append(name) + + print("separator icons against %s" % core) + for key in sorted(LOADED): + icon = icons.get(key) + check("%s separator loads" % key, icon is not None, + "not in %s" % sorted(icons)) + if icon is not None: + check("%s separator is %dpx tall" % (key, height), icon.height == height, + "got %d" % icon.height) + for key in sorted(SKIPPED): + check("%s has no separator" % key, key not in icons) + warned = [r.getMessage() for r in handler.records + if r.levelno >= logging.WARNING and "separator" in r.getMessage().lower()] + check("nothing about separators is logged above debug", not warned, repr(warned)) + + if failures: + print("\n%d failure(s)" % len(failures)) + return 1 + print("\nall passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/basketball-scoreboard/README.md b/plugins/basketball-scoreboard/README.md index 9269ab37..7fdcb9ab 100644 --- a/plugins/basketball-scoreboard/README.md +++ b/plugins/basketball-scoreboard/README.md @@ -407,7 +407,7 @@ All **Advanced**, and per league. | `.scroll_settings.scroll_speed` | 1.0–200.0 px/s | `50.0` | Higher scrolls faster. | | `.scroll_settings.scroll_delay` | 0.001–0.1 s | `0.01` | Ignored; kept so saved configs still load. Scrolling is paced to the panel refresh; `scroll_speed` sets the speed. **Hidden from the config form since 1.35.0 (still declared).** | | `.scroll_settings.gap_between_games` | 8–128 px | `48` | Gap between game cards. | -| `.scroll_settings.show_league_separators` | boolean | `true` | Draw league icons between leagues. | +| `.scroll_settings.show_league_separators` | boolean | `true` | Draw league icons between leagues: the NBA logo, the NCAA basketball badge for men's and women's college games (March Madness during the tournament). The core ships no WNBA icon, so the WNBA scrolls without one unless you add `assets/sports/wnba_logos/WNBA.png` to the LEDMatrix install. | | `.scroll_settings.dynamic_duration` | boolean | `true` | Size the scroll duration from the content width. | | `.scroll_settings.game_card_width` | 32–512 px | `128` | Card width. Lower it on a multi-panel chain to fit more games on screen at once. | diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 1961b8bc..208616cb 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.42.0", + "version": "1.42.1", "update_interval": 60, "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", @@ -19,6 +19,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.42.1", + "released": "2026-10-03", + "ledmatrix_min_version": "3.8.0", + "notes": "NCAA men's and women's slates get a league separator again: it named an NCAA.png the core has never shipped, and now uses the core's NCAA basketball badge (ncaam.png). The core ships no WNBA icon, so that separator is skipped at debug instead of warning on every scroll display load." + }, { "version": "1.42.0", "released": "2026-10-02", diff --git a/plugins/basketball-scoreboard/scroll_display.py b/plugins/basketball-scoreboard/scroll_display.py index 2ef78a3d..73c6cff9 100644 --- a/plugins/basketball-scoreboard/scroll_display.py +++ b/plugins/basketball-scoreboard/scroll_display.py @@ -61,9 +61,15 @@ class ScrollDisplay(_ScrollDisplayBase): # 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. + # + # Spelled exactly as the core ships them: the Pi's filesystem is + # case-sensitive. The core has no generic NCAA.png; ncaam.png is its NCAA + # basketball badge, so the men's and women's slates share it. It has no + # WNBA icon either, so the WNBA scrolls without a separator unless one is + # added at this path. 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 + NCAA_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaam.png" MARCH_MADNESS_SEPARATOR_ICON = "assets/sports/ncaa_logos/MARCH_MADNESS.png" @@ -153,7 +159,9 @@ def _load_separator_icon( display_name: Name for logging purposes """ if not os.path.exists(icon_path): - self.logger.warning(f"{display_name} separator icon not found at {icon_path}") + # Debug, not warning: the core ships no WNBA icon, so every + # install logged this on every load. + self.logger.debug(f"{display_name} separator icon not found at {icon_path} (will skip separator)") return try: @@ -252,8 +260,8 @@ def build_vegas_elements(self, games: List[Dict], leagues: List[str], def vegas_separator(self, league: str) -> Optional[Image.Image]: """Core's league separator, or the March Madness one ahead of tournament games. - Built as prepare_scroll_content builds it. The core ships no NCAA.png, - so without this a tournament slate would lose its separator altogether. + Built as prepare_scroll_content builds it. Without this a tournament + slate would show the regular NCAA badge rather than March Madness. """ icon = self._separator_icons.get(f"{league}_tournament") if icon is None or league not in getattr(self, "_vegas_tournament_leagues", ()): diff --git a/plugins/basketball-scoreboard/test_separator_icons.py b/plugins/basketball-scoreboard/test_separator_icons.py new file mode 100644 index 00000000..05813e33 --- /dev/null +++ b/plugins/basketball-scoreboard/test_separator_icons.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Separator icons: the ones the core ships load, the rest are skipped quietly. + +The NCAA separator named an NCAA.png the core has never shipped, so +men's and women's college slates scrolled with none, while the core's +NCAA basketball badge (ncaam.png) sat unused. There is no WNBA icon +at all, and its miss was logged as a warning on every load. + +Builds the real ScrollDisplay with the working directory at a core checkout +(the icon paths are relative to it) and records what it logs. Needs a core +with src.common.sports_scroll (LEDMATRIX_CORE, the working directory, or +../LEDMatrix next to this repo); exits 2 without one. + +Run: /bin/python plugins/basketball-scoreboard/test_separator_icons.py +""" + +import logging +import os +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent + +#: League keys that must get a separator: the core ships their icon. +LOADED = {"nba", "ncaam", "ncaaw", "ncaam_tournament", "ncaaw_tournament"} +#: League keys whose icon the core does not ship: no separator, no warning. +SKIPPED = {"wnba"} + + +def core_checkout(): + for candidate in (os.environ.get("LEDMATRIX_CORE", ""), os.getcwd(), + str(PLUGIN_DIR.parents[2] / "LEDMatrix")): + if candidate and (Path(candidate) / "src" / "common" / "sports_scroll.py").is_file(): + return Path(candidate) + return None + + +class _Records(logging.Handler): + def __init__(self): + super().__init__(logging.DEBUG) + self.records = [] + + def emit(self, record): + self.records.append(record) + + +def main(): + core = core_checkout() + if core is None: + print("SKIP: no LEDMatrix core checkout with src.common.sports_scroll") + return 2 + sys.path.insert(0, str(PLUGIN_DIR)) + sys.path.insert(1, str(core)) + os.chdir(core) + + import test_core_scroll as helpers # the constructor stubs live there + + logger = logging.getLogger("test_separator_icons") + logger.setLevel(logging.DEBUG) + logger.propagate = False + handler = _Records() + logger.addHandler(handler) + + mod = helpers._fresh_scroll_display() + kwargs = helpers._args_for(mod.ScrollDisplay) + for name in ("custom_logger", "logger"): + if name in kwargs: + kwargs[name] = logger + display = mod.ScrollDisplay(**kwargs) + icons = display._separator_icons + height = display.display_height - 4 + + failures = [] + + def check(name, cond, detail=""): + print((" PASS " if cond else " FAIL ") + name + + ("" if cond or not detail else ": " + detail)) + if not cond: + failures.append(name) + + print("separator icons against %s" % core) + for key in sorted(LOADED): + icon = icons.get(key) + check("%s separator loads" % key, icon is not None, + "not in %s" % sorted(icons)) + if icon is not None: + check("%s separator is %dpx tall" % (key, height), icon.height == height, + "got %d" % icon.height) + for key in sorted(SKIPPED): + check("%s has no separator" % key, key not in icons) + warned = [r.getMessage() for r in handler.records + if r.levelno >= logging.WARNING and "separator" in r.getMessage().lower()] + check("nothing about separators is logged above debug", not warned, repr(warned)) + + if failures: + print("\n%d failure(s)" % len(failures)) + return 1 + print("\nall passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 3e42c2e2..56c9a576 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.42.2", + "version": "1.42.3", "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.42.3", + "released": "2026-10-03", + "ledmatrix_min_version": "3.8.0", + "notes": "NCAA men's and women's hockey slates get a league separator: it named an ncaa_hockey.png the core has never shipped, and now uses the core's NCAA hockey badge (ncaah.png)." + }, { "version": "1.42.2", "released": "2026-10-02", diff --git a/plugins/hockey-scoreboard/scroll_display.py b/plugins/hockey-scoreboard/scroll_display.py index ab90737d..c9a01fa3 100644 --- a/plugins/hockey-scoreboard/scroll_display.py +++ b/plugins/hockey-scoreboard/scroll_display.py @@ -58,10 +58,16 @@ class ScrollDisplay(_ScrollDisplayBase): # 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. + # + # Spelled exactly as the core ships them: the Pi's filesystem is + # case-sensitive. ncaah.png is the core's NCAA hockey badge; these named + # an ncaa_hockey.png it never shipped, so NCAA hockey scrolled with no + # separator. There is no generic NCAA.png either; the "ncaa" key keeps + # its path so a file added there is still picked up. 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" + NCAAM_HOCKEY_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaah.png" + NCAAW_HOCKEY_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaah.png" def _default_game_card_width(self) -> int: diff --git a/plugins/hockey-scoreboard/test_separator_icons.py b/plugins/hockey-scoreboard/test_separator_icons.py new file mode 100644 index 00000000..40dad60e --- /dev/null +++ b/plugins/hockey-scoreboard/test_separator_icons.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Separator icons: the ones the core ships load, the rest are skipped quietly. + +The NCAA hockey separators named an ncaa_hockey.png the core has never +shipped; its NCAA hockey badge is ncaah.png. College slates scrolled +with no separator, and nothing logged it. + +Builds the real ScrollDisplay with the working directory at a core checkout +(the icon paths are relative to it) and records what it logs. Needs a core +with src.common.sports_scroll (LEDMATRIX_CORE, the working directory, or +../LEDMatrix next to this repo); exits 2 without one. + +Run: /bin/python plugins/hockey-scoreboard/test_separator_icons.py +""" + +import logging +import os +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent + +#: League keys that must get a separator: the core ships their icon. +LOADED = {"nhl", "ncaam_hockey", "ncaa_mens", "ncaaw_hockey", "ncaa_womens"} +#: League keys whose icon the core does not ship: no separator, no warning. +SKIPPED = {"ncaa"} + + +def core_checkout(): + for candidate in (os.environ.get("LEDMATRIX_CORE", ""), os.getcwd(), + str(PLUGIN_DIR.parents[2] / "LEDMatrix")): + if candidate and (Path(candidate) / "src" / "common" / "sports_scroll.py").is_file(): + return Path(candidate) + return None + + +class _Records(logging.Handler): + def __init__(self): + super().__init__(logging.DEBUG) + self.records = [] + + def emit(self, record): + self.records.append(record) + + +def main(): + core = core_checkout() + if core is None: + print("SKIP: no LEDMatrix core checkout with src.common.sports_scroll") + return 2 + sys.path.insert(0, str(PLUGIN_DIR)) + sys.path.insert(1, str(core)) + os.chdir(core) + + import test_core_scroll as helpers # the constructor stubs live there + + logger = logging.getLogger("test_separator_icons") + logger.setLevel(logging.DEBUG) + logger.propagate = False + handler = _Records() + logger.addHandler(handler) + + mod = helpers._fresh_scroll_display() + kwargs = helpers._args_for(mod.ScrollDisplay) + for name in ("custom_logger", "logger"): + if name in kwargs: + kwargs[name] = logger + display = mod.ScrollDisplay(**kwargs) + icons = display._separator_icons + height = display.display_height - 4 + + failures = [] + + def check(name, cond, detail=""): + print((" PASS " if cond else " FAIL ") + name + + ("" if cond or not detail else ": " + detail)) + if not cond: + failures.append(name) + + print("separator icons against %s" % core) + for key in sorted(LOADED): + icon = icons.get(key) + check("%s separator loads" % key, icon is not None, + "not in %s" % sorted(icons)) + if icon is not None: + check("%s separator is %dpx tall" % (key, height), icon.height == height, + "got %d" % icon.height) + for key in sorted(SKIPPED): + check("%s has no separator" % key, key not in icons) + warned = [r.getMessage() for r in handler.records + if r.levelno >= logging.WARNING and "separator" in r.getMessage().lower()] + check("nothing about separators is logged above debug", not warned, repr(warned)) + + if failures: + print("\n%d failure(s)" % len(failures)) + return 1 + print("\nall passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ledmatrix-leaderboard/README.md b/plugins/ledmatrix-leaderboard/README.md index 18442462..9f7e12e7 100644 --- a/plugins/ledmatrix-leaderboard/README.md +++ b/plugins/ledmatrix-leaderboard/README.md @@ -246,6 +246,11 @@ The plugin supports the following sports leagues (the `enabled_sports` keys): - **ncaam_hockey**: NCAA Men's Hockey poll - **ncaa_baseball**: NCAA Baseball standings +Each league opens with its logo from the LEDMatrix install's `assets/sports/`. +Both college basketball polls use the NCAA basketball badge. The core ships no +NCAA baseball logo, so that league's logo column stays blank unless you add +`assets/sports/ncaa_logos/ncaa_baseball.png`. + ## Data fetching - Standings are fetched in `update()`, never while drawing, and cached. diff --git a/plugins/ledmatrix-leaderboard/image_renderer.py b/plugins/ledmatrix-leaderboard/image_renderer.py index a2aa8357..0de07b16 100644 --- a/plugins/ledmatrix-leaderboard/image_renderer.py +++ b/plugins/ledmatrix-leaderboard/image_renderer.py @@ -97,6 +97,9 @@ def __init__(self, display_height: int, logger: Optional[logging.Logger] = None, # changed. On a live rig this ran on the render thread and was the # largest single contributor to a 3.2s freeze of the scroll. self._logo_cache: Dict[Any, Optional[Image.Image]] = {} + # League logo paths already reported missing. A miss is not cached + # (see _cached_prepared_logo), so without this every rebuild warned. + self._missing_league_logos: set = set() self.text_outline = bool(appearance.get('text_outline', True)) self.logo_scale = self._clamp_float(appearance.get('logo_scale', 1.0), 0.5, 1.5, 1.0) self.font_size_override = self._clamp_int(appearance.get('font_size', 0), 0, 32, 0) @@ -362,7 +365,12 @@ def download_missing_logos(self, leaderboard_data: List[Dict[str, Any]]) -> None self.logger.error("Error downloading missing logo for %s: %s", team_abbr, e) def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: - """Get league logo from the configured path.""" + """Get league logo from the configured path. + + A missing file is warned about once per path, then logged at debug: + the league still draws, with a blank logo column, and the rebuild + that asks again comes round every cycle. + """ if not league_logo_path: return None try: @@ -370,9 +378,14 @@ def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: logo = Image.open(league_logo_path) self.logger.debug(f"Successfully loaded league logo from {league_logo_path}") return logo + if league_logo_path in self._missing_league_logos: + self.logger.debug("League logo still missing: %s", league_logo_path) else: - self.logger.warning(f"League logo not found at path: {league_logo_path}") - return None + self._missing_league_logos.add(league_logo_path) + self.logger.warning( + "League logo not found at path: %s (drawing the league without one)", + league_logo_path) + return None except Exception as e: self.logger.error(f"Error loading league logo: {e}") return None diff --git a/plugins/ledmatrix-leaderboard/league_config.py b/plugins/ledmatrix-leaderboard/league_config.py index f3e65c59..3024daca 100644 --- a/plugins/ledmatrix-leaderboard/league_config.py +++ b/plugins/ledmatrix-leaderboard/league_config.py @@ -44,6 +44,15 @@ def _initialize_league_configs(self) -> Dict[str, Dict[str, Any]]: hand-edited config or a hot reload without enabled_sports. They used to enable ncaam_hockey and not nba, mlb or nhl, the opposite of what the settings form shows for a fresh install. + + ``league_logo`` names a file the core ships, spelled exactly as it is + on disk: the Pi's filesystem is case-sensitive, so ``nfl.png`` misses + ``NFL.png`` there while a Windows or macOS checkout finds it. Core + 3.3.0 dropped the lowercase copies these used to name (core #506). + The core has no women's NCAA or NCAA baseball league logo: + ncaaw_basketball borrows the NCAA basketball badge, and ncaa_baseball + names a file nothing ships, so its column draws blank unless one is + added there. ``scripts/test_core_asset_paths.py`` checks the spelling. """ # Default enabled values per config schema DEFAULT_ENABLED = { @@ -63,7 +72,7 @@ def get_enabled_default(league_key: str) -> bool: 'sport': 'football', 'league': 'nfl', 'logo_dir': 'assets/sports/nfl_logos', - 'league_logo': 'assets/sports/nfl_logos/nfl.png', + 'league_logo': 'assets/sports/nfl_logos/NFL.png', 'standings_url': 'https://site.api.espn.com/apis/v2/sports/football/nfl/standings', 'enabled': self.enabled_sports.get('nfl', {}).get('enabled', get_enabled_default('nfl')), 'top_teams': self.enabled_sports.get('nfl', {}).get('top_teams', 10), @@ -75,7 +84,7 @@ def get_enabled_default(league_key: str) -> bool: 'sport': 'basketball', 'league': 'nba', 'logo_dir': 'assets/sports/nba_logos', - 'league_logo': 'assets/sports/nba_logos/nba.png', + 'league_logo': 'assets/sports/nba_logos/NBA.png', 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams', 'standings_url': 'https://site.api.espn.com/apis/v2/sports/basketball/nba/standings', 'enabled': self.enabled_sports.get('nba', {}).get('enabled', get_enabled_default('nba')), @@ -85,7 +94,7 @@ def get_enabled_default(league_key: str) -> bool: 'sport': 'baseball', 'league': 'mlb', 'logo_dir': 'assets/sports/mlb_logos', - 'league_logo': 'assets/sports/mlb_logos/mlb.png', + 'league_logo': 'assets/sports/mlb_logos/MLB.png', 'standings_url': 'https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings', 'enabled': self.enabled_sports.get('mlb', {}).get('enabled', get_enabled_default('mlb')), 'top_teams': self.enabled_sports.get('mlb', {}).get('top_teams', 10), @@ -108,7 +117,7 @@ def get_enabled_default(league_key: str) -> bool: 'sport': 'hockey', 'league': 'nhl', 'logo_dir': 'assets/sports/nhl_logos', - 'league_logo': 'assets/sports/nhl_logos/nhl.png', + 'league_logo': 'assets/sports/nhl_logos/NHL.png', 'standings_url': 'https://site.api.espn.com/apis/v2/sports/hockey/nhl/standings', 'enabled': self.enabled_sports.get('nhl', {}).get('enabled', get_enabled_default('nhl')), 'top_teams': self.enabled_sports.get('nhl', {}).get('top_teams', 10), @@ -131,7 +140,7 @@ def get_enabled_default(league_key: str) -> bool: 'sport': 'basketball', 'league': 'womens-college-basketball', 'logo_dir': 'assets/sports/ncaa_womens_logos', - 'league_logo': 'assets/sports/ncaa_womens_logos/ncaaw.png', + 'league_logo': 'assets/sports/ncaa_logos/ncaam.png', 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/teams', 'rankings_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/rankings', 'enabled': self.enabled_sports.get('ncaaw_basketball', {}).get('enabled', get_enabled_default('ncaaw_basketball')), diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index 7179ada9..9798570f 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-leaderboard", "name": "Sports Leaderboard", - "version": "1.5.5", + "version": "1.5.6", "description": "Displays scrolling leaderboards and standings for multiple sports leagues including NFL, NBA, MLB, NCAA Football, NCAA Basketball, and more", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -31,6 +31,12 @@ "requirements_file": "requirements.txt", "min_ledmatrix_version": "3.4.0", "versions": [ + { + "version": "1.5.6", + "released": "2026-10-03", + "ledmatrix_min_version": "3.4.0", + "notes": "Fixes the NFL, NBA, MLB and NHL league logos on the Pi: they named nfl.png etc. while the core ships NFL.png (its lowercase copies went in core 3.3.0), and the Pi's filesystem is case-sensitive, so those leagues drew a blank logo column and warned every rebuild. NCAA women's basketball now uses the core's NCAA basketball badge (it named an ncaaw.png nothing ships). The core has no NCAA baseball logo, so that column stays blank; a missing league logo is now warned about once per path, then logged at debug." + }, { "version": "1.5.5", "released": "2026-10-02", diff --git a/plugins/ledmatrix-leaderboard/test_league_logo_paths.py b/plugins/ledmatrix-leaderboard/test_league_logo_paths.py new file mode 100644 index 00000000..0ee62d28 --- /dev/null +++ b/plugins/ledmatrix-leaderboard/test_league_logo_paths.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""League logos: named as the core ships them, and a miss warned about once. + +The Pi's filesystem is case-sensitive. league_config named ``nfl.png``, +``mlb.png``, ``nhl.png`` and ``nba.png`` while the core ships ``NFL.png`` and +friends (its lowercase copies went in core 3.3.0, #506), so on a Pi those +leagues drew a blank logo column -- and because a missing file is never +cached, the warning came back on every rebuild. A Windows checkout found the +files regardless, which is why nobody saw it off the Pi. + +The path check compares against os.listdir(), not os.path.exists(), so it +fails on a case-insensitive filesystem too. It needs a core checkout +(LEDMATRIX_CORE, the working directory, or ../LEDMatrix next to this repo) +and is skipped without one; the warning check runs anywhere. + +Run: python3 plugins/ledmatrix-leaderboard/test_league_logo_paths.py +""" + +import logging +import os +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +try: + from PIL import Image # noqa: F401 +except ImportError: + print("SKIP: Pillow not installed") + sys.exit(2) + +import image_renderer as ir # noqa: E402 +from league_config import LeagueConfig # noqa: E402 + +#: Leagues the core has no logo for. The path stays a drop-in: a file added +#: there is drawn. Anything else must name a file the core ships. +UNSHIPPED_LEAGUE_LOGOS = {"ncaa_baseball"} + +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +def core_checkout(): + for candidate in (os.environ.get("LEDMATRIX_CORE", ""), os.getcwd(), + str(PLUGIN_DIR.parents[2] / "LEDMatrix")): + if candidate and (Path(candidate) / "assets" / "sports").is_dir(): + return Path(candidate) + return None + + +def shipped_exactly(root, rel): + """True only when every path component matches an entry's exact case.""" + current = root + for part in rel.split("/"): + try: + if part not in os.listdir(current): + return False + except OSError: + return False + current = current / part + return True + + +class _Records(logging.Handler): + def __init__(self): + super().__init__(logging.DEBUG) + self.records = [] + + def emit(self, record): + self.records.append(record) + + +def test_missing_logo_warns_once_per_path(): + print("missing league logo is warned about once, not every rebuild") + logger = logging.getLogger("test_league_logo_paths") + logger.setLevel(logging.DEBUG) + logger.propagate = False + handler = _Records() + logger.addHandler(handler) + try: + renderer = ir.ImageRenderer(32, logger) + data = [ + {"league": "nfl", "league_config": {"league_logo": "no/such/nfl.png"}, "teams": []}, + {"league": "mlb", "league_config": {"league_logo": "no/such/mlb.png"}, "teams": []}, + ] + for _ in range(3): # three rebuilds + renderer._build_layout(data) + finally: + logger.removeHandler(handler) + + warnings = [r.getMessage() for r in handler.records + if r.levelno >= logging.WARNING and "League logo" in r.getMessage()] + check("one warning per missing path across three rebuilds", len(warnings) == 2, + "got %d: %r" % (len(warnings), warnings)) + check("each warning names its path", + any("no/such/nfl.png" in w for w in warnings) + and any("no/such/mlb.png" in w for w in warnings), repr(warnings)) + later = [r for r in handler.records + if r.levelno == logging.DEBUG and "still missing" in r.getMessage()] + check("later misses drop to debug", len(later) == 4, "got %d" % len(later)) + + +def test_league_logos_are_shipped_with_exact_case(core): + print("every league logo names a file the core ships, spelled exactly") + leagues = LeagueConfig({}, logging.getLogger("test_league_logo_paths")).league_configs + for key, league in sorted(leagues.items()): + path = league.get("league_logo") + if key in UNSHIPPED_LEAGUE_LOGOS: + continue + check("%s: %s" % (key, path), bool(path) and shipped_exactly(core, path), + "not in %s with this exact case" % core) + check("the unshipped list names real leagues", + UNSHIPPED_LEAGUE_LOGOS <= set(leagues), repr(UNSHIPPED_LEAGUE_LOGOS - set(leagues))) + march = ir.ImageRenderer.MARCH_MADNESS_LOGO_PATH + check("March Madness: %s" % march, shipped_exactly(core, march)) + + +def main(): + test_missing_logo_warns_once_per_path() + core = core_checkout() + if core is None: + print(" SKIP league logo paths: no LEDMatrix core checkout") + else: + test_league_logos_are_shipped_with_exact_case(core) + + if failures: + print("\n%d failure(s)" % len(failures)) + return 1 + print("\nall passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ledmatrix-leaderboard/test_pixel_perfect.py b/plugins/ledmatrix-leaderboard/test_pixel_perfect.py index 838d2bb1..0dfb1d93 100644 --- a/plugins/ledmatrix-leaderboard/test_pixel_perfect.py +++ b/plugins/ledmatrix-leaderboard/test_pixel_perfect.py @@ -54,7 +54,7 @@ def _league_data(logo_dir="assets/sports/nfl_logos"): "league": "nfl", "league_config": { "logo_dir": logo_dir, - "league_logo": os.path.join(logo_dir, "nfl.png") if logo_dir else "", + "league_logo": os.path.join(logo_dir, "NFL.png") if logo_dir else "", }, "teams": _teams(), }] diff --git a/scripts/test_core_asset_paths.py b/scripts/test_core_asset_paths.py new file mode 100644 index 00000000..925e1fbc --- /dev/null +++ b/scripts/test_core_asset_paths.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Every asset path a plugin spells must match the shipped file's case. + +The Pi's filesystem is case-sensitive: ``NFL.png`` and ``nfl.png`` are two +different files there. Windows and default macOS are not, so a path that +differs from the shipped file only in case loads on a dev box, then misses on +every Pi. That is how the leaderboard lost its NFL, MLB and NHL league logos: +it named ``nfl_logos/nfl.png``, the core shipped both spellings until 3.3.0 +dropped the lowercase copies (core #506), and nothing here noticed. The only +symptom was a warning on the Pi every cycle and a blank logo column. + +This scans every ``assets/...`` string literal in plugin code and JSON, and in +the docs render fixtures, and fails when the file exists in the core checkout +(or the plugin's own directory) only under a different case. A path that +exists nowhere is not flagged: plenty are optional drop-ins or are downloaded +at runtime. + +Scoreboard separator icons get a stricter check, because a wrong one fails +silently: each ``*_SEPARATOR_ICON`` must name a file the core ships, or be +declared in UNSHIPPED_SEPARATOR_ICONS as a drop-in nothing ships. + +Needs a core checkout (LEDMATRIX_CORE, or ../LEDMatrix); exits 2 without one. + + python scripts/test_core_asset_paths.py +""" + +import os +import re +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PLUGINS = REPO_ROOT / "plugins" +DOCS_ASSETS = REPO_ROOT / "docs" / "assets" + +#: A quoted asset path. f-strings and os.path.join() pieces do not match, so +#: only literal paths are checked; those are where a misspelling hides. +ASSET_RE = re.compile( + r"""["'](assets/[A-Za-z0-9_.\-/]+\.(?:png|jpe?g|gif|bmp|bdf|ttf|otf))["']""", + re.IGNORECASE) + + +#: Separator icons a scoreboard names but the core does not ship. Each is a +#: drop-in: the league scrolls without a separator until a file is added at +#: that path. A path NOT listed here +#: must name a file the core ships -- hockey named ncaa_hockey.png for years +#: while the core's NCAA hockey badge sat at ncaah.png. An entry that the core +#: starts shipping fails too, so this list only shrinks. +UNSHIPPED_SEPARATOR_ICONS = { + "assets/sports/milb_logos/MiLB.png", # baseball: no MiLB league logo + "assets/sports/ncaa_logos/ncaa_baseball.png", # baseball: NCAA badges are sport-specific + "assets/sports/wnba_logos/WNBA.png", # basketball: no WNBA league logo + "assets/sports/ncaa_logos/NCAA.png", # hockey, lacrosse: no sport-neutral NCAA badge + "assets/sports/ncaa_logos/ncaa_lacrosse.png", # lacrosse: no NCAA lacrosse badge + "assets/sports/ufc_logos/UFC.png", # ufc: generate_placeholder_icon.py writes one +} + +SEPARATOR_RE = re.compile(r"""^\s+\w+_SEPARATOR_ICON\s*=\s*["']([^"']+)["']""", re.M) + + +def core_checkout(): + for candidate in (os.environ.get("LEDMATRIX_CORE", ""), + str(REPO_ROOT.parent / "LEDMatrix")): + if candidate and (Path(candidate) / "assets").is_dir(): + return Path(candidate) + return None + + +def spelled_on_disk(root, rel): + """How ``rel`` is spelled under ``root``. + + Returns ``rel`` when every component matches exactly, the on-disk spelling + when one only matches ignoring case, and None when it is absent. Compares + against os.listdir() rather than calling os.path.exists(), which says yes + to any case on Windows and macOS -- the very blind spot this guards. + """ + current = Path(root) + spelled = [] + for part in rel.split("/"): + try: + entries = os.listdir(current) + except OSError: + return None + if part not in entries: + folded = [e for e in entries if e.lower() == part.lower()] + if not folded: + return None + part = sorted(folded)[0] + spelled.append(part) + current = current / part + return "/".join(spelled) + + +def sources(): + """(file, plugin dir or None) for every file whose literals are checked.""" + for path in sorted(PLUGINS.rglob("*")): + if path.suffix not in (".py", ".json") or "__pycache__" in path.parts: + continue + plugin_dir = PLUGINS / path.relative_to(PLUGINS).parts[0] + yield path, plugin_dir + if DOCS_ASSETS.is_dir(): + for path in sorted(DOCS_ASSETS.rglob("*.json")): + plugin_dir = PLUGINS / path.relative_to(DOCS_ASSETS).parts[0] + yield path, plugin_dir if plugin_dir.is_dir() else None + + +def case_mismatches(core): + """'file:line: path -> shipped spelling' for every miscased literal.""" + problems = [] + for path, plugin_dir in sources(): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for lineno, line in enumerate(text.splitlines(), 1): + for match in ASSET_RE.finditer(line): + rel = match.group(1) + for root in (core, plugin_dir): + if root is None: + continue + on_disk = spelled_on_disk(root, rel) + if on_disk is None: + continue + if on_disk != rel: + where = "core" if root == core else "the plugin" + problems.append( + f"{path.relative_to(REPO_ROOT).as_posix()}:{lineno}: " + f"{rel} -> {where} ships {on_disk}") + break + return problems + + +class SpelledOnDiskTests(unittest.TestCase): + """The resolver itself, on a scratch tree (any filesystem).""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + (self.root / "assets" / "sports" / "nfl_logos").mkdir(parents=True) + (self.root / "assets" / "sports" / "nfl_logos" / "NFL.png").write_bytes(b"") + + def tearDown(self): + self._tmp.cleanup() + + def test_exact_spelling_is_returned_unchanged(self): + rel = "assets/sports/nfl_logos/NFL.png" + self.assertEqual(spelled_on_disk(self.root, rel), rel) + + def test_case_only_difference_reports_the_shipped_spelling(self): + self.assertEqual(spelled_on_disk(self.root, "assets/sports/nfl_logos/nfl.png"), + "assets/sports/nfl_logos/NFL.png") + self.assertEqual(spelled_on_disk(self.root, "assets/Sports/nfl_logos/NFL.png"), + "assets/sports/nfl_logos/NFL.png") + + def test_absent_file_is_none(self): + self.assertIsNone(spelled_on_disk(self.root, "assets/sports/nfl_logos/MiLB.png")) + self.assertIsNone(spelled_on_disk(self.root, "assets/sports/milb_logos/MiLB.png")) + + def test_quoted_literals_are_found_and_templates_are_not(self): + line = ("a = 'assets/sports/nfl_logos/nfl.png'; " + "b = f\"assets/sports/{x}_logos/{y}.png\"; c = \"assets/fonts/4x6-font.ttf\"") + self.assertEqual([m.group(1) for m in ASSET_RE.finditer(line)], + ["assets/sports/nfl_logos/nfl.png", "assets/fonts/4x6-font.ttf"]) + + +class CoreAssetCaseTests(unittest.TestCase): + """Every literal asset path in the repo against a real core checkout.""" + + def test_no_asset_path_differs_from_the_shipped_file_only_in_case(self): + core = core_checkout() + if core is None: + self.skipTest("no LEDMatrix core checkout") + problems = case_mismatches(core) + self.assertEqual(problems, [], "\n" + "\n".join(problems)) + + def test_separator_icons_are_shipped_or_declared_drop_ins(self): + core = core_checkout() + if core is None: + self.skipTest("no LEDMatrix core checkout") + problems = [] + named = set() + for path in sorted(PLUGINS.glob("*/scroll_display.py")): + for match in SEPARATOR_RE.finditer(path.read_text(encoding="utf-8")): + rel = match.group(1) + named.add(rel) + if rel in UNSHIPPED_SEPARATOR_ICONS or spelled_on_disk(core, rel) == rel: + continue + problems.append( + f"{path.relative_to(REPO_ROOT).as_posix()}: {rel} is not in the core; " + f"point it at an icon the core ships, or list it in " + f"UNSHIPPED_SEPARATOR_ICONS and skip it quietly") + for rel in sorted(UNSHIPPED_SEPARATOR_ICONS): + if spelled_on_disk(core, rel) is not None: + problems.append(f"{rel} now ships with the core; drop it from " + f"UNSHIPPED_SEPARATOR_ICONS") + elif rel not in named: + problems.append(f"{rel} is in UNSHIPPED_SEPARATOR_ICONS but no " + f"scroll_display.py names it any more") + self.assertEqual(problems, [], "\n" + "\n".join(problems)) + + +if __name__ == "__main__": + if core_checkout() is None: + print("SKIP: no LEDMatrix core checkout (set LEDMATRIX_CORE or clone ../LEDMatrix)") + sys.exit(2) + unittest.main()