From 7636d4d6d19a8fea5ce5fa2056f7a255221c75d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 22:22:33 -0400 Subject: [PATCH 1/2] ci: run the plugins' own tests, and repair the ones that could not pass This workflow was the safety harness plus manifest checks, so a plugin's test_*.py guarded nothing unless someone ran it by hand. That is how a display() returning None on every path survived from February to August (#263): the harness renders screens and cannot see a return value, and its fixtures seed data so an empty-state path never executes. scripts/run_plugin_tests.py already existed, with the exit-code convention that makes this gateable -- 2 for "prerequisites absent", 1 for a real failure. It was simply never wired up. Baseline across the fleet was 75 passed, 2 skipped, 9 failed, which is why: nine reds nobody could act on are not a gate, they are noise. All nine were stale tests or environment handling, not plugin bugs: baseball asserted plugin.initialized and iterated plugin.leagues, neither of which exists; its config was flat (mlb_enabled) where the plugin reads nested (config["mlb"]["enabled"]), so it ran with every league disabled and passed vacuously baseball the antialiasing test only found the core fonts when the plugin sat inside a core checkout, so it always skipped basketball imported BasketballPluginManager, a name the plugin had stopped using, from a hardcoded /home/chuck path; it now reads class_name from the manifest, which is what the loader uses, so a rename cannot pass unnoticed basketball MockLogger lacked setLevel and %-formatting, so real code under test raised inside the stub basketball its mock competitors carried a score but no team block or competitor id, which the extractor needs before it ever looks at a score -- so all five score formats reported "could not extract" for an unrelated reason hockey constructed CacheManager(config_manager=...), removed from the core's signature, and resolved config/ relative to the plugin dir; it now mirrors the pre-flight in the baseball suite and runs 29 real render cycles hockey test_recent_games.py deleted: a print-only script with zero assertions, a hardcoded /home/chuck path and a hardcoded debug date, broken since the monorepo migration lacrosse asked ESPN for the upcoming season, which is unpublished in August; it now falls back to the last completed season and skips, rather than fails, if neither has fixtures soccer asserted a bare "MCI" cache key after logo caching became size-scoped ("MCI@32x32") to stop panel sizes colliding weather almanac fonts now resolve via LEDMATRIX_CORE (so it runs instead of failing), and astral's absence is a skip, since the plugin declares it and CI installs it Now 83 passed, 2 skipped, 0 failed. The two skips are honest: one script prompts on stdin, and astral is absent locally but present in CI. The changed-plugin detection now yields two lists. Shipped-code changes still drive the version gate and harness; test-only edits no longer do, which the existing test/ exclusion had always intended but missed for the root-level test_*.py where most of these live. A second list including tests drives the unit-test run, so a PR that only edits a test still has to prove it passes. Also gitignores emulator_config.json, which RGBMatrixEmulator drops into whatever directory the suites are invoked from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- .github/workflows/test-plugins.yml | 67 +++++++++- .gitignore | 6 + .../test_baseball_plugin.py | 108 +++++++++------- .../test_score_antialiasing.py | 8 ++ .../test_plugin_syntax.py | 74 +++++++---- .../test_score_fix_verification.py | 58 ++++++++- .../hockey-scoreboard/test_hockey_emulator.py | 34 ++++- .../hockey-scoreboard/test_recent_games.py | 116 ------------------ .../test_lacrosse_plugin.py | 68 +++++++--- .../ledmatrix-weather/test_almanac_layout.py | 29 ++++- .../test_almanac_moon_data.py | 10 +- .../soccer-scoreboard/test_live_screens.py | 8 +- 12 files changed, 367 insertions(+), 219 deletions(-) delete mode 100644 plugins/hockey-scoreboard/test_recent_games.py diff --git a/.github/workflows/test-plugins.yml b/.github/workflows/test-plugins.yml index 4c7cbc9e..0aab056a 100644 --- a/.github/workflows/test-plugins.yml +++ b/.github/workflows/test-plugins.yml @@ -59,16 +59,34 @@ jobs: run: | if [ "$ALL_INPUT" = "true" ]; then ids=$(ls plugins) + all_ids="$ids" else - # Plugins with a changed file outside their test/ dir. + # Two lists, because the consumers want different things. + # + # ids -- plugins whose *shipped* code changed. Drives the version + # bump gate and the safety harness. Test-only edits are excluded: + # they change nothing a user receives, so demanding a version bump + # for them is noise. The root-level test_*.py exclusion matters as + # much as the test/ one -- most plugins keep their tests there, so + # without it "test-only change" was never actually true. ids=$(git diff --name-only "$BASE_SHA"...HEAD -- 'plugins/*' \ | grep -vE '^plugins/[^/]+/test/' \ + | grep -vE '^plugins/[^/]+/test_[^/]+\.py$' \ + | sed -E 's#^plugins/([^/]+)/.*#\1#' | sort -u) + # all_ids -- any change at all, tests included. Drives the unit-test + # run: a PR that only edits a test still has to prove the test + # passes, which is exactly when it most needs checking. + all_ids=$(git diff --name-only "$BASE_SHA"...HEAD -- 'plugins/*' \ | sed -E 's#^plugins/([^/]+)/.*#\1#' | sort -u) fi echo "ids<> "$GITHUB_OUTPUT" echo "$ids" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" - echo "Changed plugins:"; echo "$ids" + echo "all_ids<> "$GITHUB_OUTPUT" + echo "$all_ids" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + echo "Changed plugins (shipped code):"; echo "$ids" + echo "Changed plugins (including tests):"; echo "$all_ids" - name: Enforce version bump on changed plugins if: steps.changed.outputs.ids != '' && github.event.inputs.all != 'true' @@ -175,6 +193,49 @@ jobs: fi exit $fail + # The plugins' own test_*.py. Nothing ran these until now: this workflow + # was the harness plus manifest checks, so a plugin unit test guarded + # nothing unless somebody ran it by hand. That is how a display() that + # returned None on every path survived from February to August -- the + # harness renders screens and cannot see a return value, and its fixtures + # seed data so an empty-state path never executes. + # + # Runs with if: always() so a harness failure does not hide these, and + # vice versa -- one PR should surface every problem it has. + # + # run_plugin_tests.py separates "prerequisites absent" (exit 2, reported + # as a skip) from a real failure, which is what makes this gateable: a + # script wanting a tty or an out-of-season feed no longer looks like a + # regression. Adding a plugin test that cannot pass here will now fail + # CI, so make it skip deliberately rather than leaving it red. + - name: Run plugin unit tests on changed plugins + if: always() && steps.changed.outputs.all_ids != '' + working-directory: plugins-repo + env: + IDS: ${{ steps.changed.outputs.all_ids }} + run: | + set -e + present="" + for pid in $IDS; do + case "$pid" in + '' | *[!a-z0-9._-]*) echo "::error::invalid plugin id (redacted)"; exit 1 ;; + esac + # A plugin deleted in this PR is still a "changed" id; the runner + # treats an unknown id as an error, so drop it here. + [ -d "plugins/$pid" ] || { echo "::notice::$pid removed, skipping"; continue; } + # The harness step installs these too, but this step runs even when + # that one failed, so it cannot rely on having got that far. + if [ -f "plugins/$pid/requirements.txt" ]; then + pip install -r "plugins/$pid/requirements.txt" + fi + present="$present $pid" + done + if [ -z "$present" ]; then + echo "No changed plugin still present; nothing to test." + exit 0 + fi + python scripts/run_plugin_tests.py $present --core "$GITHUB_WORKSPACE/core" + - name: Nothing to check if: steps.changed.outputs.ids == '' - run: echo "No plugin code changed (test-only or docs change) — safety harness skipped." + run: echo "No shipped plugin code changed (test-only or docs change) — version gate and safety harness skipped; plugin unit tests still ran if any test changed." diff --git a/.gitignore b/.gitignore index b67bf26f..973c7a2a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,9 @@ plugins/**/config_secrets.json # Asset backups (local working copies) plugins/*/assets/**/*_backup/ + +# RGBMatrixEmulator writes this into the working directory on first run, +# so the plugin test suites drop one wherever they are invoked from. +# ledmatrix-music/emulator_config.json is committed on purpose and stays +# tracked -- .gitignore does not apply to files already in the index. +emulator_config.json diff --git a/plugins/baseball-scoreboard/test_baseball_plugin.py b/plugins/baseball-scoreboard/test_baseball_plugin.py index 56963280..e0fa91b1 100755 --- a/plugins/baseball-scoreboard/test_baseball_plugin.py +++ b/plugins/baseball-scoreboard/test_baseball_plugin.py @@ -80,28 +80,38 @@ def create_mock_cache_manager(): def create_test_config() -> Dict[str, Any]: - """Create test configuration for baseball scoreboard plugin.""" + """Test configuration for the baseball scoreboard plugin. + + Nested per league, which is the shape the plugin reads + (``config["mlb"]["enabled"]``, see manager.py). It used to be flat + (``mlb_enabled``), which the plugin never looked at, so every league + resolved to disabled and the suite reported success while exercising + almost nothing -- "Enabled leagues: []" on a passing run. + + MLB on, the other two off: one enabled league is enough to drive the + update / display / info path for real, and the whole suite still finishes + in about 25s. + """ return { - 'mlb_enabled': True, - 'mlb_favorite_teams': ['TEX', 'NYM'], - 'mlb_display_modes_live': True, - 'mlb_display_modes_recent': True, - 'mlb_display_modes_upcoming': True, - 'mlb_live_priority': True, - 'mlb_live_update_interval': 15, - 'mlb_recent_update_interval': 3600, - 'mlb_upcoming_update_interval': 3600, - 'mlb_recent_games_to_show': 5, - 'mlb_upcoming_games_to_show': 10, - 'mlb_show_records': False, - 'mlb_show_ranking': False, - 'mlb_show_odds': False, - 'mlb_test_mode': False, # Set to True for test mode - - 'milb_enabled': False, - 'ncaa_baseball_enabled': False, - + 'enabled': True, 'display_duration': 15, + 'mlb': { + 'enabled': True, + 'favorite_teams': ['TEX', 'NYM'], + 'display_modes': { + 'show_live': True, + 'show_recent': True, + 'show_upcoming': True, + }, + 'live_priority': True, + 'recent_games_to_show': 5, + 'upcoming_games_to_show': 10, + 'show_records': False, + 'show_ranking': False, + 'show_odds': False, + }, + 'milb': {'enabled': False}, + 'ncaa_baseball': {'enabled': False}, } @@ -153,13 +163,22 @@ def __init__(self): plugin_manager=plugin_manager ) - if plugin.initialized: - print("[OK] Plugin initialized successfully") - print(f" Enabled leagues: {[k for k, v in plugin.leagues.items() if v.get('enabled', False)]}") - return plugin - else: - print("[FAIL] Plugin initialization failed") + # Construction not raising is the real signal. This used to gate on + # `plugin.initialized`, an attribute neither this plugin nor BasePlugin + # has ever defined, so the check raised AttributeError and reported + # "initialization error" on a plugin that had in fact just initialised + # fine. Assert the surface the rest of this suite goes on to use. + missing = [name for name in ("display", "update", "_league_registry") + if not hasattr(plugin, name)] + if missing: + print(f"[FAIL] Plugin is missing expected attributes: {missing}") return None + + enabled = [k for k, v in plugin._league_registry.items() + if v.get("enabled", False)] + print("[OK] Plugin initialized successfully") + print(f" Enabled leagues: {enabled}") + return plugin except Exception as e: print(f"[FAIL] Plugin initialization error: {e}") @@ -179,17 +198,22 @@ def test_plugin_update(plugin): plugin.update() print("[OK] Plugin update completed") - # Check league states - for league_key, league_config in plugin.leagues.items(): - if league_config.get('enabled', False): - live_state = plugin.league_state[league_key]['live'] - recent_state = plugin.league_state[league_key]['recent'] - upcoming_state = plugin.league_state[league_key]['upcoming'] - - print(f" {league_key}:") - print(f" Live games: {len(live_state['games_list'])}") - print(f" Recent games: {len(recent_state['games_list'])}") - print(f" Upcoming games: {len(upcoming_state['games_list'])}") + # Report what each enabled league loaded. This used to read + # plugin.leagues / plugin.league_state, neither of which the plugin has + # -- the games are reachable through the per-mode managers held in + # _league_registry, so the whole block raised AttributeError and + # reported an update failure on an update that had succeeded. + for league_key, entry in plugin._league_registry.items(): + if not entry.get('enabled', False): + continue + print(f" {league_key}:") + for mode in ('live', 'recent', 'upcoming'): + mgr = entry.get('managers', {}).get(mode) + if mgr is None: + print(f" {mode.capitalize()} games: (no manager)") + continue + games = getattr(mgr, 'games_list', None) or [] + print(f" {mode.capitalize()} games: {len(games)}") return True except Exception as e: @@ -269,11 +293,11 @@ def run_emulator_test(plugin, duration=30): if cycle_count % 5 == 0: print(f" Cycle {cycle_count}: {elapsed:.1f}s elapsed") # Print current game info - for league_key, league_config in plugin.leagues.items(): - if league_config.get('enabled', False): - live_state = plugin.league_state[league_key]['live'] - if live_state['current_game']: - game = live_state['current_game'] + for league_key, entry in plugin._league_registry.items(): + if entry.get('enabled', False): + live_mgr = entry.get('managers', {}).get('live') + game = getattr(live_mgr, 'current_game', None) + if game: print(f" {league_key} live: {game.get('away_abbr', '?')} @ {game.get('home_abbr', '?')}") # Short delay between cycles diff --git a/plugins/baseball-scoreboard/test_score_antialiasing.py b/plugins/baseball-scoreboard/test_score_antialiasing.py index 3f6e31f3..49d0a136 100644 --- a/plugins/baseball-scoreboard/test_score_antialiasing.py +++ b/plugins/baseball-scoreboard/test_score_antialiasing.py @@ -35,6 +35,14 @@ def _find_pixel_font(): """ name = "PressStart2P-Regular.ttf" candidates = [] + # LEDMATRIX_CORE is the runner's contract for "the core is here", and it is + # absolute. Walking up from the plugin only finds the fonts when the plugin + # happens to sit inside a core checkout (LEDMatrix/plugin-repos//); from + # a standalone plugins repo it never does, so this skipped every run even + # when a core was supplied. + core = os.environ.get("LEDMATRIX_CORE") + if core: + candidates.append(os.path.join(core, "assets", "fonts", name)) d = PLUGIN_DIR for _ in range(6): candidates.append(os.path.join(d, "assets", "fonts", name)) diff --git a/plugins/basketball-scoreboard/test_plugin_syntax.py b/plugins/basketball-scoreboard/test_plugin_syntax.py index 7d176604..c120a315 100644 --- a/plugins/basketball-scoreboard/test_plugin_syntax.py +++ b/plugins/basketball-scoreboard/test_plugin_syntax.py @@ -1,35 +1,65 @@ #!/usr/bin/env python3 -""" -Simple syntax checker for the basketball plugin. -Tests that the plugin can be imported without errors. +"""Smoke test: the entry point imports and declares the class the manifest names. + +Cheap, but it catches the two failures that stop a plugin loading at all -- +a syntax error, and a manifest whose ``class_name`` no longer matches the code. +The second is not hypothetical: this file used to import a hardcoded +``BasketballPluginManager``, a name the plugin had long since stopped using, so +it failed on every run for a reason that had nothing to do with the plugin. It +now reads the name from the manifest, which is the thing the loader actually +uses, so a rename is either reflected in both places or caught here. + +Needs the core on the path for ``src.plugin_system.base_plugin``; run via +scripts/run_plugin_tests.py, which supplies it. + +Exit codes: 0 pass, 2 skip (no core), 1 fail. """ -import sys +import json import os +import sys +from pathlib import Path -# Set emulator mode before any imports -os.environ['EMULATOR'] = 'true' +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) -# Add LEDMatrix to path -sys.path.insert(0, '/home/chuck/Github/LEDMatrix') +# Emulator mode must be set before the plugin imports anything from the core. +os.environ.setdefault("EMULATOR", "true") + +core = os.environ.get("LEDMATRIX_CORE") +if core: + sys.path.insert(0, core) + +try: + import src.plugin_system.base_plugin # noqa: F401 +except ImportError as exc: + print(f"SKIP: needs a LEDMatrix core on the path ({exc}); " + f"set LEDMATRIX_CORE or run via scripts/run_plugin_tests.py --core") + sys.exit(2) + +manifest = json.loads((PLUGIN_DIR / "manifest.json").read_text(encoding="utf-8")) +class_name = manifest["class_name"] +entry_point = manifest.get("entry_point", "manager.py") +module_name = entry_point[:-3] if entry_point.endswith(".py") else entry_point try: - # Try to import the plugin - from manager import BasketballPluginManager - print("✓ Plugin imported successfully") - print(f"✓ Class name: {BasketballPluginManager.__name__}") - print(f"✓ Base classes: {BasketballPluginManager.__bases__}") - print("\nPlugin structure is valid!") - sys.exit(0) - + module = __import__(module_name) except SyntaxError as e: - print(f"✗ Syntax error: {e}") + print(f"FAIL: syntax error in {entry_point}: {e}") sys.exit(1) except ImportError as e: - print(f"✗ Import error: {e}") + print(f"FAIL: {entry_point} could not be imported: {e}") sys.exit(1) -except Exception as e: - print(f"✗ Error: {e}") - import traceback - traceback.print_exc() + +cls = getattr(module, class_name, None) +if cls is None: + available = sorted( + n for n, v in vars(module).items() if isinstance(v, type) + and v.__module__ == module.__name__) + print(f"FAIL: manifest class_name is {class_name!r}, but {entry_point} " + f"defines no such class. It defines: {', '.join(available) or '(none)'}") sys.exit(1) + +print(f"PASS: {entry_point} imports and defines {class_name}") +print(f" base classes: {', '.join(b.__name__ for b in cls.__bases__)}") +sys.exit(0) diff --git a/plugins/basketball-scoreboard/test_score_fix_verification.py b/plugins/basketball-scoreboard/test_score_fix_verification.py index ed305ca8..8ba66dfd 100644 --- a/plugins/basketball-scoreboard/test_score_fix_verification.py +++ b/plugins/basketball-scoreboard/test_score_fix_verification.py @@ -16,11 +16,46 @@ # Mock logger class MockLogger: - def debug(self, msg): + """Stands in for the core logger. + + Accepts *args/**kwargs and the configuration methods because production + code calls this like a real ``logging.Logger`` -- lazy `%s` formatting, + `setLevel`, `exc_info=True`. A stub that only took `(self, msg)` turned any + such call into an AttributeError or TypeError inside the code under test, + which read as a plugin failure rather than a gap in the stub. + """ + + def debug(self, msg, *args, **kwargs): + pass + + def info(self, msg, *args, **kwargs): + pass + + def warning(self, msg, *args, **kwargs): + print("WARNING: " + (msg % args if args else str(msg))) + + def error(self, msg, *args, **kwargs): + print("ERROR: " + (msg % args if args else str(msg))) + + def exception(self, msg, *args, **kwargs): + self.error(msg, *args, **kwargs) + + def critical(self, msg, *args, **kwargs): + self.error(msg, *args, **kwargs) + + def log(self, level, msg, *args, **kwargs): + pass + + def setLevel(self, level): pass - def warning(self, msg): - print(f"WARNING: {msg}") - def info(self, msg): + + def isEnabledFor(self, level): + return False + + def addHandler(self, handler): + pass + + def removeHandler(self, handler): pass def test_score_extraction_with_mock_api_data(): @@ -126,8 +161,21 @@ def _fetch_data(self): # Create a mock game event game_event = { "id": f"test_{i}", + # Each competitor needs a `team` block: the extractor reads + # team.abbreviation (falling back to team.name) before it ever + # looks at the score, so a competitor carrying only a score + # makes extraction return None and every case below fail for a + # reason that has nothing to do with score parsing -- which is + # the only thing this test is about. "competitions": [{ - "competitors": [test_case["home_team"], test_case["away_team"]], + "competitors": [ + {"id": "1", **test_case["home_team"], + "team": {"abbreviation": "HOM", "name": "Home Team", + "displayName": "Home Team", "id": "1"}}, + {"id": "2", **test_case["away_team"], + "team": {"abbreviation": "AWY", "name": "Away Team", + "displayName": "Away Team", "id": "2"}}, + ], "status": { "type": { "name": "STATUS_FINAL", diff --git a/plugins/hockey-scoreboard/test_hockey_emulator.py b/plugins/hockey-scoreboard/test_hockey_emulator.py index 36d81d0e..b3496e94 100755 --- a/plugins/hockey-scoreboard/test_hockey_emulator.py +++ b/plugins/hockey-scoreboard/test_hockey_emulator.py @@ -72,12 +72,15 @@ def create_cache_manager(): try: from src.cache_manager import CacheManager from src.config_manager import ConfigManager - - # Create config manager - config_manager = ConfigManager() - - # Create cache manager - cache_manager = CacheManager(config_manager=config_manager) + + # CacheManager takes no arguments on a current core; it used to be + # constructed with config_manager=. Passing it raised a TypeError that + # aborted this script before its first real check. Attach the config + # manager afterwards, which is where plugins read it from + # (cache_manager.config_manager), and only when the core still wants it. + cache_manager = CacheManager() + if not hasattr(cache_manager, "config_manager"): + cache_manager.config_manager = ConfigManager() print("[OK] Created cache manager") return cache_manager except Exception as e: @@ -323,6 +326,25 @@ def main(): if __name__ == "__main__": + # Pre-flight, mirroring test_baseball_plugin.py. This script drives the + # core's DisplayManager, CacheManager and ConfigManager, all of which + # resolve config/ and assets/ relative to the working directory -- and the + # runner starts us in the plugin directory. Without this it died on a + # missing config template before reaching a single real check, which is + # indistinguishable from a regression at a glance. + # + # Skip (exit 2) rather than fail (exit 1) when there is no core: reporting + # "not applicable" as a regression is what trains people to ignore results. + _core_root = os.environ.get("LEDMATRIX_CORE", "") + if not os.path.exists(os.path.join(_core_root, "config", "config.template.json")): + print("SKIP: needs the core config template — set LEDMATRIX_CORE or " + "run from a LEDMatrix checkout (looked in %s)" + % (_core_root or os.getcwd())) + sys.exit(2) + + # The plugin itself stays importable via the sys.path entry set above. + os.chdir(_core_root) + try: exit_code = main() sys.exit(exit_code) diff --git a/plugins/hockey-scoreboard/test_recent_games.py b/plugins/hockey-scoreboard/test_recent_games.py deleted file mode 100644 index 16e775f2..00000000 --- a/plugins/hockey-scoreboard/test_recent_games.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify recent games filtering is working correctly -""" - -import sys -sys.path.append('/home/chuck/Github/LEDMatrix/src') - -from data_fetcher import HockeyDataFetcher -from cache_manager import CacheManager -from game_filter import HockeyGameFilter -import logging - -# Set up logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Initialize components -cache_manager = CacheManager() -data_fetcher = HockeyDataFetcher(cache_manager, logger) -game_filter = HockeyGameFilter(logger) - -# Fetch NHL data -print("Fetching NHL data...") -nhl_config = { - 'enabled': True, - 'update_interval_seconds': 60, - 'request_timeout': 30, - 'recent_games_to_show': 50, - 'favorite_teams': ['TB'], - 'favorite_teams_only': True, - 'display_modes': { - 'live': True, - 'recent': True, - 'upcoming': True - } -} - -games = data_fetcher.fetch_league_data('nhl', nhl_config) -print(f"Total NHL games: {len(games)}") - -# Add league config to games -for game in games: - game['league_config'] = nhl_config - game['league'] = 'nhl' # Set the league field - -# Debug: Check the league config -print(f"League config: {nhl_config}") -print(f"Display modes: {nhl_config.get('display_modes', {})}") -print(f"Mode enabled check: {nhl_config.get('display_modes', {}).get('recent', False)}") - -# Debug: Check what games have post status -post_games = [g for g in games if g.get('status', {}).get('state') == 'post'] -print(f"Games with post status: {len(post_games)}") - -# Debug: Check TB games with post status -tb_post_games = [g for g in games if g.get('status', {}).get('state') == 'post' and 'TB' in [g.get('home_team', {}).get('abbrev', ''), g.get('away_team', {}).get('abbrev', '')]] -print(f"TB games with post status: {len(tb_post_games)}") - -# Show first few TB post games -for i, game in enumerate(tb_post_games[:5]): - home_team = game.get('home_team', {}).get('abbrev', 'UNK') - away_team = game.get('away_team', {}).get('abbrev', 'UNK') - start_time = game.get('start_time', '') - status = game.get('status', {}).get('state', '') - print(f" {i+1}. {away_team} @ {home_team} ({start_time[:10]}) - {status}") - -# Filter for recent games (using granular mode name) -recent_games = game_filter.filter_games_by_mode(games, 'recent') # Updated to use mode type -print(f"Recent games after mode filtering: {len(recent_games)}") - -# Apply favorite teams filter -favorite_games = game_filter.filter_favorite_teams_only(recent_games, True) -print(f"Favorite teams games: {len(favorite_games)}") - -# Sort games (updated to use mode type) -sorted_games = game_filter.sort_games(favorite_games, 'recent') -print(f"Final sorted games: {len(sorted_games)}") - -# Show the first few recent TB games -print("\nRecent TB games (should show most recent first):") -for i, game in enumerate(sorted_games[:5]): - home_team = game.get('home_team', {}).get('abbrev', 'UNK') - away_team = game.get('away_team', {}).get('abbrev', 'UNK') - start_time = game.get('start_time', '') - status = game.get('status', {}).get('state', '') - print(f" {i+1}. {away_team} @ {home_team} ({start_time[:10]}) - {status}") - -# Show all recent games before favorite teams filter -print(f"\nAll recent games before favorite teams filter: {len(recent_games)}") -for i, game in enumerate(recent_games[:10]): - home_team = game.get('home_team', {}).get('abbrev', 'UNK') - away_team = game.get('away_team', {}).get('abbrev', 'UNK') - start_time = game.get('start_time', '') - status = game.get('status', {}).get('state', '') - print(f" {i+1}. {away_team} @ {home_team} ({start_time[:10]}) - {status}") - -# Check if 10/18/2025 game is in recent games -oct_18_in_recent = [g for g in recent_games if '2025-10-18' in g.get('start_time', '')] -print(f"\n10/18/2025 games in recent: {len(oct_18_in_recent)}") -for game in oct_18_in_recent: - home_team = game.get('home_team', {}).get('abbrev', 'UNK') - away_team = game.get('away_team', {}).get('abbrev', 'UNK') - start_time = game.get('start_time', '') - status = game.get('status', {}).get('state', '') - print(f" {away_team} @ {home_team} ({start_time}) - {status}") - -# Check specifically for 10/18/2025 game -oct_18_games = [g for g in sorted_games if '2025-10-18' in g.get('start_time', '')] -print(f"\nGames on 2025-10-18: {len(oct_18_games)}") -for game in oct_18_games: - home_team = game.get('home_team', {}).get('abbrev', 'UNK') - away_team = game.get('away_team', {}).get('abbrev', 'UNK') - start_time = game.get('start_time', '') - status = game.get('status', {}).get('state', '') - print(f" {away_team} @ {home_team} ({start_time}) - {status}") diff --git a/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py index 038deef6..2ddd8ebe 100644 --- a/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py +++ b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py @@ -112,6 +112,14 @@ def test_rankings_resolver() -> None: print(f" [ok] dynamic resolver — men={men[:3]}..., women={women[:3]}...") +class _NoFixtureData(Exception): + """ESPN answered, but had nothing scheduled in any window we asked for. + + Distinct from _NetworkUnavailable: the API is reachable and behaving, there + is just no data this time of year. Neither is a plugin failure. + """ + + class _NetworkUnavailable(Exception): """Raised by a test when it detects the network/external feed is down.""" @@ -166,14 +174,28 @@ def _fetch_data(self): return inst -def test_extraction(label: str, league_slug: str, date_window: str) -> None: - url = ( - f"https://site.api.espn.com/apis/site/v2/sports/lacrosse/" - f"{league_slug}/scoreboard?dates={date_window}&limit=50" - ) - data = _fetch(url) - events = data.get("events", []) - assert events, f"{label}: no events returned by ESPN for {date_window}" +def test_extraction(label: str, league_slug: str, date_windows) -> None: + tried = [] + events = [] + for date_window in date_windows: + url = ( + f"https://site.api.espn.com/apis/site/v2/sports/lacrosse/" + f"{league_slug}/scoreboard?dates={date_window}&limit=50" + ) + data = _fetch(url) + events = data.get("events", []) + tried.append(f"{date_window} ({len(events)} events)") + if events: + break + + if not events: + # Every window came back empty. ESPN answered, so this is not a + # network problem and not a plugin fault -- there is simply nothing + # scheduled to extract. Skip rather than fail: a red test nobody can + # act on is how a real regression gets ignored. + raise _NoFixtureData( + f"{label}: ESPN returned no events for any window tried: " + f"{', '.join(tried)}") inst = _make_test_instance() extracted = 0 @@ -204,22 +226,28 @@ def test_extraction(label: str, league_slug: str, date_window: str) -> None: # --------------------------------------------------------------------------- # Runner # --------------------------------------------------------------------------- -def _build_season_window() -> str: - """Build a rolling scoreboard date window for the current season. - - NCAA lacrosse runs January through late May. From January through June we - query the current calendar year; from July onward we query the upcoming - season. Returned format is 'YYYYMMDD-YYYYMMDD' as ESPN expects. +def _season_windows() -> list: + """Scoreboard date windows to try, most relevant first. + + NCAA lacrosse runs January through late May, so from July onward the + interesting season is next year's. ESPN does not publish that schedule + months ahead, though: asking in August returns a perfectly valid response + with zero events, which is not a plugin fault but used to fail this test + every summer. So the upcoming season is tried first and the last completed + one is the fallback -- what these tests actually need is some real events + to drive the extraction pipeline, and either season provides them. + + Returned format is 'YYYYMMDD-YYYYMMDD' as ESPN expects. """ now = datetime.now() - year = now.year if now.month < 7 else now.year + 1 - return f"{year}0101-{year}0601" + upcoming = now.year if now.month < 7 else now.year + 1 + return [f"{year}0101-{year}0601" for year in (upcoming, upcoming - 1)] def main() -> int: print("Lacrosse Scoreboard plugin — smoke test") - season_window = _build_season_window() + season_windows = _season_windows() tests = [ ("imports", test_imports, ()), @@ -227,12 +255,12 @@ def main() -> int: ( "men's extraction", test_extraction, - ("men's", "mens-college-lacrosse", season_window), + ("men's", "mens-college-lacrosse", season_windows), ), ( "women's extraction", test_extraction, - ("women's", "womens-college-lacrosse", season_window), + ("women's", "womens-college-lacrosse", season_windows), ), ] @@ -251,6 +279,8 @@ def main() -> int: except AssertionError as e: print(f" [FAIL] {name}: {e}") failed += 1 + except _NoFixtureData as e: + print(f" [skip] {name}: {e}") except _NetworkUnavailable as e: print(f" [skip] {name}: {e}") except network_errors as e: diff --git a/plugins/ledmatrix-weather/test_almanac_layout.py b/plugins/ledmatrix-weather/test_almanac_layout.py index f102287c..a236e2ba 100644 --- a/plugins/ledmatrix-weather/test_almanac_layout.py +++ b/plugins/ledmatrix-weather/test_almanac_layout.py @@ -20,13 +20,26 @@ from manager import WeatherPlugin # noqa: E402 +class _FontsUnavailable(Exception): + """The core's production fonts are not on this machine.""" + + def _font(name, size): - for base in ("assets/fonts", "../assets/fonts", - os.path.join(os.path.dirname(__file__), "assets/fonts")): + # LEDMATRIX_CORE is the runner's contract for "here is the core", and it is + # absolute -- the relative paths below only resolve when the process + # happens to start in the right directory, which is why this used to fail + # rather than skip whenever it ran from the plugin dir. + core = os.environ.get("LEDMATRIX_CORE") + bases = ["assets/fonts", "../assets/fonts", + os.path.join(os.path.dirname(__file__), "assets/fonts")] + if core: + bases.insert(0, os.path.join(core, "assets", "fonts")) + for base in bases: p = os.path.join(base, name) if os.path.exists(p): return ImageFont.truetype(p, size) - raise FileNotFoundError(f"{name} not found; run from a LEDMatrix checkout") + raise _FontsUnavailable( + f"{name} not found (looked in: {', '.join(bases)})") class _FakeMatrix: @@ -290,4 +303,12 @@ def main(): if __name__ == "__main__": - main() + try: + main() + except _FontsUnavailable as exc: + # Exit 2 is the runner's "prerequisites absent" code. These are the + # core's production fonts, deliberately not bundled with the plugin, so + # their absence says nothing about the plugin -- and a failure nobody + # can act on is how a real one gets ignored. + print(f"SKIP: {exc}; set LEDMATRIX_CORE or run from a LEDMatrix checkout") + sys.exit(2) diff --git a/plugins/ledmatrix-weather/test_almanac_moon_data.py b/plugins/ledmatrix-weather/test_almanac_moon_data.py index 126c50e0..c9327abc 100644 --- a/plugins/ledmatrix-weather/test_almanac_moon_data.py +++ b/plugins/ledmatrix-weather/test_almanac_moon_data.py @@ -26,7 +26,15 @@ sys.path.insert(0, os.path.dirname(__file__)) from manager import WeatherPlugin # noqa: E402 -from astral import moon as astral_moon, Observer # noqa: E402 +try: + from astral import moon as astral_moon, Observer # noqa: E402 +except ImportError: + # Declared in the plugin's requirements.txt, so CI installs it and this + # runs for real there. Locally it is often absent; exit 2 is the runner's + # "prerequisites absent" code, which keeps that distinct from a failure. + print("SKIP: astral not installed (see plugins/ledmatrix-weather/" + "requirements.txt); pip install astral to run this test") + sys.exit(2) # Federal Way, WA — the board's configured location. diff --git a/plugins/soccer-scoreboard/test_live_screens.py b/plugins/soccer-scoreboard/test_live_screens.py index 7b1be988..3397b332 100644 --- a/plugins/soccer-scoreboard/test_live_screens.py +++ b/plugins/soccer-scoreboard/test_live_screens.py @@ -133,7 +133,13 @@ def test_scroll_passes_real_logo_cache_to_renderer(): assert ok, "prepare_scroll_content returned False (no cards rendered)" # A correct call shares the dict cache, so loaded logos land in it. The bug # passed plugin_dir instead, leaving this dict untouched. - assert "MCI" in sd._logo_cache and "LIV" in sd._logo_cache, ( + # + # Match on the team rather than the whole key: GameRenderer._logo_cache_key + # scopes entries by slot size ("MCI@32x32") so one team cached at two panel + # sizes cannot collide. What this test guards is that the shared dict is + # populated at all, which the size suffix does not change. + cached_teams = {key.split("@", 1)[0] for key in sd._logo_cache} + assert {"MCI", "LIV"} <= cached_teams, ( f"logos not loaded into shared cache: {list(sd._logo_cache)}" ) print("PASS: scroll passes a real dict logo cache (logos load, no placeholder)") From 2cc2f6b0b1732d1cc58cc4aebf52ad2b21eb9045 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 22:31:09 -0400 Subject: [PATCH 2/2] test: assert the baseball config lands, and narrow two import skips Two review findings, both about a check that could pass without meaning anything -- which is the failure this PR exists to remove. The baseball suite printed which leagues were enabled but never asserted it. That is exactly how it came to run green over a plugin with every league disabled, so a silent return to the flat-config state would have gone unnoticed again. Assert MLB is the only one enabled. The two skip guards caught ImportError broadly, so a ModuleNotFoundError raised from *inside* the core, or from inside astral, would have been filed as "not installed" and skipped. Match on exc.name and re-raise anything else: a genuine breakage should be a failure, not a skip nobody reads. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins/baseball-scoreboard/test_baseball_plugin.py | 13 +++++++++++-- plugins/basketball-scoreboard/test_plugin_syntax.py | 8 +++++++- plugins/ledmatrix-weather/test_almanac_moon_data.py | 6 +++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/plugins/baseball-scoreboard/test_baseball_plugin.py b/plugins/baseball-scoreboard/test_baseball_plugin.py index e0fa91b1..85afbc90 100755 --- a/plugins/baseball-scoreboard/test_baseball_plugin.py +++ b/plugins/baseball-scoreboard/test_baseball_plugin.py @@ -174,8 +174,17 @@ def __init__(self): print(f"[FAIL] Plugin is missing expected attributes: {missing}") return None - enabled = [k for k, v in plugin._league_registry.items() - if v.get("enabled", False)] + enabled = sorted(k for k, v in plugin._league_registry.items() + if v.get("enabled", False)) + # Assert, don't just report. The config used to be flat, so nothing was + # enabled and the whole suite ran green over a plugin doing nothing -- + # printing "Enabled leagues: []" as it went. A silent return to that + # state is the regression most worth catching here. + if enabled != ["mlb"]: + print(f"[FAIL] expected MLB to be the only enabled league, got " + f"{enabled}; the test config is not reaching the plugin") + return None + print("[OK] Plugin initialized successfully") print(f" Enabled leagues: {enabled}") return plugin diff --git a/plugins/basketball-scoreboard/test_plugin_syntax.py b/plugins/basketball-scoreboard/test_plugin_syntax.py index c120a315..baba4645 100644 --- a/plugins/basketball-scoreboard/test_plugin_syntax.py +++ b/plugins/basketball-scoreboard/test_plugin_syntax.py @@ -32,7 +32,13 @@ try: import src.plugin_system.base_plugin # noqa: F401 -except ImportError as exc: +except ModuleNotFoundError as exc: + # Skip only when the core itself is absent. A ModuleNotFoundError raised + # from *inside* the core -- a missing dependency, a broken import in + # base_plugin -- is a real failure, and reporting it as "no core here" + # would hide it behind a skip nobody reads. + if exc.name not in ("src", "src.plugin_system", "src.plugin_system.base_plugin"): + raise print(f"SKIP: needs a LEDMatrix core on the path ({exc}); " f"set LEDMATRIX_CORE or run via scripts/run_plugin_tests.py --core") sys.exit(2) diff --git a/plugins/ledmatrix-weather/test_almanac_moon_data.py b/plugins/ledmatrix-weather/test_almanac_moon_data.py index c9327abc..f1621c56 100644 --- a/plugins/ledmatrix-weather/test_almanac_moon_data.py +++ b/plugins/ledmatrix-weather/test_almanac_moon_data.py @@ -28,10 +28,14 @@ try: from astral import moon as astral_moon, Observer # noqa: E402 -except ImportError: +except ModuleNotFoundError as exc: # Declared in the plugin's requirements.txt, so CI installs it and this # runs for real there. Locally it is often absent; exit 2 is the runner's # "prerequisites absent" code, which keeps that distinct from a failure. + # Only astral's own absence counts -- an import error from inside astral + # is a real problem and must not be filed as "not installed". + if exc.name != "astral": + raise print("SKIP: astral not installed (see plugins/ledmatrix-weather/" "requirements.txt); pip install astral to run this test") sys.exit(2)