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..85afbc90 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,31 @@ 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 = 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 except Exception as e: print(f"[FAIL] Plugin initialization error: {e}") @@ -179,17 +207,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 +302,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..baba4645 100644 --- a/plugins/basketball-scoreboard/test_plugin_syntax.py +++ b/plugins/basketball-scoreboard/test_plugin_syntax.py @@ -1,35 +1,71 @@ #!/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 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) + +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..f1621c56 100644 --- a/plugins/ledmatrix-weather/test_almanac_moon_data.py +++ b/plugins/ledmatrix-weather/test_almanac_moon_data.py @@ -26,7 +26,19 @@ 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 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) # 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)")