diff --git a/src/base_classes/api_extractors.py b/src/base_classes/api_extractors.py deleted file mode 100644 index 97155d86..00000000 --- a/src/base_classes/api_extractors.py +++ /dev/null @@ -1,371 +0,0 @@ -""" -Abstract API Data Extraction Layer - -This module provides a pluggable system for extracting game data from different -sports APIs. Each sport can have its own extractor that handles sport-specific -fields and data structures. -""" - -from abc import ABC, abstractmethod -from typing import Dict, Optional -import logging -from datetime import datetime -import pytz - -class APIDataExtractor(ABC): - """Abstract base class for API data extraction.""" - - def __init__(self, logger: logging.Logger): - self.logger = logger - - @abstractmethod - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract common game details from raw API data.""" - - @abstractmethod - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract sport-specific fields (downs, innings, periods, etc.).""" - - def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: - """Extract common game details that work across all sports.""" - if not game_event: - return None, None, None, None, None - - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - situation = competition.get("situation") - - # Parse game time - start_time_utc = None - try: - # Parse the datetime string - if game_date_str.endswith('Z'): - game_date_str = game_date_str.replace('Z', '+00:00') - dt = datetime.fromisoformat(game_date_str) - # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) - if dt.tzinfo is None: - # If naive, assume it's UTC - start_time_utc = dt.replace(tzinfo=pytz.UTC) - else: - # Convert to pytz.UTC for consistency - start_time_utc = dt.astimezone(pytz.UTC) - except ValueError: - self.logger.warning(f"Could not parse game date: {game_date_str}") - - # Extract teams - home_team = next((c for c in competitors if c.get("homeAway") == "home"), None) - away_team = next((c for c in competitors if c.get("homeAway") == "away"), None) - - if not home_team or not away_team: - self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") - return None, None, None, None, None - - return { - "game_event": game_event, - "competition": competition, - "status": status, - "situation": situation, - "start_time_utc": start_time_utc, - "home_team": home_team, - "away_team": away_team - }, home_team, away_team, status, situation - - except Exception as e: - self.logger.error(f"Error extracting common details: {e}") - return None, None, None, None, None - - -class ESPNFootballExtractor(APIDataExtractor): - """ESPN API extractor for football (NFL/NCAA).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract football game details from ESPN API.""" - common_data, home_team, away_team, status, situation = self._extract_common_details(game_event) - if not common_data: - return None - - try: - # Extract basic team info - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Extract sport-specific fields - sport_fields = self.get_sport_specific_fields(game_event) - - # Build game details - details = { - "id": game_event.get("id"), - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_team_name": home_team["team"].get("displayName", ""), - "away_team_name": away_team["team"].get("displayName", ""), - "status_text": status["type"].get("shortDetail", ""), - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - **sport_fields # Add sport-specific fields - } - - return details - - except Exception as e: - self.logger.error(f"Error extracting football game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract football-specific fields.""" - try: - competition = game_event["competitions"][0] - status = competition["status"] - situation = competition.get("situation", {}) - - sport_fields = { - "down": "", - "distance": "", - "possession": "", - "is_redzone": False, - "home_timeouts": 0, - "away_timeouts": 0, - "scoring_event": "" - } - - if situation and status["type"]["state"] == "in": - sport_fields.update({ - "down": situation.get("down", ""), - "distance": situation.get("distance", ""), - "possession": situation.get("possession", ""), - "is_redzone": situation.get("isRedZone", False), - "home_timeouts": situation.get("homeTimeouts", 0), - "away_timeouts": situation.get("awayTimeouts", 0) - }) - - # Detect scoring events - status_detail = status["type"].get("detail", "").lower() - if "touchdown" in status_detail or "field goal" in status_detail: - sport_fields["scoring_event"] = status_detail - - return sport_fields - - except Exception as e: - self.logger.error(f"Error extracting football-specific fields: {e}") - return {} - - -class ESPNBaseballExtractor(APIDataExtractor): - """ESPN API extractor for baseball (MLB).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract baseball game details from ESPN API.""" - common_data, home_team, away_team, status, situation = self._extract_common_details(game_event) - if not common_data: - return None - - try: - # Extract basic team info - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Extract sport-specific fields - sport_fields = self.get_sport_specific_fields(game_event) - - # Build game details - details = { - "id": game_event.get("id"), - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_team_name": home_team["team"].get("displayName", ""), - "away_team_name": away_team["team"].get("displayName", ""), - "status_text": status["type"].get("shortDetail", ""), - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - **sport_fields # Add sport-specific fields - } - - return details - - except Exception as e: - self.logger.error(f"Error extracting baseball game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract baseball-specific fields.""" - try: - competition = game_event["competitions"][0] - status = competition["status"] - situation = competition.get("situation", {}) - - sport_fields = { - "inning": "", - "outs": 0, - "bases": "", - "strikes": 0, - "balls": 0, - "pitcher": "", - "batter": "" - } - - if situation and status["type"]["state"] == "in": - sport_fields.update({ - "inning": situation.get("inning", ""), - "outs": situation.get("outs", 0), - "bases": situation.get("bases", ""), - "strikes": situation.get("strikes", 0), - "balls": situation.get("balls", 0), - "pitcher": situation.get("pitcher", ""), - "batter": situation.get("batter", "") - }) - - return sport_fields - - except Exception as e: - self.logger.error(f"Error extracting baseball-specific fields: {e}") - return {} - - -class ESPNHockeyExtractor(APIDataExtractor): - """ESPN API extractor for hockey (NHL/NCAA).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract hockey game details from ESPN API.""" - common_data, home_team, away_team, status, situation = self._extract_common_details(game_event) - if not common_data: - return None - - try: - # Extract basic team info - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Extract sport-specific fields - sport_fields = self.get_sport_specific_fields(game_event) - - # Build game details - details = { - "id": game_event.get("id"), - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_team_name": home_team["team"].get("displayName", ""), - "away_team_name": away_team["team"].get("displayName", ""), - "status_text": status["type"].get("shortDetail", ""), - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - **sport_fields # Add sport-specific fields - } - - return details - - except Exception as e: - self.logger.error(f"Error extracting hockey game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract hockey-specific fields.""" - try: - competition = game_event["competitions"][0] - status = competition["status"] - situation = competition.get("situation", {}) - - sport_fields = { - "period": "", - "period_text": "", - "power_play": False, - "penalties": "", - "shots_on_goal": {"home": 0, "away": 0} - } - - if situation and status["type"]["state"] == "in": - period = status.get("period", 0) - period_text = "" - if period == 1: - period_text = "P1" - elif period == 2: - period_text = "P2" - elif period == 3: - period_text = "P3" - elif period > 3: - period_text = f"OT{period-3}" - - sport_fields.update({ - "period": str(period), - "period_text": period_text, - "power_play": situation.get("isPowerPlay", False), - "penalties": situation.get("penalties", ""), - "shots_on_goal": { - "home": situation.get("homeShots", 0), - "away": situation.get("awayShots", 0) - } - }) - - return sport_fields - - except Exception as e: - self.logger.error(f"Error extracting hockey-specific fields: {e}") - return {} - - -class SoccerAPIExtractor(APIDataExtractor): - """Generic extractor for soccer APIs (different structure than ESPN).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract soccer game details from various soccer APIs.""" - # This would need to be adapted based on the specific soccer API being used - # For now, return a basic structure - try: - return { - "id": game_event.get("id"), - "home_abbr": game_event.get("home_team", {}).get("abbreviation", ""), - "away_abbr": game_event.get("away_team", {}).get("abbreviation", ""), - "home_score": str(game_event.get("home_score", "0")), - "away_score": str(game_event.get("away_score", "0")), - "home_team_name": game_event.get("home_team", {}).get("name", ""), - "away_team_name": game_event.get("away_team", {}).get("name", ""), - "status_text": game_event.get("status", ""), - "is_live": game_event.get("is_live", False), - "is_final": game_event.get("is_final", False), - "is_upcoming": game_event.get("is_upcoming", False), - **self.get_sport_specific_fields(game_event) - } - except Exception as e: - self.logger.error(f"Error extracting soccer game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract soccer-specific fields.""" - try: - return { - "half": game_event.get("half", ""), - "stoppage_time": game_event.get("stoppage_time", ""), - "cards": { - "home_yellow": game_event.get("home_yellow_cards", 0), - "away_yellow": game_event.get("away_yellow_cards", 0), - "home_red": game_event.get("home_red_cards", 0), - "away_red": game_event.get("away_red_cards", 0) - }, - "possession": { - "home": game_event.get("home_possession", 0), - "away": game_event.get("away_possession", 0) - } - } - except Exception as e: - self.logger.error(f"Error extracting soccer-specific fields: {e}") - return {} - - -# Factory function removed - sport classes now instantiate extractors directly diff --git a/src/base_classes/baseball.py b/src/base_classes/baseball.py deleted file mode 100644 index f36ca9db..00000000 --- a/src/base_classes/baseball.py +++ /dev/null @@ -1,692 +0,0 @@ -""" -Baseball Base Classes - -This module provides baseball-specific base classes that extend the core sports functionality -with baseball-specific logic for innings, outs, bases, strikes, balls, etc. -""" - -import logging -import time -from typing import Any, Dict, Optional - -from PIL import Image, ImageDraw, ImageFont - -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive, SportsRecent - - -class Baseball(SportsCore): - """Base class for baseball sports with common functionality.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - # Baseball-specific configuration - self.show_innings = self.mode_config.get("show_innings", True) - self.show_outs = self.mode_config.get("show_outs", True) - self.show_bases = self.mode_config.get("show_bases", True) - self.show_count = self.mode_config.get("show_count", True) - self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False) - self.show_series_summary = self.mode_config.get("show_series_summary", False) - self.data_source = ESPNDataSource(logger) - self.sport = "baseball" - - def _is_baseball_game_live(self, game: Dict) -> bool: - """Check if a baseball game is currently live.""" - try: - # Check if game is marked as live - is_live = game.get("is_live", False) - if is_live: - return True - - # Check inning to determine if game is active - inning = game.get("inning", "") - if inning and inning != "Final": - return True - - return False - - except Exception as e: - self.logger.error(f"Error checking if baseball game is live: {e}") - return False - - def _get_baseball_game_status(self, game: Dict) -> str: - """Get baseball-specific game status.""" - try: - status = game.get("status_text", "") - inning = game.get("inning", "") - - if self._is_baseball_game_live(game): - if inning: - return f"Live - {inning}" - else: - return "Live" - elif game.get("is_final", False): - return "Final" - elif game.get("is_upcoming", False): - return "Upcoming" - else: - return status - - except Exception as e: - self.logger.error(f"Error getting baseball game status: {e}") - return "" - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - details, home_team, away_team, status, situation = ( - self._extract_game_details_common(game_event) - ) - if details is None or home_team is None or away_team is None or status is None: - return - try: - # print(status["type"]["state"]) - # exit() - game_status = status["type"]["name"].lower() - status_state = status["type"]["state"].lower() - # Get team abbreviations - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - - # Check if this is a favorite team game - is_favorite_game = ( - home_abbr in self.favorite_teams or away_abbr in self.favorite_teams - ) - - # Log all teams found for debugging - self.logger.debug( - f"Found game: {away_abbr} @ {home_abbr} (Status: {game_status}, State: {status_state})" - ) - - # Only log detailed information for favorite teams - if is_favorite_game: - # Use the validated competition-level `status` here too. MiLB - # events carry no event-level one, so this debug line raised a - # KeyError and dropped the very games it was meant to help - # diagnose -- and only for favourites, which is the worst way - # for it to fail. - self.logger.debug(f"Full status data: {status}") - self.logger.debug(f"Status type: {game_status}, State: {status_state}") - self.logger.debug(f"Status detail: {status['type'].get('detail', '')}") - self.logger.debug( - f"Status shortDetail: {status['type'].get('shortDetail', '')}" - ) - series = game_event["competitions"][0].get("series", None) - series_summary = "" - if series: - series_summary = series.get("summary", "") - # Get game state information - if status_state == "in": - # For live games, get detailed state - # Use the competition-level `status` already validated by - # _extract_game_details_common. Real ESPN events duplicate - # status at the event top level, but MiLB events (synthesized - # from the MLB Stats API into an ESPN-like shape) populate - # only the competition-level one, so the top-level lookup - # raised a bare KeyError and dropped the event. - inning = status.get( - "period", 1 - ) # Get inning from status period - - # Get inning information from status - status_detail = status["type"].get("detail", "").lower() - status_short = status["type"].get("shortDetail", "").lower() - - if is_favorite_game: - self.logger.debug( - f"Raw status detail: {status['type'].get('detail')}" - ) - self.logger.debug( - f"Raw status short: {status['type'].get('shortDetail')}" - ) - - # Determine inning half from status information - inning_half = "top" # Default - - # Handle end of inning: next inning is top - if "end" in status_detail or "end" in status_short: - inning_half = "top" - inning = ( - status.get("period", 1) + 1 - ) # Use period and increment for next inning - if is_favorite_game: - self.logger.debug( - f"Detected end of inning. Setting to Top {inning}" - ) - # Handle middle of inning: next is bottom of current inning - elif "mid" in status_detail or "mid" in status_short: - inning_half = "bottom" - if is_favorite_game: - self.logger.debug( - f"Detected middle of inning. Setting to Bottom {inning}" - ) - # Handle bottom of inning - elif ( - "bottom" in status_detail - or "bot" in status_detail - or "bottom" in status_short - or "bot" in status_short - ): - inning_half = "bottom" - if is_favorite_game: - self.logger.debug(f"Detected bottom of inning: {inning}") - # Handle top of inning - elif "top" in status_detail or "top" in status_short: - inning_half = "top" - if is_favorite_game: - self.logger.debug(f"Detected top of inning: {inning}") - - if is_favorite_game: - self.logger.debug(f"Status detail: {status_detail}") - self.logger.debug(f"Status short: {status_short}") - self.logger.debug(f"Determined inning: {inning_half} {inning}") - - # Get count and bases from situation - situation = game_event["competitions"][0].get("situation", {}) - - if is_favorite_game: - self.logger.debug(f"Full situation data: {situation}") - - # Get count from the correct location in the API response - count = situation.get("count", {}) - balls = count.get("balls", 0) - strikes = count.get("strikes", 0) - outs = situation.get("outs", 0) - - # Add detailed logging for favorite team games - if is_favorite_game: - self.logger.debug(f"Full situation data: {situation}") - self.logger.debug(f"Count object: {count}") - self.logger.debug( - f"Raw count values - balls: {balls}, strikes: {strikes}" - ) - self.logger.debug(f"Raw outs value: {outs}") - - # Try alternative locations for count data - if balls == 0 and strikes == 0: - # First try the summary field - if "summary" in situation: - try: - count_summary = situation["summary"] - balls, strikes = map(int, count_summary.split("-")) - if is_favorite_game: - self.logger.debug( - f"Using summary count: {count_summary}" - ) - except (ValueError, AttributeError): - if is_favorite_game: - self.logger.debug("Could not parse summary count") - else: - # Check if count is directly in situation - balls = situation.get("balls", 0) - strikes = situation.get("strikes", 0) - if is_favorite_game: - self.logger.debug( - f"Using direct situation count: balls={balls}, strikes={strikes}" - ) - self.logger.debug( - f"Full situation keys: {list(situation.keys())}" - ) - - if is_favorite_game: - self.logger.debug(f"Final count: balls={balls}, strikes={strikes}") - - # Get base runners - bases_occupied = [ - situation.get("onFirst", False), - situation.get("onSecond", False), - situation.get("onThird", False), - ] - - if is_favorite_game: - self.logger.debug(f"Bases occupied: {bases_occupied}") - else: - # Default values for non-live games - inning = 1 - inning_half = "top" - balls = 0 - strikes = 0 - outs = 0 - bases_occupied = [False, False, False] - - details.update( - { - "status": game_status, - "status_state": status_state, - "inning": inning, - "inning_half": inning_half, - "balls": balls, - "strikes": strikes, - "outs": outs, - "bases_occupied": bases_occupied, - "start_time": game_event["date"], - "series_summary": series_summary, - } - ) - - # Basic validation (can be expanded) - if not details["home_abbr"] or not details["away_abbr"]: - self.logger.warning( - f"Missing team abbreviation in event: {details['id']}" - ) - return None - - self.logger.debug( - f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" - ) - - return details - except Exception as e: - # Log the problematic event structure if possible - self.logger.error( - f"Error extracting game details: {e} from event: {game_event.get('id')}", - exc_info=True, - ) - return None - - def display_series_summary(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - if not self.show_series_summary: - return - - series_summary = game.get("series_summary", "") - bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time']) - height = bbox[3] - bbox[1] - shots_y = (self.display_height - height) // 2 - shots_width = draw_overlay.textlength(series_summary, font=self.fonts['time']) - shots_x = (self.display_width - shots_width) // 2 - self._draw_text_with_outline( - draw_overlay, series_summary, (shots_x, shots_y), self.fonts['time'] - ) - -class BaseballRecent(Baseball, SportsRecent): - """Base class for recent baseball games.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - self.display_series_summary(game, draw_overlay) - - -class BaseballLive(Baseball, SportsLive): - """Base class for live baseball games.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - # self.current_game["bases_occupied"] = [ - # random.choice([True, False]) for _ in range(3) - # ] - # self.current_game["balls"] = random.choice([1, 2, 3]) - # self.current_game["strikes"] = random.choice([1, 2]) - # self.current_game["outs"] = random.choice([1, 2]) - if self.current_game["inning_half"] == "top": - self.current_game["inning_half"] = "bottom" - else: - self.current_game["inning_half"] = "top" - self.current_game["inning"] += 1 - self.current_game["balls"] = (self.current_game["balls"] + 1) % 4 - self.current_game["strikes"] = (self.current_game["strikes"] + 1) % 3 - self.current_game["outs"] = (self.current_game["outs"] + 1) % 3 - self.current_game["bases_occupied"] = [ - not b for b in self.current_game["bases_occupied"] - ] - if self.current_game["inning"] % 2 == 0: - self.current_game["home_score"] = str( - int(self.current_game["home_score"]) + 1 - ) - else: - self.current_game["away_score"] = str( - int(self.current_game["away_score"]) + 1 - ) - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring - try: - main_img = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) - ) - overlay = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) - ) - draw_overlay = ImageDraw.Draw( - overlay - ) # Draw text elements on overlay first - - home_logo = self._load_and_resize_logo( - game["home_id"], - game["home_abbr"], - game["home_logo_path"], - game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game["away_id"], - game["away_abbr"], - game["away_logo_path"], - game.get("away_logo_url"), - ) - - if not home_logo or not away_logo: - self.logger.error( - f"Failed to load logos for live game: {game.get('id')}" - ) # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) - self._draw_text_with_outline( - draw_final, "Logo Error", (5, 5), self.fonts["status"] - ) - self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = ( - self.display_width - home_logo.width + 10 - ) # adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 # adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Live Game Specific Elements --- - - # Define default text color - text_color = (255, 255, 255) - - # Draw Inning (Top Center) - inning_half = game["inning_half"] - inning_num = game["inning"] - if game["is_final"]: - inning_text = "FINAL" - else: - inning_half_indicator = ( - "▲" if game["inning_half"].lower() == "top" else "▼" - ) - inning_num = game["inning"] - inning_text = f"{inning_half_indicator}{inning_num}" - - inning_bbox = draw_overlay.textbbox( - (0, 0), inning_text, font=self.display_manager.font - ) - inning_width = inning_bbox[2] - inning_bbox[0] - inning_x = (self.display_width - inning_width) // 2 - inning_y = 1 # Position near top center - # draw_overlay.text((inning_x, inning_y), inning_text, fill=(255, 255, 255), font=self.display_manager.font) - self._draw_text_with_outline( - draw_overlay, - inning_text, - (inning_x, inning_y), - self.display_manager.font, - ) - - # --- REVISED BASES AND OUTS DRAWING --- - bases_occupied = game["bases_occupied"] # [1st, 2nd, 3rd] - outs = game.get("outs", 0) - inning_half = game["inning_half"] - - # Define geometry - base_diamond_size = 7 - out_circle_diameter = 3 - out_vertical_spacing = 2 # Space between out circles - spacing_between_bases_outs = ( - 3 # Horizontal space between base cluster and out column - ) - base_vert_spacing = 1 # Internal vertical space in base cluster - base_horiz_spacing = 1 # Internal horizontal space in base cluster - - # Calculate cluster dimensions - base_cluster_height = ( - base_diamond_size + base_vert_spacing + base_diamond_size - ) - base_cluster_width = ( - base_diamond_size + base_horiz_spacing + base_diamond_size - ) - out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing - out_cluster_width = out_circle_diameter - - # Calculate overall start positions - overall_start_y = ( - inning_bbox[3] + 0 - ) # Start immediately below inning text (moved up 3 pixels) - - # Center the BASE cluster horizontally - bases_origin_x = (self.display_width - base_cluster_width) // 2 - - # Determine relative positions for outs based on inning half - if inning_half == "top": # Away batting, outs on left - outs_column_x = ( - bases_origin_x - spacing_between_bases_outs - out_cluster_width - ) - else: # Home batting, outs on right - outs_column_x = ( - bases_origin_x + base_cluster_width + spacing_between_bases_outs - ) - - # Calculate vertical alignment offset for outs column (center align with bases cluster) - outs_column_start_y = ( - overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) - ) - - # --- Draw Bases (Diamonds) --- - base_color_occupied = (255, 255, 255) - base_color_empty = (255, 255, 255) # Outline color - h_d = base_diamond_size // 2 - - # 2nd Base (Top center relative to bases_origin_x) - c2x = bases_origin_x + base_cluster_width // 2 - c2y = overall_start_y + h_d - poly2 = [ - (c2x, overall_start_y), - (c2x + h_d, c2y), - (c2x, c2y + h_d), - (c2x - h_d, c2y), - ] - if bases_occupied[1]: - draw_overlay.polygon(poly2, fill=base_color_occupied) - else: - draw_overlay.polygon(poly2, outline=base_color_empty) - - base_bottom_y = c2y + h_d # Bottom Y of 2nd base diamond - - # 3rd Base (Bottom left relative to bases_origin_x) - c3x = bases_origin_x + h_d - c3y = base_bottom_y + base_vert_spacing + h_d - poly3 = [ - (c3x, base_bottom_y + base_vert_spacing), - (c3x + h_d, c3y), - (c3x, c3y + h_d), - (c3x - h_d, c3y), - ] - if bases_occupied[2]: - draw_overlay.polygon(poly3, fill=base_color_occupied) - else: - draw_overlay.polygon(poly3, outline=base_color_empty) - - # 1st Base (Bottom right relative to bases_origin_x) - c1x = bases_origin_x + base_cluster_width - h_d - c1y = base_bottom_y + base_vert_spacing + h_d - poly1 = [ - (c1x, base_bottom_y + base_vert_spacing), - (c1x + h_d, c1y), - (c1x, c1y + h_d), - (c1x - h_d, c1y), - ] - if bases_occupied[0]: - draw_overlay.polygon(poly1, fill=base_color_occupied) - else: - draw_overlay.polygon(poly1, outline=base_color_empty) - - # --- Draw Outs (Vertical Circles) --- - circle_color_out = (255, 255, 255) - circle_color_empty_outline = (100, 100, 100) - - for i in range(3): - cx = outs_column_x - cy = outs_column_start_y + i * ( - out_circle_diameter + out_vertical_spacing - ) - coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter] - if i < outs: - draw_overlay.ellipse(coords, fill=circle_color_out) - else: - draw_overlay.ellipse(coords, outline=circle_color_empty_outline) - - # --- Draw Balls-Strikes Count (BDF Font) --- - balls = game.get("balls", 0) - strikes = game.get("strikes", 0) - - # Add debug logging for count with cooldown - current_time = time.time() - if ( - game["home_abbr"] in self.favorite_teams - or game["away_abbr"] in self.favorite_teams - ) and current_time - self.last_count_log_time >= self.count_log_interval: - self.logger.debug(f"Displaying count: {balls}-{strikes}") - self.logger.debug( - f"Raw count data: balls={game.get('balls')}, strikes={game.get('strikes')}" - ) - self.last_count_log_time = current_time - - count_text = f"{balls}-{strikes}" - bdf_font = self.display_manager.calendar_font - bdf_font.set_char_size(height=7 * 64) # Set 7px height - count_text_width = self.display_manager.get_text_width(count_text, bdf_font) - - # Position below the base/out cluster - cluster_bottom_y = ( - overall_start_y + base_cluster_height - ) # Find the bottom of the taller part (bases) - count_y = cluster_bottom_y + 2 # Start 2 pixels below cluster - - # Center horizontally within the BASE cluster width - count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2 - - # Ensure draw object is set and draw text - self.display_manager.draw = draw_overlay - # self.display_manager._draw_bdf_text(count_text, count_x, count_y, text_color, font=bdf_font) - # Use _draw_text_with_outline for count text - # self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color) - - # Draw Balls-Strikes Count with outline using BDF font - # Define outline color (consistent with _draw_text_with_outline default) - outline_color_for_bdf = (0, 0, 0) - - # Draw outline - for dx_offset, dy_offset in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - self.display_manager._draw_bdf_text( - count_text, - count_x + dx_offset, - count_y + dy_offset, - color=outline_color_for_bdf, - font=bdf_font, - ) - - # Draw main text - self.display_manager._draw_bdf_text( - count_text, count_x, count_y, color=text_color, font=bdf_font - ) - - # Draw Team:Score at the bottom (matching main branch format) - score_font = self.display_manager.font # Use PressStart2P - outline_color = (0, 0, 0) - score_text_color = ( - 255, - 255, - 255, - ) # Use a specific name for score text color - - # Helper function for outlined text - def draw_bottom_outlined_text(x, y, text): - self._draw_text_with_outline( - draw_overlay, - text, - (x, y), - score_font, - fill=score_text_color, - outline_color=outline_color, - ) - - away_abbr = game["away_abbr"] - home_abbr = game["home_abbr"] - away_score_str = str(game["away_score"]) - home_score_str = str(game["home_score"]) - - away_text = f"{away_abbr}:{away_score_str}" - home_text = f"{home_abbr}:{home_score_str}" - - # Calculate Y position (bottom edge) - # Get font height (approximate or precise) - try: - font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] - except AttributeError: - font_height = 8 # Fallback for default font - score_y = ( - self.display_height - font_height - 2 - ) # 2 pixels padding from bottom - - # Away Team:Score (Bottom Left) - away_score_x = 2 # 2 pixels padding from left - draw_bottom_outlined_text(away_score_x, score_y, away_text) - - # Home Team:Score (Bottom Right) - home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font) - home_text_width = home_text_bbox[2] - home_text_bbox[0] - home_score_x = ( - self.display_width - home_text_width - 2 - ) # 2 pixels padding from right - draw_bottom_outlined_text(home_score_x, score_y, home_text) - - # Draw gambling odds if available - if "odds" in game and game["odds"]: - self._draw_dynamic_odds( - draw_overlay, game["odds"], self.display_width, self.display_height - ) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert("RGB") # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error( - f"Error displaying live Baseball game: {e}", exc_info=True - ) # Changed log prefix diff --git a/src/base_classes/basketball.py b/src/base_classes/basketball.py deleted file mode 100644 index 72c4bf28..00000000 --- a/src/base_classes/basketball.py +++ /dev/null @@ -1,300 +0,0 @@ -import logging -from typing import Any, Dict, Optional - -from PIL import Image, ImageDraw, ImageFont - -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager - - -class Basketball(SportsCore): - """Base class for basketball sports with common functionality.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.data_source = ESPNDataSource(logger) - self.sport = "basketball" - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - # --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES --- - details, home_team, away_team, status, situation = ( - self._extract_game_details_common(game_event) - ) - if details is None or home_team is None or away_team is None or status is None: - return - try: - # Format period/quarter - period = status.get("period", 0) - period_text = "" - if status["type"]["state"] == "in": - if period == 0: - period_text = "Start" # Before kickoff - elif period >= 1 and period <= 4: - period_text = f"Q{period}" # OT starts after Q4 - elif period > 4: - period_text = f"OT{period - 4}" # OT starts after Q4 - elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state - period_text = "HALF" - elif status["type"]["state"] == "post": - if period > 4 : period_text = "Final/OT" - else: period_text = "Final" - elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming - - details.update({ - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - }) - - # Basic validation (can be expanded) - if not details["home_abbr"] or not details["away_abbr"]: - self.logger.warning( - f"Missing team abbreviation in event: {details['id']}" - ) - return None - - self.logger.debug( - f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" - ) - - return details - except Exception as e: - # Log the problematic event structure if possible - self.logger.error( - f"Error extracting game details: {e} from event: {game_event.get('id')}", - exc_info=True, - ) - return None - - -class BasketballLive(Basketball, SportsLive): - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - # For testing, we'll just update the clock to show it's working - minutes = int(self.current_game["clock"].split(":")[0]) - seconds = int(self.current_game["clock"].split(":")[1]) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - minutes = 19 - if self.current_game["period"] < 3: - self.current_game["period"] += 1 - else: - self.current_game["period"] = 1 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live Basketball game.""" # Updated docstring - try: - main_img = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) - ) - overlay = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) - ) - draw_overlay = ImageDraw.Draw( - overlay - ) # Draw text elements on overlay first - home_logo = self._load_and_resize_logo( - game["home_id"], - game["home_abbr"], - game["home_logo_path"], - game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game["away_id"], - game["away_abbr"], - game["away_logo_path"], - game.get("away_logo_url"), - ) - - if not home_logo or not away_logo: - self.logger.error( - f"Failed to load logos for live game: {game.get('id')}" - ) # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) - self._draw_text_with_outline( - draw_final, "Logo Error", (5, 5), self.fonts["status"] - ) - self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = ( - self.display_width - home_logo.width + 10 - ) # adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 # adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Draw Text Elements on Overlay --- - # Note: Rankings are now handled in the records/rankings section below - - # Period/Quarter and Clock (Top center) - period_clock_text = ( - f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - ) - - status_width = draw_overlay.textlength( - period_clock_text, font=self.fonts["time"] - ) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline( - draw_overlay, - period_clock_text, - (status_x, status_y), - self.fonts["time"], - ) - - # Scores (centered, slightly above bottom) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) - score_x = (self.display_width - score_width) // 2 - score_y = ( - self.display_height // 2 - ) - 3 # centered #from 14 # Position score higher - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] - ) - - # Draw odds if available - if "odds" in game and game["odds"]: - self._draw_dynamic_odds( - draw_overlay, game["odds"], self.display_width, self.display_height - ) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get("away_abbr", "") - home_abbr = game.get("home_abbr", "") - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - 1 - self.logger.debug( - f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" - ) - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = "" - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = "" - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get("away_record", "") - else: - away_text = "" - - if away_text: - away_record_x = 3 - self.logger.debug( - f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - away_text, - (away_record_x, record_y), - record_font, - ) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = "" - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = "" - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get("home_record", "") - else: - home_text = "" - - if home_text: - home_record_bbox = draw_overlay.textbbox( - (0, 0), home_text, font=record_font - ) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 3 - self.logger.debug( - f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - home_text, - (home_record_x, record_y), - record_font, - ) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert("RGB") # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error( - f"Error displaying live Hockey game: {e}", exc_info=True - ) # Changed log prefix diff --git a/src/base_classes/data_sources.py b/src/base_classes/data_sources.py deleted file mode 100644 index 80c6177a..00000000 --- a/src/base_classes/data_sources.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Pluggable Data Source Architecture - -This module provides abstract data sources that can be plugged into the sports system -to support different APIs and data providers. -""" - -from abc import ABC, abstractmethod -from typing import Dict, List -import requests -import logging -from datetime import datetime -from src.common.espn_dates import fetch_espn_scoreboard - -class DataSource(ABC): - """Abstract base class for data sources.""" - - def __init__(self, logger: logging.Logger): - self.logger = logger - self.session = requests.Session() - - # Configure retry strategy - from requests.adapters import HTTPAdapter - from urllib3.util.retry import Retry - - retry_strategy = Retry( - total=5, - backoff_factor=1, - status_forcelist=[429, 500, 502, 503, 504], - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("http://", adapter) - self.session.mount("https://", adapter) - - @abstractmethod - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games for a sport/league.""" - - @abstractmethod - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule for a sport/league within date range.""" - - @abstractmethod - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings for a sport/league.""" - - def get_headers(self) -> Dict[str, str]: - """Get headers for API requests. - - The agent carries the project URL deliberately. Around 2026-08-04 ESPN - began returning 403 for bare custom tokens like 'LEDMatrix/1.0' — and - for browser-style strings — while accepting an agent that identifies - the client and links to it. An Accept header alone does not rescue the - bare form when the request goes out through requests. - """ - return { - 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', - 'Accept': 'application/json' - } - - -class ESPNDataSource(DataSource): - """ESPN API data source.""" - - def __init__(self, logger: logging.Logger): - super().__init__(logger) - self.base_url = "https://site.api.espn.com/apis/site/v2/sports" - - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games from ESPN API.""" - try: - now = datetime.now() - formatted_date = now.strftime("%Y%m%d") - url = f"{self.base_url}/{sport}/{league}/scoreboard" - data = fetch_espn_scoreboard( - self.session, url, params={"dates": formatted_date, "limit": 1000}, - headers=self.get_headers(), timeout=15, logger=self.logger, - ) - events = data.get('events', []) - - # Filter for live games - live_events = [event for event in events - if event.get('competitions', [{}])[0].get('status', {}).get('type', {}).get('state') == 'in'] - - self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}") - return live_events - - except Exception as e: - self.logger.error(f"Error fetching live games from ESPN: {e}") - return [] - - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule from ESPN API.""" - try: - start_date, end_date = date_range - url = f"{self.base_url}/{sport}/{league}/scoreboard" - - params = { - 'dates': f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}", - "limit": 1000 - } - - data = fetch_espn_scoreboard( - self.session, url, params=params, - headers=self.get_headers(), timeout=15, logger=self.logger, - ) - events = data.get('events', []) - - self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}") - return events - - except Exception as e: - self.logger.error(f"Error fetching schedule from ESPN: {e}") - return [] - - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings, or the poll for leagues that have one. - - Order matters and used to be wrong. College leagues publish a poll at - /rankings and a records table at /standings; professional leagues have - only /standings. The old code tried /standings first and fell back to - /rankings only on a 404 -- but college /standings answers 200, so the - fallback never fired and college rankings came back empty forever. - Nothing failed; the AP rank badge simply never appeared, and anything - else keyed off rankings quietly did nothing. - - A 200 that lacks the key is treated as a miss, so a league answering - both endpoints still ends up with whichever one actually carries a poll. - """ - league_name = (league or "").lower() - wants_poll = "college" in league_name or "ncaa" in league_name - endpoints = ["rankings", "standings"] if wants_poll else ["standings", "rankings"] - - for endpoint in endpoints: - url = f"{self.base_url}/{sport}/{league}/{endpoint}" - # Only the request is guarded. Inspecting the payload happens - # below, outside the handler, so that a bug in this method cannot - # be mistaken for an endpoint that failed -- that mistake would - # silently drop rankings for a league that has them, which is the - # exact failure this function was written to fix. - try: - response = self.session.get( - url, headers=self.get_headers(), timeout=15 - ) - response.raise_for_status() - data = response.json() - except (requests.RequestException, ValueError) as e: - status = getattr(getattr(e, "response", None), "status_code", None) - # Only a 404 is routine -- it is how a league says "no poll - # here". Everything else is worth an error, and `status is - # None` covers the ones that matter most: ConnectionError, - # Timeout, a body that would not parse. Silencing those left a - # board that could not reach ESPN with one debug line, and the - # ranked filter running on an empty table. - if status != 404: - self.logger.error( - f"Error fetching {endpoint} from ESPN for " - f"{sport}/{league}: {e}" - ) - continue - - if not isinstance(data, dict): - # A list or a bare string is not something the callers can - # read. Treat it as a miss so the other endpoint still gets a - # turn, but say so -- this means ESPN changed shape. - self.logger.error( - f"Unexpected {endpoint} payload for {sport}/{league}: " - f"got {type(data).__name__}, expected an object" - ) - continue - if endpoint == "rankings" and not data.get("rankings"): - continue - self.logger.debug(f"Fetched {endpoint} for {sport}/{league}") - return data - self.logger.debug( - f"Standings/rankings not available for {sport}/{league} from ESPN API" - ) - return {} - - -class MLBAPIDataSource(DataSource): - """MLB API data source.""" - - def __init__(self, logger: logging.Logger): - super().__init__(logger) - self.base_url = "https://statsapi.mlb.com/api/v1" - - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games from MLB API.""" - try: - url = f"{self.base_url}/schedule" - params = { - 'sportId': 1, # MLB - 'date': datetime.now().strftime('%Y-%m-%d'), - 'hydrate': 'game,team,venue,weather' - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - games = data.get('dates', [{}])[0].get('games', []) - - # Filter for live games - live_games = [game for game in games - if game.get('status', {}).get('abstractGameState') == 'Live'] - - self.logger.debug(f"Fetched {len(live_games)} live games from MLB API") - return live_games - - except Exception as e: - self.logger.error(f"Error fetching live games from MLB API: {e}") - return [] - - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule from MLB API.""" - try: - start_date, end_date = date_range - url = f"{self.base_url}/schedule" - - params = { - 'sportId': 1, # MLB - 'startDate': start_date.strftime('%Y-%m-%d'), - 'endDate': end_date.strftime('%Y-%m-%d'), - 'hydrate': 'game,team,venue' - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - all_games = [] - for date_data in data.get('dates', []): - all_games.extend(date_data.get('games', [])) - - self.logger.debug(f"Fetched {len(all_games)} scheduled games from MLB API") - return all_games - - except Exception as e: - self.logger.error(f"Error fetching schedule from MLB API: {e}") - return [] - - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings from MLB API.""" - try: - url = f"{self.base_url}/standings" - params = { - 'leagueId': 103, # American League - 'season': datetime.now().year, - 'standingsType': 'regularSeason' - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - self.logger.debug("Fetched standings from MLB API") - return data - - except Exception as e: - self.logger.error(f"Error fetching standings from MLB API: {e}") - return {} - - -class SoccerAPIDataSource(DataSource): - """Soccer API data source (generic structure).""" - - def __init__(self, logger: logging.Logger, api_key: str = None): - super().__init__(logger) - self.api_key = api_key - self.base_url = "https://api.football-data.org/v4" # Example API - - def get_headers(self) -> Dict[str, str]: - """Get headers with API key for soccer API.""" - headers = super().get_headers() - if self.api_key: - headers['X-Auth-Token'] = self.api_key - return headers - - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games from soccer API.""" - try: - # This would need to be adapted based on the specific soccer API - url = f"{self.base_url}/matches" - params = { - 'status': 'LIVE', - 'competition': league - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - matches = data.get('matches', []) - - self.logger.debug(f"Fetched {len(matches)} live games from soccer API") - return matches - - except Exception as e: - self.logger.error(f"Error fetching live games from soccer API: {e}") - return [] - - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule from soccer API.""" - try: - start_date, end_date = date_range - url = f"{self.base_url}/matches" - - params = { - 'competition': league, - 'dateFrom': start_date.strftime('%Y-%m-%d'), - 'dateTo': end_date.strftime('%Y-%m-%d') - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - matches = data.get('matches', []) - - self.logger.debug(f"Fetched {len(matches)} scheduled games from soccer API") - return matches - - except Exception as e: - self.logger.error(f"Error fetching schedule from soccer API: {e}") - return [] - - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings from soccer API.""" - try: - url = f"{self.base_url}/competitions/{league}/standings" - response = self.session.get(url, headers=self.get_headers(), timeout=15) - response.raise_for_status() - - data = response.json() - self.logger.debug("Fetched standings from soccer API") - return data - - except Exception as e: - self.logger.error(f"Error fetching standings from soccer API: {e}") - return {} - - -# Factory function removed - sport classes now instantiate data sources directly diff --git a/src/base_classes/football.py b/src/base_classes/football.py deleted file mode 100644 index 2974934f..00000000 --- a/src/base_classes/football.py +++ /dev/null @@ -1,387 +0,0 @@ -from typing import Dict, Any, Optional -from src.display_manager import DisplayManager -from src.cache_manager import CacheManager -import logging -from PIL import Image, ImageDraw, ImageFont -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive - -class Football(SportsCore): - """Base class for football sports with common functionality.""" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.data_source = ESPNDataSource(logger) - self.sport = "football" - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - details, home_team, away_team, status, situation = self._extract_game_details_common(game_event) - if details is None or home_team is None or away_team is None or status is None: - return - try: - competition = game_event["competitions"][0] - status = competition["status"] - - # --- Football Specific Details (Likely same for NFL/NCAAFB) --- - down_distance_text = "" - down_distance_text_long = "" - possession_indicator = None # Default to None - scoring_event = "" # Track scoring events - home_timeouts = 0 - away_timeouts = 0 - is_redzone = False - posession = None - - if situation and status["type"]["state"] == "in": - # down = situation.get("down") - down_distance_text = situation.get("shortDownDistanceText") - down_distance_text_long = situation.get("downDistanceText") - # distance = situation.get("distance") - - # Detect scoring events from status detail - status_detail = status["type"].get("detail", "").lower() - status_short = status["type"].get("shortDetail", "").lower() - is_redzone = situation.get("isRedZone") - posession = situation.get("possession") - - # Check for scoring events in status text - if any(keyword in status_detail for keyword in ["touchdown", "td"]): - scoring_event = "TOUCHDOWN" - elif any(keyword in status_detail for keyword in ["field goal", "fg"]): - scoring_event = "FIELD GOAL" - elif any(keyword in status_detail for keyword in ["extra point", "pat", "point after"]): - scoring_event = "PAT" - elif any(keyword in status_short for keyword in ["touchdown", "td"]): - scoring_event = "TOUCHDOWN" - elif any(keyword in status_short for keyword in ["field goal", "fg"]): - scoring_event = "FIELD GOAL" - elif any(keyword in status_short for keyword in ["extra point", "pat"]): - scoring_event = "PAT" - - # Determine possession based on team ID - possession_team_id = situation.get("possession") - if possession_team_id: - if possession_team_id == home_team.get("id"): - possession_indicator = "home" - elif possession_team_id == away_team.get("id"): - possession_indicator = "away" - - home_timeouts = situation.get("homeTimeouts", 3) # Default to 3 if not specified - away_timeouts = situation.get("awayTimeouts", 3) # Default to 3 if not specified - - - # Format period/quarter - period = status.get("period", 0) - period_text = "" - if status["type"]["state"] == "in": - if period == 0: - period_text = "Start" # Before kickoff - elif period >= 1 and period <= 4: - period_text = f"Q{period}" # OT starts after Q4 - elif period > 4: - period_text = f"OT{period - 4}" # OT starts after Q4 - elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state - period_text = "HALF" - elif status["type"]["state"] == "post": - if period > 4 : period_text = "Final/OT" - else: period_text = "Final" - elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming - - details.update({ - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - "home_timeouts": home_timeouts, - "away_timeouts": away_timeouts, - "down_distance_text": down_distance_text, # Added Down/Distance - "down_distance_text_long": down_distance_text_long, - "is_redzone": is_redzone, - "possession": posession, # ID of team with possession - "possession_indicator": possession_indicator, # Added for easy home/away check - "scoring_event": scoring_event, # Track scoring events (TOUCHDOWN, FIELD GOAL, PAT) - }) - - # Basic validation (can be expanded) - if not details['home_abbr'] or not details['away_abbr']: - self.logger.warning(f"Missing team abbreviation in event: {details['id']}") - return None - - self.logger.debug(f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}") - - return details - except Exception as e: - # Log the problematic event structure if possible - logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) - return None - -class FootballLive(Football, SportsLive): - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - try: - minutes, seconds = map(int, self.current_game["clock"].split(':')) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - # Simulate end of quarter/game - if self.current_game["period"] < 4: # Q4 is period 4 - self.current_game["period"] += 1 - # Update period_text based on new period - if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1" - elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2" - elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3" - elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4" - # Reset clock for next quarter (e.g., 15:00) - minutes, seconds = 15, 0 - else: - # Simulate game end - self.current_game["is_live"] = False - self.current_game["is_final"] = True - self.current_game["period_text"] = "Final" - minutes, seconds = 0, 0 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Simulate down change occasionally - if seconds % 15 == 0: - self.current_game["down_distance_text"] = f"{['1st','2nd','3rd','4th'][seconds % 4]} & {seconds % 10 + 1}" - self.current_game["status_text"] = f"{self.current_game['period_text']} {self.current_game['clock']}" - - # Display update handled by main loop or explicit call if needed immediately - # self.display(force_clear=True) # Only if immediate update is desired here - - except ValueError: - self.logger.warning("Test mode: Could not parse clock") # Changed log prefix - # No actual display call here, let main loop handle it - - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring - try: - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) # Draw text elements on overlay first - - home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) - away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for live game: {game.get('id')}") # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = self.display_width - home_logo.width + 10 #adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 #adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Draw Text Elements on Overlay --- - # Note: Rankings are now handled in the records/rankings section below - - # Scores (centered, slightly above bottom) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 #centered #from 14 # Position score higher - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) - - # Period/Quarter and Clock (Top center) - period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - if game.get("is_halftime"): \ - period_clock_text = "Halftime" # Override for halftime - elif game.get("is_period_break"): - period_clock_text = game.get("status_text", "Period Break") - - status_width = draw_overlay.textlength(period_clock_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time']) - - # Down & Distance or Scoring Event (Below Period/Clock) - scoring_event = game.get("scoring_event", "") - down_distance = game.get("down_distance_text", "") - if self.display_width > 128: - down_distance = game.get("down_distance_text_long", "") - - # Show scoring event if detected, otherwise show down & distance - if scoring_event and game.get("is_live"): - # Display scoring event with special formatting - event_width = draw_overlay.textlength(scoring_event, font=self.fonts['detail']) - event_x = (self.display_width - event_width) // 2 - event_y = (self.display_height) - 7 - - # Color coding for different scoring events - if scoring_event == "TOUCHDOWN": - event_color = (255, 215, 0) # Gold - elif scoring_event == "FIELD GOAL": - event_color = (0, 255, 0) # Green - elif scoring_event == "PAT": - event_color = (255, 165, 0) # Orange - else: - event_color = (255, 255, 255) # White - - self._draw_text_with_outline(draw_overlay, scoring_event, (event_x, event_y), self.fonts['detail'], fill=event_color) - elif down_distance and game.get("is_live"): # Only show if live and available - dd_width = draw_overlay.textlength(down_distance, font=self.fonts['detail']) - dd_x = (self.display_width - dd_width) // 2 - dd_y = (self.display_height)- 7 # Top of D&D text - down_color = (200, 200, 0) if not game.get("is_redzone", False) else (255,0,0) # Yellowish text - self._draw_text_with_outline(draw_overlay, down_distance, (dd_x, dd_y), self.fonts['detail'], fill=down_color) - - # Possession Indicator (small football icon) - possession = game.get("possession_indicator") - if possession: # Only draw if possession is known - ball_radius_x = 3 # Wider for football shape - ball_radius_y = 2 # Shorter for football shape - ball_color = (139, 69, 19) # Brown color for the football - lace_color = (255, 255, 255) # White for laces - - # Approximate height of the detail font (4x6 font at size 6 is roughly 6px tall) - detail_font_height_approx = 6 - ball_y_center = dd_y + (detail_font_height_approx // 2) # Center ball vertically with D&D text - - possession_ball_padding = 3 # Pixels between D&D text and ball - - if possession == "away": - # Position ball to the left of D&D text - ball_x_center = dd_x - possession_ball_padding - ball_radius_x - elif possession == "home": - # Position ball to the right of D&D text - ball_x_center = dd_x + dd_width + possession_ball_padding + ball_radius_x - else: - ball_x_center = 0 # Should not happen / no indicator - - if ball_x_center > 0: # Draw if position is valid - # Draw the football shape (ellipse) - draw_overlay.ellipse( - (ball_x_center - ball_radius_x, ball_y_center - ball_radius_y, # x0, y0 - ball_x_center + ball_radius_x, ball_y_center + ball_radius_y), # x1, y1 - fill=ball_color, outline=(0,0,0) - ) - # Draw a simple horizontal lace - draw_overlay.line( - (ball_x_center - 1, ball_y_center, ball_x_center + 1, ball_y_center), - fill=lace_color, width=1 - ) - - # Timeouts (Bottom corners) - 3 small bars per team - timeout_bar_width = 4 - timeout_bar_height = 2 - timeout_spacing = 1 - timeout_y = self.display_height - timeout_bar_height - 1 # Bottom edge - - # Away Timeouts (Bottom Left) - away_timeouts_remaining = game.get("away_timeouts", 0) - for i in range(3): - to_x = 2 + i * (timeout_bar_width + timeout_spacing) - color = (255, 255, 255) if i < away_timeouts_remaining else (80, 80, 80) # White if available, gray if used - draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0)) - - # Home Timeouts (Bottom Right) - home_timeouts_remaining = game.get("home_timeouts", 0) - for i in range(3): - to_x = self.display_width - 2 - timeout_bar_width - (2-i) * (timeout_bar_width + timeout_spacing) - color = (255, 255, 255) if i < home_timeouts_remaining else (80, 80, 80) # White if available, gray if used - draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0)) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get('away_abbr', '') - home_abbr = game.get('home_abbr', '') - - record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - 4 - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = '' - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = '' - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get('away_record', '') - else: - away_text = '' - - if away_text: - away_record_x = 3 - self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = '' - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = '' - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get('home_record', '') - else: - home_text = '' - - if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 3 - self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix diff --git a/src/base_classes/hockey.py b/src/base_classes/hockey.py deleted file mode 100644 index 419f09ec..00000000 --- a/src/base_classes/hockey.py +++ /dev/null @@ -1,380 +0,0 @@ -import logging -from typing import Any, Dict, Optional - -from PIL import Image, ImageDraw, ImageFont - -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager - - -class Hockey(SportsCore): - """Base class for hockey sports with common functionality.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.data_source = ESPNDataSource(logger) - self.sport = "hockey" - self.show_shots_on_goal = self.mode_config.get("show_shots_on_goal", False) - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - # --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES --- - details, home_team, away_team, status, situation = ( - self._extract_game_details_common(game_event) - ) - if details is None or home_team is None or away_team is None or status is None: - return - try: - competition = game_event["competitions"][0] - status = competition["status"] - powerplay = False - penalties = "" - # A competitor may legitimately arrive without a "statistics" - # array (pre-game feeds, and some in-progress ones). Reading it - # unguarded raised KeyError inside the generator and dropped the - # WHOLE event, discarding valid scores and status. Default to an - # empty list so the saves/shots figures fall back to 0 instead. - home_stats = home_team.get("statistics", []) - away_stats = away_team.get("statistics", []) - home_team_saves = next( - ( - int(c["displayValue"]) - for c in home_stats - if c.get("name") == "saves" - ), - 0, - ) - home_team_saves_per = next( - ( - float(c["displayValue"]) - for c in home_stats - if c.get("name") == "savePct" - ), - 0.0, - ) - away_team_saves = next( - ( - int(c["displayValue"]) - for c in away_stats - if c.get("name") == "saves" - ), - 0, - ) - away_team_saves_per = next( - ( - float(c["displayValue"]) - for c in away_stats - if c.get("name") == "savePct" - ), - 0.0, - ) - - home_shots = 0 - away_shots = 0 - if home_team_saves_per > 0: - away_shots = round(home_team_saves / home_team_saves_per) - if away_team_saves_per > 0: - home_shots = round(away_team_saves / away_team_saves_per) - if situation and status["type"]["state"] == "in": - # Detect scoring events from status detail - # status_detail = status["type"].get("detail", "") - powerplay = situation.get("isPowerPlay", False) - penalties = situation.get("penalties", "") - - # Format period/quarter - period = status.get("period", 0) - period_text = "" - if status["type"]["state"] == "in": - if period == 0: - period_text = "Start" # Before kickoff - elif period >= 1 and period <= 3: - period_text = f"P{period}" # OT starts after Q4 - elif period > 3: - period_text = f"OT{period - 3}" # OT starts after Q4 - elif status["type"]["state"] == "post": - if period > 3: - period_text = "Final/OT" - else: - period_text = "Final" - elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming - - details.update( - { - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - "power_play": powerplay, - "penalties": penalties, - "home_shots": home_shots, - "away_shots": away_shots, - } - ) - - # Basic validation (can be expanded) - if not details["home_abbr"] or not details["away_abbr"]: - self.logger.warning( - f"Missing team abbreviation in event: {details['id']}" - ) - return None - - self.logger.debug( - f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" - ) - - return details - except Exception as e: - # Log the problematic event structure if possible - self.logger.error( - f"Error extracting game details: {e} from event: {game_event.get('id')}", - exc_info=True, - ) - return None - - -class HockeyLive(Hockey, SportsLive): - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - # For testing, we'll just update the clock to show it's working - minutes = int(self.current_game["clock"].split(":")[0]) - seconds = int(self.current_game["clock"].split(":")[1]) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - minutes = 19 - if self.current_game["period"] < 3: - self.current_game["period"] += 1 - else: - self.current_game["period"] = 1 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring - try: - main_img = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) - ) - overlay = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) - ) - draw_overlay = ImageDraw.Draw( - overlay - ) # Draw text elements on overlay first - home_logo = self._load_and_resize_logo( - game["home_id"], - game["home_abbr"], - game["home_logo_path"], - game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game["away_id"], - game["away_abbr"], - game["away_logo_path"], - game.get("away_logo_url"), - ) - - if not home_logo or not away_logo: - self.logger.error( - f"Failed to load logos for live game: {game.get('id')}" - ) # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) - self._draw_text_with_outline( - draw_final, "Logo Error", (5, 5), self.fonts["status"] - ) - self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = ( - self.display_width - home_logo.width + 10 - ) # adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 # adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Draw Text Elements on Overlay --- - # Note: Rankings are now handled in the records/rankings section below - - # Period/Quarter and Clock (Top center) - period_clock_text = ( - f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - ) - if game.get("is_period_break"): - period_clock_text = game.get("status_text", "Period Break") - - status_width = draw_overlay.textlength( - period_clock_text, font=self.fonts["time"] - ) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline( - draw_overlay, - period_clock_text, - (status_x, status_y), - self.fonts["time"], - ) - - # Scores (centered, slightly above bottom) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) - score_x = (self.display_width - score_width) // 2 - score_y = ( - self.display_height // 2 - ) - 3 # centered #from 14 # Position score higher - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] - ) - - # Shots on Goal - if self.show_shots_on_goal: - shots_font = self.fonts.get('detail', ImageFont.load_default()) - home_shots = str(game.get("home_shots", "0")) - away_shots = str(game.get("away_shots", "0")) - shots_text = f"{away_shots} SHOTS {home_shots}" - shots_bbox = draw_overlay.textbbox((0, 0), shots_text, font=shots_font) - shots_height = shots_bbox[3] - shots_bbox[1] - shots_y = self.display_height - shots_height - 1 - shots_width = draw_overlay.textlength(shots_text, font=shots_font) - shots_x = (self.display_width - shots_width) // 2 - self._draw_text_with_outline( - draw_overlay, shots_text, (shots_x, shots_y), shots_font - ) - - # Draw odds if available - if "odds" in game and game["odds"]: - self._draw_dynamic_odds( - draw_overlay, game["odds"], self.display_width, self.display_height - ) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get("away_abbr", "") - home_abbr = game.get("home_abbr", "") - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - 1 - self.logger.debug( - f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" - ) - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = "" - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = "" - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get("away_record", "") - else: - away_text = "" - - if away_text: - away_record_x = 3 - self.logger.debug( - f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - away_text, - (away_record_x, record_y), - record_font, - ) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = "" - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = "" - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get("home_record", "") - else: - home_text = "" - - if home_text: - home_record_bbox = draw_overlay.textbbox( - (0, 0), home_text, font=record_font - ) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 3 - self.logger.debug( - f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - home_text, - (home_record_x, record_y), - record_font, - ) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert("RGB") # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error( - f"Error displaying live Hockey game: {e}", exc_info=True - ) # Changed log prefix diff --git a/src/base_classes/sports/__init__.py b/src/base_classes/sports/__init__.py deleted file mode 100644 index a6def39f..00000000 --- a/src/base_classes/sports/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Sports scoreboard base classes. - -Formerly the single module ``src/base_classes/sports.py``; now a package so -capabilities can be composed instead of accumulating in one class. See -docs/SPORTS_UNIFICATION.md for the architecture. The import path is -unchanged: ``from src.base_classes.sports import SportsCore`` still works. -""" - -from .core import SportsCore -from .modes import SportsLive, SportsRecent, SportsUpcoming - -__all__ = [ - "SportsCore", - "SportsUpcoming", - "SportsRecent", - "SportsLive", -] diff --git a/src/base_classes/sports/capabilities/__init__.py b/src/base_classes/sports/capabilities/__init__.py deleted file mode 100644 index e084ad7c..00000000 --- a/src/base_classes/sports/capabilities/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Opt-in capabilities for the sports scoreboards. - -Each module here is a feature that only *some* sports want. They are composed -by inheritance (mixins) or selected by name (strategies) — never enabled by an -``if self._enabled:`` branch inside the base classes. - -The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not -inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not -in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in. - -See ``docs/SPORTS_UNIFICATION.md`` for the full rationale. -""" - -from .celebrations import CelebrationMixin -from .rotation import ( - RotationStrategy, - SimpleRotation, - SmoothWeightedRotation, - WeightedCycleRotation, - get_rotation_strategy, - register_rotation_strategy, -) - -__all__ = [ - "CelebrationMixin", - "RotationStrategy", - "SimpleRotation", - "SmoothWeightedRotation", - "WeightedCycleRotation", - "get_rotation_strategy", - "register_rotation_strategy", -] diff --git a/src/base_classes/sports/capabilities/celebrations.py b/src/base_classes/sports/capabilities/celebrations.py deleted file mode 100644 index 49d371cd..00000000 --- a/src/base_classes/sports/capabilities/celebrations.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Score / win celebration takeover — an opt-in capability. - -Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other -five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the -five that do not opt in have none of this code in their MRO: a bug here cannot -reach hockey, and hockey's config never grows keys it ignores. - -Usage — mix in *before* the mode class so its ``display`` runs first:: - - class SoccerLive(CelebrationMixin, SportsLive): - def score_phrase(self, points, team_abbr): - return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!")) - -The two lineages spelled this differently (``_check_for_goal`` / -``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` / -``celebrate_opponent_scores`` in football) but the bodies were identical apart -from three things, each of which is a seam here rather than a branch: - -* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN" - from the points delta and soccer uses to say "GOOOOAAALLL"; -* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football - where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where - two quick goals are two real events; -* **team identity** — matching goes through ``_favorite_key``, so nrl can match - on team id (its abbreviations are ambiguous) without core knowing why. - -The config keys are read under both spellings, so a plugin adopting the mixin -keeps working with the ``*_goals`` keys already in its published schema. -""" - -from __future__ import annotations - -import re -import time -from typing import Any, Dict, List, Optional - -from PIL import Image, ImageDraw - - -class CelebrationMixin: - """Full-screen takeover when a tracked team scores or wins.""" - - #: Collapse increments that land while a celebration is already on screen - #: into that one celebration. True for sports where a single scoring play - #: arrives as more than one score update (football: touchdown +6, then the - #: extra point +1). False where consecutive increments are distinct events — - #: suppressing there would swallow a real goal. - COALESCE_SCORING_SEQUENCE = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - mode_config = getattr(self, "mode_config", {}) or {} - self.celebration_enabled = mode_config.get("celebration_enabled", True) - # Coerced and floored at init: this value is compared numerically on the - # display path, where a string from a hand-edited config would raise - # TypeError outside any try block, and a zero or negative value would - # arm a celebration that can never render. - raw_duration = mode_config.get("celebration_duration", 8) - try: - self.celebration_duration = max(1.0, float(raw_duration)) - except (TypeError, ValueError): - self.logger.warning( - "[Celebrations] Unusable celebration_duration %r; using 8s. " - "Set a positive number of seconds.", - raw_duration, - ) - self.celebration_duration = 8.0 - # Both spellings: the soccer lineage ships `celebrate_opponent_goals`, - # football ships `celebrate_opponent_scores`. Whichever the plugin's - # schema declares is the one its users have set. - self.celebrate_opponent_scores = mode_config.get( - "celebrate_opponent_scores", - mode_config.get("celebrate_opponent_goals", False), - ) - # Per-game score baselines: {game_id: {"away": int, "home": int}} - self._score_baselines: Dict[str, Dict[str, int]] = {} - # The active celebration (a game *snapshot*, so a win survives the game - # leaving live_games) or None. See _start_celebration for the shape. - self.active_celebration: Optional[Dict[str, Any]] = None - - # ------------------------------------------------------------------ - # Override points - # ------------------------------------------------------------------ - - def score_phrase(self, points: int, team_abbr: str) -> str: - """The wording for a score celebration. - - ``points`` is the score delta that triggered it, which sports with - variable-value scores use to name the play. The default is deliberately - sport-neutral; every celebrating plugin overrides it. - """ - return f"{team_abbr} SCORES!" - - def win_phrase(self, team_abbr: str) -> str: - """The wording for a win celebration.""" - return f"{team_abbr} WINS!" - - def _is_favorite(self, key: Optional[str]) -> bool: - """Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite.""" - return bool(self.favorite_teams) and key in self.favorite_teams - - # ------------------------------------------------------------------ - # Detection - # ------------------------------------------------------------------ - - @staticmethod - def _score_to_int(score) -> Optional[int]: - """Coerce an ESPN score value (str / int / dict) to an int, or None.""" - try: - if score is None: - return None - if isinstance(score, str): - s = score.strip() - if not s: - return None - try: - return int(float(s)) - except ValueError: - numbers = re.findall(r"\d+", s) - return int(numbers[0]) if numbers else None - if isinstance(score, dict): - return int(float(score.get("value", score.get("displayValue", 0)))) - return int(float(score)) - except (ValueError, TypeError): - return None - - def _should_celebrate_for(self, game: Dict, side: str) -> bool: - """Whether a score by ``side`` in ``game`` should trigger a celebration.""" - if self._is_favorite(self._favorite_key(game, side)): - return True - if not self.favorite_teams: - # No favorites configured: the user opted to show this game, so - # celebrate any score in it. - return True - # Favorites exist but this team isn't one -> it's the opponent. - return self.celebrate_opponent_scores - - def prune_score_baselines(self, live_games: List[Dict]) -> None: - """Drop baselines for games no longer live. - - Only :meth:`_check_for_win` removes entries, and it only fires for games - seen to go final. A game that vanishes from the live list any other way - — postponed, dropped by the feed, or simply still live when the board - restarts — leaves its baseline behind forever, so on a board that runs - all season the dict grows without bound. - - Call this from ``update()`` with the current live set, alongside the - equivalent pruning in :meth:`SmoothWeightedRotation.next_game`. - """ - live_ids = {g.get("id") for g in live_games} - self._score_baselines = { - gid: baseline - for gid, baseline in self._score_baselines.items() - if gid in live_ids - } - - def has_active_celebration(self) -> bool: - """True while a celebration is within its display window.""" - celebration = self.active_celebration - return bool(celebration) and ( - time.time() - celebration["started_at"] < self.celebration_duration - ) - - def _check_for_score(self, game: Dict) -> None: - """Compare a live game's score against its baseline and arm a - celebration when a celebratable team's score increases.""" - if not self.celebration_enabled: - return - game_id = game.get("id") - if not game_id: - return - away = self._score_to_int(game.get("away_score")) - home = self._score_to_int(game.get("home_score")) - if away is None or home is None: - return - - baseline = self._score_baselines.get(game_id) - # Always refresh the baseline: a first sighting must never celebrate (a - # game already in progress at boot would false-fire), and a decrement - # (VAR, a correction) just re-bases silently. - self._score_baselines[game_id] = {"away": away, "home": home} - if baseline is None: - return - - away_delta = away - baseline["away"] - home_delta = home - baseline["home"] - if away_delta <= 0 and home_delta <= 0: - return - - # One takeover per scoring sequence, where the sport has such a thing. - # The baseline is already advanced above, so nothing re-fires later. - if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration(): - return - - scored_side = None - points = 0 - if away_delta > 0 and self._should_celebrate_for(game, "away"): - scored_side, points = "away", away_delta - if scored_side is None and home_delta > 0 and self._should_celebrate_for( - game, "home" - ): - scored_side, points = "home", home_delta - if scored_side is None: - return - - self._start_celebration( - game, - "score", - scored_side=scored_side, - team_abbr=game.get(f"{scored_side}_abbr", ""), - away_score=away, - home_score=home, - points=points, - ) - - def _check_for_win(self, game: Dict) -> None: - """When a game we were tracking live goes final, arm a win celebration - if a favorite won. Fires at most once per game.""" - if not self.celebration_enabled: - return - game_id = game.get("id") - if not game_id: - return - # Only celebrate wins for games we actually watched go live: one seen - # for the first time already-final (the board started after full time) - # has no baseline and must not fire. - if game_id not in self._score_baselines: - return - # Consume the baseline so this can only fire once. - self._score_baselines.pop(game_id, None) - - away = self._score_to_int(game.get("away_score")) - home = self._score_to_int(game.get("home_score")) - if away is None or home is None: - return - - if away > home: - winner_side = "away" - elif home > away: - winner_side = "home" - else: - return # draw -> no win celebration - - # Wins are gated strictly on favorites: every game ends, so the - # "no favorites -> celebrate all" score fallback would be far too noisy. - if not self._is_favorite(self._favorite_key(game, winner_side)): - return - - self._start_celebration( - game, - "win", - scored_side=winner_side, - team_abbr=game.get(f"{winner_side}_abbr", ""), - away_score=away, - home_score=home, - ) - - def _start_celebration( - self, - game: Dict, - kind: str, - scored_side: str, - team_abbr: str, - away_score: int, - home_score: int, - points: int = 0, - ) -> None: - """Arm a celebration. ``scored_side`` ('away'/'home') is the side whose - score digit gets highlighted.""" - phrase = ( - self.win_phrase(team_abbr) - if kind == "win" - else self.score_phrase(points, team_abbr) - ) - - self.active_celebration = { - "kind": kind, - "game": dict(game), # snapshot: survives the game leaving live_games - "scored_side": scored_side, - "team_abbr": team_abbr, - "away_score": away_score, - "home_score": home_score, - "started_at": time.time(), - "phrase": phrase, - } - # Pin focus to the involved game so the post-celebration scorebug - # resumes on it. - self.current_game = dict(game) - self.logger.info( - f"[Celebrations] {kind} armed: {phrase} " - f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]" - ) - - # ------------------------------------------------------------------ - # Rendering - # ------------------------------------------------------------------ - - def _fit_font(self, draw, text: str, max_width: int, fonts: List): - """The first font whose rendered ``text`` fits ``max_width``, falling - back to the last (smallest) font.""" - for font in fonts: - if draw.textlength(text, font=font) <= max_width - 2: - return font - return fonts[-1] - - def _draw_celebration_layout( - self, celebration: Dict, force_clear: bool = False - ) -> None: - """Render the full-screen score/win takeover.""" - if force_clear: - self.display_manager.clear() - - display_width = ( - self.display_manager.matrix.width - if hasattr(self.display_manager, "matrix") and self.display_manager.matrix - else self.display_width - ) - display_height = ( - self.display_manager.matrix.height - if hasattr(self.display_manager, "matrix") and self.display_manager.matrix - else self.display_height - ) - - elapsed = time.time() - celebration["started_at"] - game = celebration["game"] - - # Background: a brief color flash for the first ~1.2s, then black. - bg = (0, 0, 0, 255) - if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0: - bg = (12, 12, 48, 255) - main_img = Image.new("RGBA", (display_width, display_height), bg) - overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0)) - draw = ImageDraw.Draw(overlay) - - # Logos at the edges (best-effort: a logo failure must not blank the - # celebration). - try: - center_y = display_height // 2 - home_logo = self._load_and_resize_logo( - game.get("home_id"), game.get("home_abbr"), - game.get("home_logo_path"), game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game.get("away_id"), game.get("away_abbr"), - game.get("away_logo_path"), game.get("away_logo_url"), - ) - if home_logo: - main_img.paste( - home_logo, - (display_width - home_logo.width + 2, center_y - home_logo.height // 2), - home_logo, - ) - if away_logo: - main_img.paste( - away_logo, (-2, center_y - away_logo.height // 2), away_logo - ) - except Exception as e: - self.logger.debug(f"[Celebrations] Logo load failed: {e}") - - # Phrase across the top, shrunk to fit the panel width. - phrase = celebration["phrase"] - phrase_font = self._fit_font( - draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]] - ) - phrase_width = draw.textlength(phrase, font=phrase_font) - self._draw_text_with_outline( - draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font - ) - - # Score centered low, with the scoring/winning side's digit pulsing in a - # highlight color so the change reads at a glance. - away_text = str(celebration["away_score"]) - home_text = str(celebration["home_score"]) - score_font = self.fonts["score"] - segments = [ - (away_text, celebration["scored_side"] == "away"), - ("-", False), - (home_text, celebration["scored_side"] == "home"), - ] - total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments) - highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0) - x = (display_width - total_width) // 2 - y = display_height - 14 - for seg, is_highlight in segments: - color = highlight if is_highlight else (255, 255, 255) - self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color) - x += draw.textlength(seg, font=score_font) - - main_img = Image.alpha_composite(main_img, overlay).convert("RGB") - self.display_manager.image = main_img - self.display_manager.update_display() - - def display(self, force_clear: bool = False) -> bool: - """Render an active celebration as a full-screen takeover; otherwise - defer to the normal live scorebug.""" - if not self.is_enabled: - return False - celebration = self.active_celebration - if celebration: - if self.has_active_celebration(): - try: - self._draw_celebration_layout(celebration, force_clear) - return True - except Exception as e: - self.logger.error( - f"[Celebrations] Error drawing celebration: {e}", exc_info=True - ) - # Disarm rather than retry: the same render would fail on - # every frame for the rest of the window, logging a - # traceback each time and leaving the scorebug off screen. - self.active_celebration = None - self.last_game_switch = time.time() - else: - self.active_celebration = None - # Reset the dwell so the scorebug resumes on the scoring/winning - # game for a full duration before rotation can move on. - self.last_game_switch = time.time() - return super().display(force_clear) diff --git a/src/base_classes/sports/capabilities/rotation.py b/src/base_classes/sports/capabilities/rotation.py deleted file mode 100644 index 4135ca9e..00000000 --- a/src/base_classes/sports/capabilities/rotation.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Live-rotation strategies — which live game to show next. - -The nine plugin copies grew three spellings of this, and the survey behind -``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted -Round-Robin algorithm in two shapes: - -* an **incremental picker** that holds weight state across calls and answers - "what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and -* a **precomputed cycle** that returns a full list of game ids up front - (football / baseball / basketball's ``_build_weighted_schedule`` and hockey's - ``_build_rotation_schedule``, which differ only in loop shape). - -They agree *within* a cycle — SWRR is deterministic — and differ only at cycle -boundaries, where the incremental form has no seam and the precomputed form -restarts. That is a real behavioral difference, so core ships both rather than -declaring a winner, and a plugin picks one by name: - - self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight) - -Core never learns which sport is asking. A plugin with a genuinely novel -ordering registers its own strategy instead of core growing a branch:: - - register_rotation_strategy("my-order", MyRotation) -""" - -from __future__ import annotations - -from typing import Callable, Dict, List, Optional, Type - - -def _game_id(game: Dict) -> Optional[str]: - """The rotation key for a game, or None if it has no usable id.""" - return game.get("id") - - -class RotationStrategy: - """Base class for live-rotation ordering. - - Subclasses implement :meth:`schedule`; :meth:`next_game` has a working - default derived from it. Strategies whose natural shape is incremental - override :meth:`next_game` instead and derive :meth:`schedule`. - - :param weight_for: callable mapping a game dict to a positive integer - weight — how many turns it gets per turn of a weight-1 game. Supplied by - the host so the *favorites* policy stays with the plugin and this module - stays free of any notion of what a favorite is. Defaults to equal - weights, which makes every strategy a plain round robin. - """ - - #: Name this strategy is registered under. Set by :func:`register_rotation_strategy`. - name: str = "" - - #: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each - #: step scans every game, so an unbounded weight — a misread config field, - #: say — would spin the display thread for an unbounded time. On a Pi that - #: stalls rendering outright, so the bound is clamped like the floor is. - MAX_WEIGHT = 16 - - def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None): - self._weight_for = weight_for or (lambda game: 1) - - def weights(self, games: List[Dict]) -> Dict[str, int]: - """``{game_id: weight}`` for games that have an id, in ``games`` order. - - A weight below 1 is clamped up: a zero or negative weight would starve - a game out of the rotation entirely, which no caller means to express - and which would make ``total_weight`` collapse. It is clamped down at - :attr:`MAX_WEIGHT` for the reason documented there. - """ - weights: Dict[str, int] = {} - for game in games: - gid = _game_id(game) - if gid is None: - continue - try: - weight = int(self._weight_for(game)) - except (TypeError, ValueError): - weight = 1 - weights[gid] = min(self.MAX_WEIGHT, max(1, weight)) - return weights - - def schedule(self, games: List[Dict]) -> List[str]: - """Game ids in display order for one cycle. Ids may repeat.""" - raise NotImplementedError - - def next_game(self, games: List[Dict]) -> Optional[Dict]: - """The next game to display, or None when there is nothing to show.""" - order = self.schedule(games) - if not order: - return None - by_id = {gid: g for g in games if (gid := _game_id(g)) is not None} - return by_id.get(order[0]) - - def reset(self) -> None: - """Drop any accumulated state. Stateless strategies need do nothing.""" - - -class SimpleRotation(RotationStrategy): - """Plain round robin: every live game once per cycle, weights ignored. - - The fallback for a plugin that wants strictly even rotation regardless of - favorites. - """ - - def schedule(self, games: List[Dict]) -> List[str]: - return [gid for g in games if (gid := _game_id(g)) is not None] - - -class WeightedCycleRotation(RotationStrategy): - """Precomputed SWRR cycle — the football / baseball / basketball / hockey shape. - - Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly - rather than clumped, highest weight scheduled first. When no game carries a - boost the cycle degenerates to a single pass in ``games`` order, which is - exactly the plain round robin it replaced. - """ - - def schedule(self, games: List[Dict]) -> List[str]: - weights = self.weights(games) - if not weights: - return [] - total_weight = sum(weights.values()) - if total_weight <= len(weights): - # No boost in effect — plain order, one pass. (Also the guard that - # keeps the loop below from being O(total_weight) for nothing.) - return list(weights) - - current = {gid: 0 for gid in weights} - order: List[str] = [] - for _ in range(total_weight): - for gid, weight in weights.items(): - current[gid] += weight - picked = max(current, key=lambda gid: current[gid]) - current[picked] -= total_weight - order.append(picked) - return order - - -class SmoothWeightedRotation(RotationStrategy): - """Incremental SWRR — the afl / nrl / soccer shape. - - Weight state persists across calls, so there is no fixed-length cycle and - therefore no clustering seam at a cycle boundary. A game seen for the first - time starts at weight 0 and receives its full weight on the next call, so a - favorite's game that has just gone live naturally wins the first pick after - it appears — "queued first on refresh" without a special-cased branch. - - State for games no longer live is dropped on each call, so a long-running - board does not accumulate entries for finished games. - """ - - def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None): - super().__init__(weight_for) - self._current: Dict[str, int] = {} - - def reset(self) -> None: - self._current = {} - - def next_game(self, games: List[Dict]) -> Optional[Dict]: - if not games: - return None - weights = self.weights(games) - if not weights: - return None - - # Keep state only for games still live. - self._current = { - gid: value for gid, value in self._current.items() if gid in weights - } - for gid, weight in weights.items(): - self._current[gid] = self._current.get(gid, 0) + weight - - total_weight = sum(weights.values()) - # Iterate in `games` order so ties break toward the feed's ordering, - # which is what the plugin copies did and what makes the no-boost case - # identical to a plain round robin. - ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights] - best = max(ids_in_order, key=lambda gid: self._current[gid]) - self._current[best] -= total_weight - return next(g for g in games if _game_id(g) == best) - - def schedule(self, games: List[Dict]) -> List[str]: - """One cycle's worth of picks, without disturbing live state. - - Derived by running the picker forward on a copy, so the returned order - is exactly what repeated :meth:`next_game` calls would produce from the - current state — callers can use it to preview or log the rotation - without perturbing it. - """ - weights = self.weights(games) - if not weights: - return [] - # type(self), not this class: a subclass that overrides next_game must - # be previewed through its own ordering, or the returned order is not - # the one repeated next_game calls would produce — which is exactly - # what this method promises. - preview = type(self)(self._weight_for) - preview._current = dict(self._current) - order: List[str] = [] - for _ in range(sum(weights.values())): - picked = preview.next_game(games) - if picked is None: - break - order.append(_game_id(picked)) - return order - - -_REGISTRY: Dict[str, Type[RotationStrategy]] = {} - - -def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None: - """Register a rotation strategy under ``name``. - - When a plugin needs an ordering that core does not ship, it registers its - own here instead of core growing a sport-specific branch. Re-registering a - name replaces it, so a plugin may also override a built-in for itself. - """ - if not name: - raise ValueError("rotation strategy name must be a non-empty string") - # Fail at registration, not at the first schedule() call several frames - # later, where the cause is no longer on the stack. - if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)): - raise TypeError( - f"rotation strategy {name!r} must be a RotationStrategy subclass, " - f"got {factory!r}" - ) - factory.name = name - _REGISTRY[name] = factory - - -def get_rotation_strategy( - name: str, weight_for: Optional[Callable[[Dict], int]] = None -) -> RotationStrategy: - """Build the strategy registered under ``name``. - - Falls back to ``"simple"`` for an unknown name rather than raising: the name - arrives from user config, and a typo should cost the boost, not the - scoreboard. - """ - factory = _REGISTRY.get(name) or _REGISTRY["simple"] - return factory(weight_for=weight_for) - - -register_rotation_strategy("simple", SimpleRotation) -register_rotation_strategy("weighted", WeightedCycleRotation) -register_rotation_strategy("swrr", SmoothWeightedRotation) diff --git a/src/base_classes/sports/core.py b/src/base_classes/sports/core.py deleted file mode 100644 index c4969386..00000000 --- a/src/base_classes/sports/core.py +++ /dev/null @@ -1,1148 +0,0 @@ -"""SportsCore — the shared fetch/cache/config/render base for the sports -scoreboards. Split out of the former ``src/base_classes/sports.py``; see -docs/SPORTS_UNIFICATION.md for the layering. -""" - -import logging -import os -import tempfile -import time -from abc import ABC, abstractmethod -from collections import OrderedDict -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import pytz -from src.common.espn_dates import fetch_espn_scoreboard -import requests -from PIL import Image, ImageDraw, ImageFont -from src.common.font_layout import load_truetype -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -from src.background_data_service import get_background_service - -# Import new architecture components (individual classes will import what they need) -from src.base_classes.api_extractors import APIDataExtractor -from src.base_classes.data_sources import DataSource -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager -from src.dynamic_team_resolver import DynamicTeamResolver -from src.logo_downloader import LogoDownloader, download_missing_logo -try: - from src.base_odds_manager import BaseOddsManager as OddsManager -except ImportError: - OddsManager = None - -# The core install root, resolved from this module's own location so nothing -# here depends on the process working directory. -# -# The index is the number of directories between this file and the root: -# src/base_classes/sports/core.py -> sports -> base_classes -> src -> root. -# It is defined ONCE, here, because hand-counting it at each use site is -# exactly what broke when this module moved from src/base_classes/sports.py -# into the package (the old depth of 2 silently started resolving to src/). -# If this module ever moves again, this is the single line to update. -_INSTALL_ROOT = Path(__file__).resolve().parents[3] - -# Shared element-style resolver. Core always ships src.element_style, so the -# guard is never taken here — it is kept because this module's methods are -# back-copied verbatim into the plugins' bundled `sports.py`, where older -# cores genuinely lack the module (the plugins fall back to the classic -# inline config read below). -try: - from src.element_style import ElementStyleResolver, defaults_from_schema_file - STYLE_AVAILABLE = True -except ImportError: # pragma: no cover - core always ships element_style - STYLE_AVAILABLE = False - - -# --- Font catalog delegation ------------------------------------------------- -# The plugin copies each carried their own alias table mapping font *family* -# names ("press_start") to filenames. That table is a duplicate of the core -# FontManager's `common_fonts` catalog, so resolve through the catalog instead -# and let the two never drift apart. Same lazy module-level shape as -# skin_runtime._get_font_manager / base_plugin._fallback_font_manager: a -# SportsCore host has no plugin_manager to borrow a FontManager from. - -_shared_font_catalog: Optional[Any] = None - - -def _font_catalog() -> Any: - """Shared read-only FontManager used purely as a font-name catalog.""" - global _shared_font_catalog - if _shared_font_catalog is None: - from src.font_manager import FontManager - _shared_font_catalog = FontManager({}) - return _shared_font_catalog - - -def _resolve_font_family_alias(font_name: str) -> str: - """Resolve a font family alias to its filename, leaving filenames as-is. - - A config `font` value may be either a family name from the FontManager - catalog ("press_start", "four_by_six", "five_by_seven") or a literal - filename; the former resolve here, the latter pass through unchanged. - """ - try: - catalogued = _font_catalog().common_fonts.get(font_name) - except Exception: - return font_name - return os.path.basename(catalogued) if catalogued else font_name - - -def _read_bdf_native_size(bdf_path: str) -> Optional[int]: - """A BDF file's one true pixel size, delegated to FontManager. - - Deliberately NOT reimplemented here: the plugin copies grew a variant - that scans the whole file and returns a partially collected value when - parsing raises, where FontManager's stops at the first STARTCHAR and - returns None on error. - """ - try: - from src.font_manager import FontManager - return FontManager._read_bdf_native_size(bdf_path) - except Exception: - return None - - -class SportsCore(ABC): - # Which ScoreboardSkin render method this class's display path maps to. - # SportsLive inherits the default; SportsUpcoming/SportsRecent override. - SKIN_MODE = "live" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - self.logger = logger - self.config = config - self.cache_manager = cache_manager - self.config_manager = self.cache_manager.config_manager - if OddsManager: - try: - self.odds_manager = OddsManager( - self.cache_manager, self.config_manager) - except Exception as e: - self.logger.warning(f"Failed to initialize OddsManager: {e}") - self.odds_manager = None - else: - self.odds_manager = None - self.logger.warning("OddsManager not available - odds functionality disabled") - self.display_manager = display_manager - self.display_width = self.display_manager.matrix.width - self.display_height = self.display_manager.matrix.height - - self.sport_key = sport_key - self.sport = None - self.league = None - - # Initialize new architecture components (will be overridden by sport-specific classes) - self.sport_config = None - self.api_extractor: APIDataExtractor - self.data_source: DataSource - self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key - self.is_enabled: bool = self.mode_config.get("enabled", False) - self.show_odds: bool = self.mode_config.get("show_odds", False) - # Use LogoDownloader to get the correct default logo directory for this sport - default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key)) - self.logo_dir = self._initialize_logo_dir(default_logo_dir) - self.update_interval: int = self.mode_config.get( - "update_interval_seconds", 60) - self.show_records: bool = self.mode_config.get('show_records', False) - self.show_ranking: bool = self.mode_config.get('show_ranking', False) - # Number of games to show (instead of time-based windows) - self.recent_games_to_show: int = self.mode_config.get( - "recent_games_to_show", 5) # Show last 5 games - self.upcoming_games_to_show: int = self.mode_config.get( - "upcoming_games_to_show", 10) # Show next 10 games - self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False) - self.show_all_live: bool = self.mode_config.get("show_all_live", False) - - self.session = requests.Session() - retry_strategy = Retry( - total=5, # increased number of retries - backoff_factor=1, # increased backoff factor - # added 429 to retry list - status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["GET", "HEAD", "OPTIONS"] - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("https://", adapter) - self.session.mount("http://", adapter) - - # LRU-bounded: entries are decoded RGBA thumbnails, not file bytes. - # Each is up to display_width*1.5 x display_height*1.5 -- about 36KB on - # a 256x64 panel, more for wide wordmarks. The key is a team - # abbreviation and assets/sports/ncaa_logos alone ships 307 of them, so - # an unbounded dict here held the whole league: ~11-18MB per manager - # instance, and a league runs three (live/recent/upcoming) each with - # its own cache. That is real money on a 1GB Pi. - self._logo_cache: "OrderedDict[str, Image.Image]" = OrderedDict() - - # Font caches for _load_custom_font_from_element_config: per-frame - # callers (font-ladder walks) resolve the same (name, size) over and - # over, and the BDF strike size means re-reading a file header. - # Both are released in cleanup(). - self._font_cache: Dict[Tuple[str, int], Any] = {} - self._bdf_native_size_cache: Dict[str, Optional[int]] = {} - - # Set up headers - self.headers = { - 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', - 'Accept': 'application/json', - 'Accept-Language': 'en-US,en;q=0.9', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive' - } - self.last_update = 0 - self.current_game = None - # Cooldown clocks for _should_log(), one per warning type. Initialized - # here rather than lazily: _should_log() reads them unguarded, so - # whichever warning fires first would otherwise raise AttributeError - # instead of logging. `_last_warning_time` is the single-clock field - # the plugin copies expose; kept for subclasses that read it. - self._warning_cooldowns: Dict[str, float] = {} - self._last_warning_time = 0 - self.fonts = self._load_fonts() - - # Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a - # skin id applied to all modes, or a per-mode mapping like - # {"live": "retro", "recent": "built-in"}. Loaded lazily on first - # render so a broken skin can never block startup. - self._skin_config = self.mode_config.get("skin") - self.skin_options = self.mode_config.get("skin_options", {}) or {} - self._skin = None - self._skin_load_attempted = False - self._skin_failures = 0 - self._skin_slow_renders = 0 - - # Initialize dynamic team resolver and resolve favorite teams - self.dynamic_resolver = DynamicTeamResolver() - raw_favorite_teams = self.mode_config.get("favorite_teams", []) - self.favorite_teams = self.dynamic_resolver.resolve_teams(raw_favorite_teams, sport_key) - - # Log dynamic team resolution - if raw_favorite_teams != self.favorite_teams: - self.logger.info(f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}") - else: - self.logger.info(f"Favorite teams: {self.favorite_teams}") - - self.logger.setLevel(logging.INFO) - - # Initialize team rankings cache - self._team_rankings_cache = {} - self._rankings_cache_timestamp = 0 - self._rankings_cache_duration = 3600 # Cache rankings for 1 hour - - # Initialize background data service with optimized settings - # Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries - self.background_service = get_background_service(self.cache_manager, max_workers=1) - self.background_fetch_requests = {} # Track background fetch requests - self.background_enabled = True - self.logger.info("Background service enabled with 1 worker (memory optimized)") - - def _initialize_logo_dir(self, configured_path: Path) -> Path: - """Resolve and ensure a writable logo directory, falling back when necessary.""" - downloader = LogoDownloader() - resolved_configured = self._resolve_project_path(configured_path) - candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured) - - for candidate in candidates: - candidate_path = self._resolve_project_path(candidate) - if downloader.ensure_logo_directory(str(candidate_path)): - if candidate_path != resolved_configured: - self.logger.warning( - "Configured logo directory '%s' is not writable; using fallback '%s'", - resolved_configured, - candidate_path, - ) - return candidate_path - - self.logger.error( - "Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)", - resolved_configured, - ) - return resolved_configured - - def _resolve_project_path(self, path: Path) -> Path: - """Convert relative paths to absolute ones rooted at the project directory.""" - if path.is_absolute(): - return path - return (_INSTALL_ROOT / path).resolve() - - def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]: - """Return fallback directories to try when the configured directory is not writable.""" - fallbacks: List[Path] = [] - - env_override = os.environ.get("LEDMATRIX_LOGO_DIR") - if env_override: - env_path = Path(env_override) - if not env_path.is_absolute(): - env_path = self._resolve_project_path(env_path) - fallbacks.append(env_path / self.sport_key) - - cache_dir = getattr(self.cache_manager, "cache_dir", None) - if cache_dir: - fallbacks.append(Path(cache_dir) / "logos" / self.sport_key) - - try: - fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key) - except RuntimeError as e: - self.logger.debug("Could not resolve home directory (expected for service users): %s", e) - - fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key) - - unique_fallbacks: List[Path] = [] - seen = set() - for candidate in fallbacks: - if candidate == configured_dir: - continue - if candidate not in seen: - unique_fallbacks.append(candidate) - seen.add(candidate) - - return unique_fallbacks - - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts['status']) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True) - - - def _resolve_skin_id(self) -> Optional[str]: - """The skin id configured for this instance's mode, or None for the - built-in renderer. Accepts a plain id (all modes) or a per-mode - mapping ({"live": "retro-baseball", "recent": "built-in"}).""" - skin_id = self._skin_config - if isinstance(skin_id, dict): - skin_id = skin_id.get(self.SKIN_MODE) - if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in": - return None - return skin_id - - def _get_skin(self): - """Lazily load the configured skin once. Returns None (built-in - renderer) when no skin is configured or loading failed.""" - if not self._skin_load_attempted: - self._skin_load_attempted = True - skin_id = self._resolve_skin_id() - if skin_id: - try: - from src.skin_system import skin_runtime - self._skin = skin_runtime.load_skin( - skin_id, sport=self.sport, sport_key=self.sport_key, - options=self.skin_options) - except Exception as e: - self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True) - self._skin = None - return self._skin - - def _render_game(self, game: Dict, force_clear: bool = False) -> None: - """Render one game: try the configured skin first, fall back to the - built-in _draw_scorebug_layout. A skin that raises 3 times in a row - is disabled for the rest of the session.""" - skin = self._get_skin() - if skin is not None and self._skin_failures < 3: - try: - from src.skin_system import skin_runtime - ctx = skin_runtime.build_context(self, game) - render = getattr(skin, f"render_{self.SKIN_MODE}") - started = time.monotonic() - handled = render(ctx, dict(game)) - elapsed = time.monotonic() - started - if elapsed > 0.15 and self._skin_slow_renders < 5: - self._skin_slow_renders += 1 - self.logger.warning( - f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to " - f"render {self.SKIN_MODE} — slow renders stall the whole display loop") - if handled: - self._skin_failures = 0 - self.display_manager.image.paste(ctx.canvas, (0, 0)) - self.display_manager.update_display() - return - except Exception: - self._skin_failures += 1 - outcome = ("disabling skin for this session" if self._skin_failures >= 3 - else "falling back to built-in renderer") - self.logger.error( - f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} " - f"({self._skin_failures}/3); {outcome}", exc_info=True) - self._draw_scorebug_layout(game, force_clear) - - def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]: - """Render one game as a standalone card via the configured skin — - for vegas mode and previews. Tries render_vegas_card at the given - size, then the mode renderer on a card-sized canvas. Returns None - when no skin is active or the skin declined, so callers can use - their default rendering.""" - skin = self._get_skin() - if skin is None or self._skin_failures >= 3: - return None - try: - from src.skin_system import skin_runtime - ctx = skin_runtime.build_context(self, game, size=size) - card = skin.render_vegas_card(ctx, dict(game)) - if card is not None: - # A successful render clears accumulated strikes, mirroring - # _render_game — transient failures must not add up across - # the session and disable a working skin. - self._skin_failures = 0 - return card - ctx = skin_runtime.build_context(self, game, size=size) - render = getattr(skin, f"render_{self.SKIN_MODE}") - if render(ctx, dict(game)): - self._skin_failures = 0 - return ctx.canvas - except Exception: - # Card failures count toward the same 3-strike session disable - # as display failures — a skin broken for vegas shouldn't get - # to throw on every scroll tick forever. - self._skin_failures += 1 - self.logger.error( - f"Skin '{self._resolve_skin_id()}' card render failed " - f"({self._skin_failures}/3)", exc_info=True) - return None - - def display(self, force_clear: bool = False) -> bool: - """Common display method for all NCAA FB managers""" # Updated docstring - if not self.is_enabled: # Check if module is enabled - return False - - if not self.current_game: - # Clear display if force_clear is True, even when there's no content - # This prevents black screens when switching to modes with no content - if force_clear: - try: - self.display_manager.clear() - self.display_manager.update_display() - except Exception as e: - self.logger.debug(f"Error clearing display when no content: {e}") - - current_time = time.time() - if not hasattr(self, '_last_warning_time'): - self._last_warning_time = 0 - if current_time - getattr(self, '_last_warning_time', 0) > 300: - self.logger.warning(f"No game data available to display in {self.__class__.__name__}") - setattr(self, '_last_warning_time', current_time) - return False - - try: - self._render_game(self.current_game, force_clear) - # display_manager.update_display() should be called within subclass draw methods - # or after calling display() in the main loop. Let's keep it out of the base display. - return True - except Exception as e: - self.logger.error(f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True) - return False - - - def _load_fonts(self) -> Dict[str, Any]: - """Load fonts used by the scoreboard. - - Paths go through :meth:`_resolve_font_path` so the bundled fonts are - found regardless of the process working directory — a bare - ``"assets/fonts/..."`` silently degraded every scoreboard to the PIL - default font whenever the process started elsewhere (the plugin safety - harness on CI being the case that surfaced it). - """ - fonts: Dict[str, Any] = {} - press_start = self._resolve_font_path("PressStart2P-Regular.ttf") - four_by_six = self._resolve_font_path("4x6-font.ttf") - try: - fonts['score'] = load_truetype(press_start, 10) - fonts['time'] = load_truetype(press_start, 8) - fonts['team'] = load_truetype(press_start, 8) - fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status - fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font - fonts['rank'] = load_truetype(press_start, 10) - self.logger.info("Successfully loaded fonts") - except OSError: - # Name the directory we searched: the usual cause is an install - # whose assets/fonts is missing, and the bare message sent people - # hunting for a font-format problem instead. - self.logger.warning( - "Fonts not found under %s, using default PIL font.", - self._font_root(), - ) - fonts['score'] = ImageFont.load_default() - fonts['time'] = ImageFont.load_default() - fonts['team'] = ImageFont.load_default() - fonts['status'] = ImageFont.load_default() - fonts['detail'] = ImageFont.load_default() - fonts['rank'] = ImageFont.load_default() - return fonts - - def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: - """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" - home_team_odds = odds.get('home_team_odds', {}) - away_team_odds = odds.get('away_team_odds', {}) - home_spread = home_team_odds.get('spread_odds') - away_spread = away_team_odds.get('spread_odds') - - # Get top-level spread as fallback - top_level_spread = odds.get('spread') - - # If we have a top-level spread and the individual spreads are None or 0, use the top-level - if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: - home_spread = top_level_spread - if away_spread is None: - away_spread = -top_level_spread - - # Determine which team is favored (has negative spread) - home_favored = home_spread is not None and home_spread < 0 - away_favored = away_spread is not None and away_spread < 0 - - # Only show the negative spread (favored team) - favored_spread = None - favored_side = None - - if home_favored: - favored_spread = home_spread - favored_side = 'home' - self.logger.debug(f"Home team favored with spread: {favored_spread}") - elif away_favored: - favored_spread = away_spread - favored_side = 'away' - self.logger.debug(f"Away team favored with spread: {favored_spread}") - else: - self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}") - - # Show the negative spread on the appropriate side - if favored_spread is not None: - spread_text = str(favored_spread) - font = self.fonts['detail'] # Use detail font for odds - - if favored_side == 'home': - # Home team is favored, show spread on right side - spread_width = draw.textlength(spread_text, font=font) - spread_x = width - spread_width # Top right - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing home spread '{spread_text}' on right side") - else: - # Away team is favored, show spread on left side - spread_x = 0 # Top left - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing away spread '{spread_text}' on left side") - - # Show over/under on the opposite side of the favored team - over_under = odds.get('over_under') - if over_under is not None: - ou_text = f"O/U: {over_under}" - font = self.fonts['detail'] # Use detail font for odds - ou_width = draw.textlength(ou_text, font=font) - - if favored_side == 'home': - # Home team is favored, show O/U on left side (opposite of spread) - ou_x = 0 # Top left - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)") - elif favored_side == 'away': - # Away team is favored, show O/U on right side (opposite of spread) - ou_x = width - ou_width # Top right - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)") - else: - # No clear favorite, show O/U in center - ou_x = (width - ou_width) // 2 - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)") - - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) - - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): - """Draw text with a black outline for better readability.""" - x, y = position - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - - #: Decoded logos to keep. A scroll of "other games" shows on the order of - #: 20 games (40 teams), so this holds a full cycle without thrashing while - #: capping the cache well below a 307-team league. - _LOGO_CACHE_MAX = 64 - - def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: - """Load and resize a team logo, with caching and automatic download if missing.""" - self.logger.debug(f"Logo path: {logo_path}") - if team_abbrev in self._logo_cache: - self.logger.debug(f"Using cached logo for {team_abbrev}") - self._logo_cache.move_to_end(team_abbrev) - return self._logo_cache[team_abbrev] - - try: - # Try different filename variations first (for cases like TA&M vs TAANDM) - actual_logo_path = None - filename_variations = LogoDownloader.get_logo_filename_variations(team_abbrev) - - for filename in filename_variations: - test_path = logo_path.parent / filename - if test_path.exists(): - actual_logo_path = test_path - self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") - break - - # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): - self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") - - # Try to download the logo from ESPN API (this will create placeholder if download fails) - download_missing_logo(self.sport_key, team_id, team_abbrev, logo_path, logo_url) - actual_logo_path = logo_path - - # Use the original path if no alternative was found - if not actual_logo_path: - actual_logo_path = logo_path - - # Only try to open the logo if the file exists - if os.path.exists(actual_logo_path): - logo = Image.open(actual_logo_path) - else: - self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt") - return None - if logo.mode != 'RGBA': - logo = logo.convert('RGBA') - - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - self._logo_cache[team_abbrev] = logo - while len(self._logo_cache) > self._LOGO_CACHE_MAX: - self._logo_cache.popitem(last=False) - return logo - - except Exception as e: - self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True) - return None - - def _fetch_odds(self, game: Dict) -> None: - """Fetch odds for a specific game using the new architecture.""" - try: - if not self.show_odds: - return - - if not self.odds_manager: - return - - # Determine update interval based on game state - is_live = game.get('is_live', False) - update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \ - else self.mode_config.get("odds_update_interval", 3600) - - # Fetch odds using OddsManager - odds_data = self.odds_manager.get_odds( - sport=self.sport, - league=self.league, - event_id=game['id'], - update_interval_seconds=update_interval, - ) - - if odds_data: - game['odds'] = odds_data - self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}") - else: - self.logger.debug(f"No odds data returned for game {game['id']}") - - except Exception as e: - self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") - - def _get_timezone(self): - try: - timezone_str = self.config.get('timezone', 'UTC') - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - return pytz.utc - - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Whether a warning of this kind is outside its cooldown window. - - Cooldowns are tracked **per ``warning_type``**. They previously shared - one timestamp, so the parameter was accepted and ignored: an API-error - warning would silence an unrelated cache warning for the next minute, - and whichever fired first won. Nothing in core called this, so no - behavior regressed with the fix — but every caller has always been - entitled to assume its own warning type has its own clock. - """ - current_time = time.time() - if current_time - self._warning_cooldowns.get(warning_type, 0) > cooldown: - self._warning_cooldowns[warning_type] = current_time - # Kept in step for subclasses that read it directly. - self._last_warning_time = current_time - return True - return False - - def _fetch_team_rankings(self) -> Dict[str, int]: - """Fetch team rankings using the new architecture components.""" - current_time = time.time() - - # Check if we have cached rankings that are still valid - if (self._team_rankings_cache and - current_time - self._rankings_cache_timestamp < self._rankings_cache_duration): - return self._team_rankings_cache - - try: - data = self.data_source.fetch_standings(self.sport, self.league) - - rankings = {} - rankings_data = data.get('rankings', []) - - if rankings_data: - # Use the first ranking (usually AP Top 25) - first_ranking = rankings_data[0] - teams = first_ranking.get('ranks', []) - - for team_data in teams: - team_info = team_data.get('team', {}) - team_abbr = team_info.get('abbreviation', '') - current_rank = team_data.get('current', 0) - - if team_abbr and current_rank > 0: - rankings[team_abbr] = current_rank - - # Cache the results - self._team_rankings_cache = rankings - self._rankings_cache_timestamp = current_time - - self.logger.debug(f"Fetched rankings for {len(rankings)} teams") - return rankings - - except Exception as e: - self.logger.error(f"Error fetching team rankings: {e}") - return {} - - def _extract_game_details_common(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: - if not game_event: - return None, None, None, None, None - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - situation = competition.get("situation") - start_time_utc = None - try: - # Parse the datetime string - if game_date_str.endswith('Z'): - game_date_str = game_date_str.replace('Z', '+00:00') - dt = datetime.fromisoformat(game_date_str) - # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) - if dt.tzinfo is None: - # If naive, assume it's UTC - start_time_utc = dt.replace(tzinfo=pytz.UTC) - else: - # Convert to pytz.UTC for consistency - start_time_utc = dt.astimezone(pytz.UTC) - except ValueError: - logging.warning(f"Could not parse game date: {game_date_str}") - - home_team = next((c for c in competitors if c.get("homeAway") == "home"), None) - away_team = next((c for c in competitors if c.get("homeAway") == "away"), None) - - if not home_team or not away_team: - self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") - return None, None, None, None, None - - try: - home_abbr = home_team["team"]["abbreviation"] - except KeyError: - home_abbr = home_team["team"]["name"][:3] - try: - away_abbr = away_team["team"]["abbreviation"] - except KeyError: - away_abbr = away_team["team"]["name"][:3] - - # Check if this is a favorite team game BEFORE doing expensive logging - is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams) - - # Only log debug info for favorite team games - if is_favorite_game: - self.logger.debug(f"Processing favorite team game: {game_event.get('id')}") - self.logger.debug(f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}") - - game_time, game_date = "", "" - if start_time_utc: - local_time = start_time_utc.astimezone(self._get_timezone()) - game_time = local_time.strftime("%I:%M%p").lstrip('0') - - # Check date format from config - use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) - if use_short_date_format: - game_date = local_time.strftime("%-m/%-d") - else: - game_date = self.display_manager.format_date_with_ordinal(local_time) - - - home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' - away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' - - # Don't show "0-0" records - set to blank instead - if home_record in {"0-0", "0-0-0"}: - home_record = '' - if away_record in {"0-0", "0-0-0"}: - away_record = '' - - details = { - "id": game_event.get("id"), - "game_time": game_time, - "game_date": game_date, - "start_time_utc": start_time_utc, - "status_text": status["type"]["shortDetail"], # e.g., "Final", "7:30 PM", "Q1 12:34" - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": (status["type"]["state"] == "pre" or - status["type"]["name"].lower() in ['scheduled', 'pre-game', 'status_scheduled']), - "is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check - "is_period_break": status["type"]["name"] == "STATUS_END_PERIOD", # Added Period Break check - "home_abbr": home_abbr, - "home_id": home_team["id"], - "home_score": home_team.get("score", "0"), - "home_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"), - "home_logo_url": home_team["team"].get("logo"), - "home_record": home_record, - "away_record": away_record, - "away_abbr": away_abbr, - "away_id": away_team["id"], - "away_score": away_team.get("score", "0"), - "away_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), - "away_logo_url": away_team["team"].get("logo"), - "is_within_window": True, # Whether game is within display window - - } - return details, home_team, away_team, status, situation - except Exception as e: - # Log the problematic event structure if possible - logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) - return None, None, None, None, None - - @abstractmethod - def _extract_game_details(self, game_event: dict) -> dict | None: - details, _, _, _, _ = self._extract_game_details_common(game_event) - return details - - @abstractmethod - def _fetch_data(self) -> Optional[Dict]: - pass - - def _fetch_todays_games(self) -> Optional[Dict]: - """Fetch only today's games for live updates (not entire season).""" - try: - tz = pytz.timezone("America/New_York") # Use full name (not "EST") for DST support - now = datetime.now(tz) - yesterday = now - timedelta(days=1) - formatted_date = now.strftime("%Y%m%d") - formatted_date_yesterday = yesterday.strftime("%Y%m%d") - # Fetch todays games only - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - data = fetch_espn_scoreboard( - self.session, url, - params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, - headers=self.headers, timeout=10, logger=self.logger, - ) - events = data.get('events', []) - - self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}") - return {'events': events} - except requests.exceptions.RequestException as e: - self.logger.error(f"API error fetching todays games for {self.sport} - {self.league}: {e}") - return None - - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now + timedelta(weeks=-2) - end_date = now + timedelta(weeks=1) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - data = fetch_espn_scoreboard( - self.session, url, params={"dates": date_str, "limit": 1000}, - headers=self.headers, timeout=10, logger=self.logger, - ) - immediate_events = data.get('events', []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {'events': immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning(f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}") - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - - # ------------------------------------------------------------------ - # Promoted from the plugin copies (see docs/SPORTS_UNIFICATION.md). - # Everything below is present in all nine bundled `sports.py` copies; - # the canonical form of each is documented on the method. - # ------------------------------------------------------------------ - - def _favorite_key(self, game: Dict, side: str) -> Optional[str]: - """Override point: which view-model field identifies a team when - matching against ``favorite_teams``. - - ``side`` is ``"home"`` or ``"away"``. The default is the team - abbreviation — what eight of the nine scoreboards match on, and what - users type into their favorites list. - - NRL overrides this to the team **id**, because NRL abbreviations are - not unique: "NEW" is both Newcastle Knights and New Zealand Warriors, - "CAN" both Canberra Raiders and Canterbury Bulldogs. Matching those by - abbreviation selects the wrong club. It is a seam rather than a branch - precisely so core never has to learn the string "nrl":: - - def _favorite_key(self, game, side): - return str(game.get(f"{side}_id")) - - An override that stringifies should note that a missing id becomes the - literal ``"None"``, which would spuriously match a favorites list - containing that string. The default returns ``None``, which never - matches. - """ - return game.get(f"{side}_abbr") - - def _config_schema_path(self) -> Optional[str]: - """Override point: the plugin's ``config_schema.json``, used as the - reference for style-resolver defaults. - - None (the default) means no schema is available — layout offsets are - then read with the classic inline config lookup, which is exactly what - a plugin that never shipped resolver support does today. A plugin - opting into :mod:`src.element_style` returns its own schema path:: - - def _config_schema_path(self): - return os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'config_schema.json') - - It must not be derived from this module's ``__file__``: after - promotion that resolves inside ``src/base_classes/sports/``, where no - ``config_schema.json`` exists. - """ - return None - - def _font_root(self) -> str: - """Override point: the directory ``assets/fonts`` resolves against. - - Defaults to the core install root, derived from this module's own - location — the same strategy as ``FontManager._resolve_asset_path``. - A plugin bundling its own fonts overrides this to return its plugin - directory. Never a cwd-relative path: fonts must load no matter where - the process was started from (e.g. the plugin safety harness on CI). - """ - return str(_INSTALL_ROOT) - - def _resolve_font_path(self, font_name: str) -> str: - """Locate a font file by filename, independently of the process cwd. - - Tries ``assets/fonts/`` relative to the cwd first (preserving - behavior for a process started from an install root), then under - :meth:`_font_root`. Returns the cwd-relative path unchanged when the - file is nowhere to be found, so callers log the familiar path. - """ - relative = os.path.join('assets', 'fonts', font_name) - if os.path.exists(relative): - return relative - candidate = os.path.join(self._font_root(), relative) - if os.path.exists(candidate): - return candidate - return relative - - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: - """ - Get layout offset for a specific element and axis. - - Args: - element: Element name (e.g., 'home_logo', 'score', 'status_text') - axis: 'x_offset' or 'y_offset' (or 'away_x_offset', 'home_x_offset' for records) - default: Default value if not configured (default: 0) - - Returns: - Offset value from config or default (always returns int) - """ - schema_path = self._config_schema_path() if STYLE_AVAILABLE else None - if schema_path: - # Shared resolver (rebuilt if the config dict was swapped out, - # matching the classic path's read-config-on-every-call semantics). - # Note it is stricter than the classic read below: a boolean offset - # degrades to the default instead of counting as 1/0, which is the - # more correct reading of a pixel offset. - resolver = getattr(self, '_style_resolver_cached', None) - if resolver is None or resolver._config is not self.config: - resolver = ElementStyleResolver( - self.config, defaults_from_schema_file(schema_path)) - self._style_resolver_cached = resolver - return resolver.offset_value(element, axis, default) - try: - layout_config = self.config.get('customization', {}).get('layout', {}) - element_config = layout_config.get(element, {}) - offset_value = element_config.get(axis, default) - - # Ensure we return an integer (handle float/string from config) - if isinstance(offset_value, (int, float)): - return int(offset_value) - elif isinstance(offset_value, str): - # Try to convert string to int - try: - return int(float(offset_value)) - except (ValueError, TypeError): - self.logger.warning( - f"Invalid layout offset value for {element}.{axis}: '{offset_value}', using default {default}" - ) - return default - else: - return default - except Exception as e: - # Gracefully handle any config access errors - self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") - return default - - def _load_custom_font_from_element_config( - self, - element_config: Dict[str, Any], - default_size: int = 8, - default_font: Optional[str] = None, - ) -> ImageFont.FreeTypeFont: - """ - Load a custom font from an element configuration dictionary. - - Args: - element_config: Configuration dict for a single element containing 'font' and 'font_size' keys - default_size: Default font size if not specified in config - default_font: Default font filename when not specified in config (e.g. '4x6-font.ttf' for odds) - - Returns: - PIL ImageFont object - """ - base_default = default_font or "PressStart2P-Regular.ttf" - font_name = element_config.get('font', base_default) - font_size = int(element_config.get('font_size', default_size)) # Ensure integer for PIL - - # Resolve family aliases (e.g. "press_start") to real filenames, then - # locate the file against _font_root() rather than the cwd. - resolved_name = _resolve_font_family_alias(font_name) - font_path = self._resolve_font_path(resolved_name) - - # Memoized: per-frame callers (font-ladder walks) resolve the same - # (name, size) repeatedly -- return the previously loaded face. - cache_key = (resolved_name, font_size) - cached_font = self._font_cache.get(cache_key) - if cached_font is not None: - return cached_font - - # Try to load the font - try: - if os.path.exists(font_path): - # Try loading as TTF first (works for both TTF and some BDF files with PIL) - if font_path.lower().endswith('.ttf'): - font = load_truetype(font_path, font_size) - self.logger.debug(f"Loaded font: {font_name} at size {font_size}") - self._font_cache[cache_key] = font - return font - elif font_path.lower().endswith('.bdf'): - # BDF fonts are fixed-size bitmaps, not scalable outlines -- - # FreeType only accepts the exact pixel size baked into the - # file (its "strike") and raises "invalid pixel size" for - # anything else. Try the requested size first (in case it - # happens to match), then fall back to the file's real - # native size, so a BDF font can still be selected via - # font_size-driven configs without the caller needing to - # know its exact strike size. - # - # This retry is the OLDER lineage's behavior and it is the - # correct one: the newer copies call truetype() on a BDF at - # any size (which simply fails) or refuse BDF outright. - try: - font = load_truetype(font_path, font_size) - self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") - self._font_cache[cache_key] = font - return font - except OSError: - if font_path in self._bdf_native_size_cache: - native_size = self._bdf_native_size_cache[font_path] - else: - native_size = _read_bdf_native_size(font_path) - self._bdf_native_size_cache[font_path] = native_size - if native_size and native_size != font_size: - try: - font = load_truetype(font_path, native_size) - self.logger.debug( - f"Loaded BDF font: {font_name} at its native size {native_size} " - f"(requested {font_size} isn't a valid strike for this file)" - ) - self._font_cache[cache_key] = font - return font - except Exception as retry_exc: - self.logger.debug( - f"BDF font {font_name} also failed to load at native " - f"size {native_size}: {retry_exc}" - ) - self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default") - # Fall through to default - else: - self.logger.warning(f"Unknown font file type: {font_name}, using default") - else: - self.logger.warning(f"Font file not found: {font_path}, using default") - except Exception as e: - self.logger.error(f"Error loading font {font_name}: {e}, using default") - - # Fall back to default font. Cached under the requested (name, size) - # key too, so a misconfigured or missing font pays the disk cost once - # instead of on every frame of a font-ladder walk. - default_font_path = self._resolve_font_path( - _resolve_font_family_alias(base_default)) - try: - if os.path.exists(default_font_path): - font = load_truetype(default_font_path, font_size) - else: - self.logger.warning("Default font not found, using PIL default") - font = ImageFont.load_default() - except Exception as e: - self.logger.error(f"Error loading default font: {e}") - font = ImageFont.load_default() - self._font_cache[cache_key] = font - return font - - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - # Font caches hold PIL faces; without this they are an unbounded - # per-instance leak across enable/disable cycles. - if hasattr(self, '_font_cache'): - self._font_cache.clear() - if hasattr(self, '_bdf_native_size_cache'): - self._bdf_native_size_cache.clear() - - # NOTE: self.background_service is deliberately NOT shut down here. - # get_background_service() returns a PROCESS-WIDE singleton shared by - # every scoreboard; shutting it down from one unloading plugin would - # stop background fetching for all the others. Whoever owns the - # process owns its lifecycle. Do not "fix" this. - - self.logger.info(f"{self.__class__.__name__} cleanup completed") diff --git a/src/base_classes/sports/modes.py b/src/base_classes/sports/modes.py deleted file mode 100644 index 8f6e3cce..00000000 --- a/src/base_classes/sports/modes.py +++ /dev/null @@ -1,1097 +0,0 @@ -"""The three display modes layered on SportsCore: SportsUpcoming, -SportsRecent and SportsLive. Split out of the former -``src/base_classes/sports.py``; see docs/SPORTS_UNIFICATION.md. -""" - -import logging -import time -from abc import abstractmethod -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List - -from PIL import Image, ImageDraw, ImageFont - -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager - -from .core import SportsCore - - -class SportsUpcoming(SportsCore): - SKIN_MODE = "upcoming" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.upcoming_games = [] # Store all fetched upcoming games initially - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get("upcoming_update_interval", 3600) # Check for recent games every hour - self.last_log_time = 0 - self.log_interval = 300 - self.last_warning_time = 0 - self.warning_cooldown = 300 - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each upcoming game for 15 seconds - - def _select_games_for_display( - self, processed_games: List[Dict], favorite_teams: List[str] - ) -> List[Dict]: - """ - Single-pass game selection with proper deduplication and counting. - - When a game involves two favorite teams, it counts toward BOTH teams' limits. - This prevents unexpected game counts from the multi-pass algorithm. - - Team identity goes through the ``_favorite_key`` override point rather - than reading ``home_abbr``/``away_abbr`` directly, because abbreviations - are not unique in every league (NRL matches on team ID instead). - """ - sorted_games = sorted( - processed_games, - key=lambda g: g.get("start_time_utc") - or datetime.max.replace(tzinfo=timezone.utc), - ) - - if not favorite_teams: - return sorted_games - - selected_games = [] - selected_ids = set() - team_counts = {team: 0 for team in favorite_teams} - - for game in sorted_games: - game_id = game.get("id") - if game_id in selected_ids: - continue - - home = self._favorite_key(game, "home") - away = self._favorite_key(game, "away") - - home_fav = home in favorite_teams - away_fav = away in favorite_teams - - if not home_fav and not away_fav: - continue - - home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show - away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show - - if home_needs or away_needs: - selected_games.append(game) - selected_ids.add(game_id) - if home_fav: - team_counts[home] += 1 - if away_fav: - team_counts[away] += 1 - - self.logger.debug( - f"Selected game {away}@{home}: team_counts={team_counts}" - ) - - if all(c >= self.upcoming_games_to_show for c in team_counts.values()): - self.logger.debug("All favorite teams satisfied, stopping selection") - break - - self.logger.info( - f"Selected {len(selected_games)} games for {len(favorite_teams)} " - f"favorite teams: {team_counts}" - ) - return selected_games - - def update(self): - """Update upcoming games data.""" - if not self.is_enabled: return - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - self.last_update = current_time - - # Fetch rankings if enabled - if self.show_ranking: - self._fetch_team_rankings() - - try: - data = self._fetch_data() # Uses shared cache - if not data or 'events' not in data: - self.logger.warning("No events found in shared data.") # Changed log prefix - if not self.games_list: self.current_game = None - return - - events = data['events'] - # self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix - - processed_games = [] - favorite_games_found = 0 - all_upcoming_games = 0 # Count all upcoming games regardless of favorites - - for event in events: - game = self._extract_game_details(event) - # Count all upcoming games for debugging - if game and game['is_upcoming']: - all_upcoming_games += 1 - - # Filter criteria: must be upcoming ('pre' state) - if game and game['is_upcoming']: - # Only fetch odds for games that will be displayed - if self.show_favorite_teams_only: - if not self.favorite_teams: - continue - if game['home_abbr'] not in self.favorite_teams and game['away_abbr'] not in self.favorite_teams: - continue - processed_games.append(game) - # Count favorite team games for logging - if (game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams): - favorite_games_found += 1 - # Odds are NOT fetched here -- see after selection below. - - # Enhanced logging for debugging - self.logger.info(f"Found {all_upcoming_games} total upcoming games in data") - self.logger.info(f"Found {len(processed_games)} upcoming games after filtering") - - if processed_games: - for game in processed_games[:3]: # Show first 3 - self.logger.info(f" {game['away_abbr']}@{game['home_abbr']} - {game['start_time_utc']}") - - if self.favorite_teams and all_upcoming_games > 0: - self.logger.info(f"Favorite teams: {self.favorite_teams}") - self.logger.info(f"Found {favorite_games_found} favorite team upcoming games") - - # Filter for favorite teams only if the config is set - if self.show_favorite_teams_only: - # Select N games per favorite team (where N = upcoming_games_to_show) - # Example: upcoming_games_to_show=2 with 3 favorite teams = up to 6 games total - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - if team_specific_games := [game for game in processed_games if game['home_abbr'] == team or game['away_abbr'] == team]: - # Sort by game time and take the earliest N games - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - # Take up to upcoming_games_to_show games for this team - team_games.extend(team_specific_games[:self.upcoming_games_to_show]) - - # Sort the final list by game time (earliest first) - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - # Remove duplicates (in case a game involves multiple favorite teams) - seen_ids = set() - unique_team_games = [] - for game in team_games: - if game['id'] not in seen_ids: - seen_ids.add(game['id']) - unique_team_games.append(game) - team_games = unique_team_games - else: - team_games = processed_games # Show all upcoming if no favorites - # Sort by game time, earliest first - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - # Limit to the specified number of upcoming games - team_games = team_games[:self.upcoming_games_to_show] - - # Odds are fetched here, for the games that survived selection, - # rather than in the loop that collects them. That loop walks every - # upcoming game in the schedule window, and for a college league - # the window is enormous -- a live rig logged 946 upcoming games in - # one cycle and displayed 1 of them. The comment up there claimed - # odds were fetched "only for games that will be displayed", but - # the only narrowing it applied was show_favorite_teams_only, which - # is not the default; in the usual case nothing narrowed it at all - # and every game cost a separate ESPN request on a Pi that is also - # driving the panel. - if self.show_odds: - for game in team_games: - self._fetch_odds(game) - - # Log changes or periodically - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(team_games) != len(self.games_list) or - any(g1['id'] != g2.get('id') for g1, g2 in zip(self.games_list, team_games)) or - (not self.games_list and team_games) - ) - - # Check if the list of games to display has changed - new_game_ids = {g['id'] for g in team_games} - current_game_ids = {g['id'] for g in self.games_list} - - if new_game_ids != current_game_ids: - self.logger.info(f"Found {len(team_games)} upcoming games within window for display.") # Changed log prefix - self.games_list = team_games - if not self.current_game or not self.games_list or self.current_game['id'] not in new_game_ids: - self.current_game_index = 0 - self.current_game = self.games_list[0] if self.games_list else None - self.last_game_switch = current_time - else: - try: - self.current_game_index = next(i for i, g in enumerate(self.games_list) if g['id'] == self.current_game['id']) - self.current_game = self.games_list[self.current_game_index] - except StopIteration: - self.current_game_index = 0 - self.current_game = self.games_list[0] - self.last_game_switch = current_time - - elif self.games_list: - self.current_game = self.games_list[self.current_game_index] # Update data - - if not self.games_list: - self.logger.info("No relevant upcoming games found to display.") # Changed log prefix - self.current_game = None - - if should_log and not self.games_list: - # Log favorite teams only if no games are found and logging is needed - self.logger.debug(f"Favorite teams: {self.favorite_teams}") # Changed log prefix - self.logger.debug(f"Total upcoming games before filtering: {len(processed_games)}") # Changed log prefix - self.last_log_time = current_time - elif should_log: - self.last_log_time = current_time - - except Exception as e: - self.logger.error(f"Error updating upcoming games: {e}", exc_info=True) # Changed log prefix - # self.current_game = None # Decide if clear on error - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the layout for an upcoming NCAA FB game.""" # Updated docstring - try: - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) - away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for game: {game.get('id')}") # Changed log prefix - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positions - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw Text Elements on Overlay - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") - - # Note: Rankings are now handled in the records/rankings section below - - # "Next Game" at the top (use smaller status font) - status_font = self.fonts['status'] - if self.display_width > 128: - status_font = self.fonts['time'] - status_text = "Next Game" - status_width = draw_overlay.textlength(status_text, font=status_font) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Changed from 2 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), status_font) - - # Date text (centered, below "Next Game") - date_width = draw_overlay.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - # Adjust Y position to stack date and time nicely - date_y = center_y - 7 # Raise date slightly - self._draw_text_with_outline(draw_overlay, game_date, (date_x, date_y), self.fonts['time']) - - # Time text (centered, below Date) - time_width = draw_overlay.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = date_y + 9 # Place time below date - self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get('away_abbr', '') - home_abbr = game.get('home_abbr', '') - - record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = '' - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = '' - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get('away_record', '') - else: - away_text = '' - - if away_text: - away_record_x = 0 - self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = '' - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = '' - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get('home_record', '') - else: - home_text = '' - - if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here - - except Exception as e: - self.logger.error(f"Error displaying upcoming game: {e}", exc_info=True) # Changed log prefix - - def display(self, force_clear=False) -> bool: - """Display upcoming games, handling switching.""" - if not self.is_enabled: return False - - if not self.games_list: - if self.current_game: self.current_game = None # Clear state if list empty - current_time = time.time() - # Log warning periodically if no games found - if current_time - self.last_warning_time > self.warning_cooldown: - self.logger.info("No upcoming games found for favorite teams to display.") # Changed log prefix - self.last_warning_time = current_time - return False # Skip display update - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.games_list) - self.current_game = self.games_list[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force redraw on switch - - # Log team switching with sport prefix - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - sport_prefix = self.sport_key.upper() if hasattr(self, 'sport_key') else 'SPORT' - self.logger.info(f"[{sport_prefix} Upcoming] Showing {away_abbr} vs {home_abbr}") - else: - self.logger.debug(f"Switched to game index {self.current_game_index}") - - if self.current_game: - self._render_game(self.current_game, force_clear) - return True - # update_display() is called within _draw_scorebug_layout for upcoming - return False - - except Exception as e: - self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix - return False - - -class SportsRecent(SportsCore): - SKIN_MODE = "recent" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.recent_games = [] # Store all fetched recent games initially - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get("recent_update_interval", 3600) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each recent game for 15 seconds - # Tracks when each game was first seen with an expired clock, keyed by - # game id. Promoted alongside the zero-clock helpers below; without it - # the first _get_zero_clock_duration() call raises AttributeError. - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - # -- Zero-clock tracking ------------------------------------------------ - # Byte-identical in all nine plugin copies. Note that afl/nrl/soccer define - # these but never call them — their clocks count up, so 0:00 means kickoff - # rather than expiry (see CLOCK_COUNTS_DOWN on SportsLive). That makes the - # pair a future `CountdownClockMixin` candidate so it stops appearing in the - # MRO of sports that cannot use it — B2 work, not now. - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] - - def _select_recent_games_for_display( - self, processed_games: List[Dict], favorite_teams: List[str] - ) -> List[Dict]: - """ - Single-pass game selection for recent games with proper deduplication. - - When a game involves two favorite teams, it counts toward BOTH teams' limits. - Games are sorted by most recent first. - - Team identity goes through the ``_favorite_key`` override point rather - than reading ``home_abbr``/``away_abbr`` directly, because abbreviations - are not unique in every league (NRL matches on team ID instead). - """ - sorted_games = sorted( - processed_games, - key=lambda g: g.get("start_time_utc") - or datetime.min.replace(tzinfo=timezone.utc), - reverse=True, - ) - - if not favorite_teams: - return sorted_games - - selected_games = [] - selected_ids = set() - team_counts = {team: 0 for team in favorite_teams} - - for game in sorted_games: - game_id = game.get("id") - if game_id in selected_ids: - continue - - home = self._favorite_key(game, "home") - away = self._favorite_key(game, "away") - - home_fav = home in favorite_teams - away_fav = away in favorite_teams - - if not home_fav and not away_fav: - continue - - home_needs = home_fav and team_counts[home] < self.recent_games_to_show - away_needs = away_fav and team_counts[away] < self.recent_games_to_show - - if home_needs or away_needs: - selected_games.append(game) - selected_ids.add(game_id) - if home_fav: - team_counts[home] += 1 - if away_fav: - team_counts[away] += 1 - - self.logger.debug( - f"Selected recent game {away}@{home}: team_counts={team_counts}" - ) - - if all(c >= self.recent_games_to_show for c in team_counts.values()): - self.logger.debug("All favorite teams satisfied, stopping selection") - break - - self.logger.info( - f"Selected {len(selected_games)} recent games for {len(favorite_teams)} " - f"favorite teams: {team_counts}" - ) - return selected_games - - def update(self): - """Update recent games data.""" - if not self.is_enabled: return - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - self.last_update = current_time # Update time even if fetch fails - - # Fetch rankings if enabled - if self.show_ranking: - self._fetch_team_rankings() - - try: - data = self._fetch_data() # Uses shared cache - if not data or 'events' not in data: - self.logger.warning("No events found in shared data.") # Changed log prefix - if not self.games_list: - self.current_game = None # Clear display if no games were showing - return - - events = data['events'] - self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix - - # Define date range for "recent" games (last 21 days to capture games from 3 weeks ago) - now = datetime.now(timezone.utc) - recent_cutoff = now - timedelta(days=21) - self.logger.info(f"Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)") - - # Process games and filter for final games, date range & favorite teams - processed_games = [] - for event in events: - game = self._extract_game_details(event) - # Filter criteria: must be final AND within recent date range - if game and game['is_final']: - game_time = game.get('start_time_utc') - if game_time and game_time >= recent_cutoff: - processed_games.append(game) - # Filter for favorite teams only if the config is set - if self.show_favorite_teams_only: - # Get all games involving favorite teams - favorite_team_games = [game for game in processed_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - self.logger.info(f"Found {len(favorite_team_games)} favorite team games out of {len(processed_games)} total final games within last 21 days") - - # Select N games per favorite team (where N = recent_games_to_show) - # Example: recent_games_to_show=1 with 2 favorite teams = 2 games total - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: - # Sort by game time and take the most recent N games - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - # Take up to recent_games_to_show games for this team - team_games.extend(team_specific_games[:self.recent_games_to_show]) - - # Sort the final list by game time (most recent first) - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - # Remove duplicates (in case a game involves multiple favorite teams) - seen_ids = set() - unique_team_games = [] - for game in team_games: - if game['id'] not in seen_ids: - seen_ids.add(game['id']) - unique_team_games.append(game) - team_games = unique_team_games - - # Debug: Show which games are selected for display - for i, game in enumerate(team_games): - self.logger.info(f"Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}") - else: - team_games = processed_games # Show all recent games if no favorites defined - self.logger.info(f"Found {len(processed_games)} total final games within last 21 days (no favorite teams filtering)") - # Sort games by start time, most recent first, and limit to recent_games_to_show - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - team_games = team_games[:self.recent_games_to_show] - - # Check if the list of games to display has changed - new_game_ids = {g['id'] for g in team_games} - current_game_ids = {g['id'] for g in self.games_list} - - if new_game_ids != current_game_ids: - self.logger.info(f"Found {len(team_games)} final games within window for display.") # Changed log prefix - self.games_list = team_games - # Reset index if list changed or current game removed - if not self.current_game or not self.games_list or self.current_game['id'] not in new_game_ids: - self.current_game_index = 0 - self.current_game = self.games_list[0] if self.games_list else None - self.last_game_switch = current_time # Reset switch timer - else: - # Try to maintain position if possible - try: - self.current_game_index = next(i for i, g in enumerate(self.games_list) if g['id'] == self.current_game['id']) - self.current_game = self.games_list[self.current_game_index] # Update data just in case - except StopIteration: - self.current_game_index = 0 - self.current_game = self.games_list[0] - self.last_game_switch = current_time - - elif self.games_list: - # List content is same, just update data for current game - self.current_game = self.games_list[self.current_game_index] - - if not self.games_list: - self.logger.info("No relevant recent games found to display.") # Changed log prefix - self.current_game = None # Ensure display clears if no games - - except Exception as e: - self.logger.error(f"Error updating recent games: {e}", exc_info=True) # Changed log prefix - # Don't clear current game on error, keep showing last known state - # self.current_game = None # Decide if we want to clear display on error - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the layout for a recently completed NCAA FB game.""" # Updated docstring - try: - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) - away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for game: {game.get('id')}") # Changed log prefix - # Draw placeholder text if logos fail (similar to live) - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positioning (closer to edges) - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw Text Elements on Overlay - # Note: Rankings are now handled in the records/rankings section below - - # Final Scores (Centered, same position as live) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = self.display_height - 14 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) - - # "Final" text (Top center) - status_text = game.get("period_text", "Final") # Use formatted period text (e.g., "Final/OT") or default "Final" - status_width = draw_overlay.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get('away_abbr', '') - home_abbr = game.get('home_abbr', '') - - record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = '' - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = '' - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get('away_record', '') - else: - away_text = '' - - if away_text: - away_record_x = 0 - self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = '' - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = '' - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get('home_record', '') - else: - home_text = '' - - if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - self._custom_scorebug_layout(game, draw_overlay) - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here - - except Exception as e: - self.logger.error(f"Error displaying recent game: {e}", exc_info=True) # Changed log prefix - - def display(self, force_clear=False) -> bool: - """Display recent games, handling switching.""" - if not self.is_enabled or not self.games_list: - # If disabled or no games, ensure display might be cleared by main loop if needed - # Or potentially clear it here? For now, rely on main loop/other managers. - if not self.games_list and self.current_game: - self.current_game = None # Clear internal state if list becomes empty - return False - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.games_list) - self.current_game = self.games_list[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force redraw on switch - - # Log team switching with sport prefix - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - sport_prefix = self.sport_key.upper() if hasattr(self, 'sport_key') else 'SPORT' - self.logger.info(f"[{sport_prefix} Recent] Showing {away_abbr} vs {home_abbr}") - else: - self.logger.debug(f"Switched to game index {self.current_game_index}") - - if self.current_game: - self._render_game(self.current_game, force_clear) - return True - # update_display() is called within _draw_scorebug_layout for recent - return False - - except Exception as e: - self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix - return False - -class SportsLive(SportsCore): - # Per-sport constants for the "is this live game actually over?" check. - # These are values, not behavior, so they are class attributes rather than - # override points (see docs/SPORTS_UNIFICATION.md "Override points"). - # - # FINAL_PERIOD: the period at/after which an expired clock can mean "over". - # 4 for four-quarter sports; hockey overrides to 3. - # CLOCK_COUNTS_DOWN: whether "0:00" means the clock expired. False for - # sports whose clock counts up (soccer/afl/nrl), where 0:00 is kickoff — - # running the expiry branch there would evict games that just started. - FINAL_PERIOD = 4 - CLOCK_COUNTS_DOWN = True - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 - self.last_update = 0 - self.live_games = [] - self.current_game_index = 0 - self.last_game_switch = 0 # Will be set to current_time when games are first loaded - self.game_display_duration = self.mode_config.get("live_game_duration", 20) - self.last_display_update = 0 - self.last_log_time = 0 - self.log_interval = 300 - self.last_count_log_time = 0 # Track when we last logged count data - self.count_log_interval = 5 # Only log count data every 5 seconds - # Initialize test_mode - defaults to False (live mode) - self.test_mode = self.mode_config.get("test_mode", False) - # Freshness bookkeeping for _detect_stale_games(). The base class only - # *reads* this map; a subclass's update() stamps entries as it ingests a - # feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}. - # Until a subclass writes "last_seen", the staleness branch of - # _detect_stale_games is inert and only the game-over check applies. - self.game_update_timestamps: Dict[str, Dict[str, float]] = {} - self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default - - @abstractmethod - def _test_mode_update(self) -> None: - return - - def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live. - - Two independent signals: - 1. ``period_text`` says "final" — universal across every sport. - 2. The clock has expired at/after :attr:`FINAL_PERIOD` — only meaningful - where :attr:`CLOCK_COUNTS_DOWN` is true. - - Fails *safe*: anything ambiguous returns False and the game keeps being - displayed. The only caller, :meth:`_detect_stale_games`, removes games - on a True, so a false positive silently drops a live game. - """ - game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - - # `period_text` may be present-but-None; `or ""` keeps that from raising - # AttributeError — the caller has no try/except around this call. - period_text = (game.get("period_text") or "").lower() - if "final" in period_text: - self.logger.debug( - f"_is_game_really_over({game_str}): " - f"returning True - 'final' in period_text='{period_text}'" - ) - return True - - if not self.CLOCK_COUNTS_DOWN: - # Count-up clock: 0:00 means the match has not started. - self.logger.debug( - f"_is_game_really_over({game_str}): returning False " - f"(count-up clock, period_text='{period_text}')" - ) - return False - - raw_clock = game.get("clock") - # `or 0` rather than a get() default: feeds routinely send an explicit - # null period, and `None >= FINAL_PERIOD` raises TypeError — which would - # take down the whole live-update pass, since the only caller - # (_detect_stale_games) has no try/except around it. - period = game.get("period") or 0 - - # Only check clock-based finish if we have a valid clock string. A - # missing or non-string clock is NOT coerced to "0:00": sports without a - # game clock (e.g. baseball, where `period` is the inning) would - # otherwise be declared over from the FINAL_PERIOD-th period onward. - if isinstance(raw_clock, str) and raw_clock.strip() and period >= self.FINAL_PERIOD: - clock = raw_clock - # Compare numerically rather than against a literal set: feeds spell - # an expired clock "0:00", ":00" and "00:00" depending on sport, and - # a membership test silently misses every spelling not listed. - clock_normalized = clock.replace(":", "").strip() - if clock_normalized.isdigit() and int(clock_normalized) == 0: - self.logger.debug( - f"_is_game_really_over({game_str}): " - f"returning True - clock at 0:00 (clock='{clock}', period={period})" - ) - return True - - self.logger.debug( - f"_is_game_really_over({game_str}): returning False" - ) - return False - - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated. - - Mutates ``games`` **in place** and returns None. Removal is by value - (``list.remove`` uses ``dict.__eq__``), so two structurally-equal game - dicts in the same list would drop the first occurrence. - """ - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def update(self): - """Update live game data and handle game switching.""" - if not self.is_enabled: - return - - # Define current_time and interval before the problematic line (originally line 455) - # Ensure 'import time' is present at the top of the file. - current_time = time.time() - - # Define interval using a pattern similar to NFLLiveManager's update method. - # Uses getattr for robustness, assuming attributes for live_games, test_mode, - # no_data_interval, and update_interval are available on self. - _live_games_attr = self.live_games - _test_mode_attr = self.test_mode # test_mode is often from a base class or config - _no_data_interval_attr = self.no_data_interval # Default similar to NFLLiveManager - _update_interval_attr = self.update_interval # Default similar to NFLLiveManager - - interval = _no_data_interval_attr if not _live_games_attr and not _test_mode_attr else _update_interval_attr - - # Original line from traceback (line 455), now with variables defined: - if current_time - self.last_update >= interval: - self.last_update = current_time - - # Fetch rankings if enabled - if self.show_ranking: - self._fetch_team_rankings() - - if self.test_mode: - # Simulate clock running down in test mode - self._test_mode_update() - else: - # Fetch live game data - data = self._fetch_data() - new_live_games = [] - if data and "events" in data: - for game in data["events"]: - details = self._extract_game_details(game) - if details and (details["is_live"] or details["is_halftime"]): - # If show_favorite_teams_only is true, only add if it's a favorite. - # Otherwise, add all games. - if self.show_all_live or not self.show_favorite_teams_only or (self.show_favorite_teams_only and (details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams)): - if self.show_odds: - self._fetch_odds(details) - new_live_games.append(details) - # Log changes or periodically - current_time_for_log = time.time() # Use a consistent time for logging comparison - should_log = ( - current_time_for_log - self.last_log_time >= self.log_interval or - len(new_live_games) != len(self.live_games) or - any(g1['id'] != g2.get('id') for g1, g2 in zip(self.live_games, new_live_games)) or # Check if game IDs changed - (not self.live_games and new_live_games) # Log if games appeared - ) - - if should_log: - if new_live_games: - filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "all teams" - self.logger.info(f"Found {len(new_live_games)} live/halftime games for {filter_text}.") - for game_info in new_live_games: # Renamed game to game_info - self.logger.info(f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})") - else: - filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "criteria" - self.logger.info(f"No live/halftime games found for {filter_text}.") - self.last_log_time = current_time_for_log - - - # Update game list and current game - if new_live_games: - # Check if the games themselves changed, not just scores/time - new_game_ids = {g['id'] for g in new_live_games} - current_game_ids = {g['id'] for g in self.live_games} - - if new_game_ids != current_game_ids: - self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time - # Reset index if current game is gone or list is new - if not self.current_game or self.current_game['id'] not in new_game_ids: - self.current_game_index = 0 - self.current_game = self.live_games[0] if self.live_games else None - self.last_game_switch = current_time - else: - # Find current game's new index if it still exists - try: - self.current_game_index = next(i for i, g in enumerate(self.live_games) if g['id'] == self.current_game['id']) - self.current_game = self.live_games[self.current_game_index] # Update current_game with fresh data - # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching - if self.last_game_switch == 0: - self.last_game_switch = current_time - except StopIteration: # Should not happen if check above passed, but safety first - self.current_game_index = 0 - self.current_game = self.live_games[0] - self.last_game_switch = current_time - - else: - # Just update the data for the existing games - temp_game_dict = {g['id']: g for g in new_live_games} - self.live_games = [temp_game_dict.get(g['id'], g) for g in self.live_games] # Update in place - if self.current_game: - self.current_game = temp_game_dict.get(self.current_game['id'], self.current_game) - # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching - # This handles the case where games were loaded previously but last_game_switch was never set - if self.last_game_switch == 0: - self.last_game_switch = current_time - - # Display update handled by main loop based on interval - - else: - # No live games found - if self.live_games: # Were there games before? - self.logger.info("Live games previously showing have ended or are no longer live.") # Changed log prefix - self.live_games = [] - self.current_game = None - self.current_game_index = 0 - - else: - # Error fetching data or no events - if self.live_games: # Were there games before? - self.logger.warning("Could not fetch update; keeping existing live game data for now.") # Changed log prefix - else: - self.logger.warning("Could not fetch data and no existing live games.") # Changed log prefix - self.current_game = None # Clear current game if fetch fails and no games were active - - # Handle game switching (outside test mode check) - # Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet) - # This prevents immediate switching when the system has been running for a while before games load - if not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.live_games) - self.current_game = self.live_games[self.current_game_index] - self.last_game_switch = current_time - self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix - # Force display update via flag or direct call if needed, but usually let main loop handle diff --git a/src/base_odds_manager.py b/src/base_odds_manager.py index 4b455069..efb10bba 100644 --- a/src/base_odds_manager.py +++ b/src/base_odds_manager.py @@ -50,8 +50,7 @@ class BaseOddsManager: # 2026-08-04 it began 403ing browser strings and bare custom tokens # alike; what it accepts is a token with a URL that says who is # calling. Every other ESPN caller in the tree already sends this - # (src/common/api_helper.py, src/base_classes/data_sources.py); the - # odds path was simply missed, and it is the one whose failures cost + # (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost # the caller its whole update budget. # # Deliberately no retry adapter, unlike api_helper: retries multiply diff --git a/src/common/sports_helpers.py b/src/common/sports_helpers.py index 6ae1c517..b78f0ca5 100644 --- a/src/common/sports_helpers.py +++ b/src/common/sports_helpers.py @@ -2,9 +2,8 @@ Ten helpers are byte-identical (executable AST, docstrings stripped) in the scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in -``src/common``. The bodies below were copied from those plugin copies -- not -from ``src/base_classes`` -- at ledmatrix-plugins ``f09bff2`` (origin/main, -2026-09-14): +``src/common``. The bodies below were copied from those plugin copies at +ledmatrix-plugins ``f09bff2`` (origin/main, 2026-09-14): - In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl, soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh`` @@ -34,9 +33,9 @@ where both can. Plugins import this and floor ``ledmatrix_min_version`` on the first core release that ships it (see ``CHANGELOG.md``). ``_favorite_key`` is the one method not taken from the plugins: it is the -override point from ``src/base_classes/sports/core.py``, carried here so later -phases (shared celebrations and game selection) have a hardware-free home for -the seam. No plugin defines it today and nothing in this module calls it. +override point from the since-removed ``src/base_classes`` sports core, +carried here so later phases (shared celebrations and game selection) have a +hardware-free home for the seam. No plugin defines it today and nothing in this module calls it. WHAT A HOST MUST PROVIDE ------------------------ @@ -80,8 +79,8 @@ runs. By convention list it after ``SportsCoreSharedMixin``:: HARDWARE-FREE ------------- -Nothing here may import ``src.display_manager``, ``src.base_classes``, -``src.plugin_system`` or anything else that reaches ``rgbmatrix`` at module +Nothing here may import ``src.display_manager``, ``src.plugin_system`` or +anything else that reaches ``rgbmatrix`` at module level; ``test/test_common_is_hardware_free.py`` enforces that for all of ``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily, exactly as the plugin copy does. @@ -224,8 +223,8 @@ class SportsHelpersMixin: containing that string. The default returns ``None`` for a missing abbreviation, which never matches. - Carried from ``src/base_classes/sports/core.py`` for later phases; - nothing in this module calls it yet. + Carried from the since-removed ``src/base_classes`` sports core for + later phases; nothing in this module calls it yet. """ return game.get(f"{side}_abbr") diff --git a/test/test_api_extractors.py b/test/test_api_extractors.py deleted file mode 100644 index cced61af..00000000 --- a/test/test_api_extractors.py +++ /dev/null @@ -1,342 +0,0 @@ -""" -Tests for src/base_classes/api_extractors.py - -Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor, -SoccerAPIExtractor, and the shared _extract_common_details logic. -""" - -import logging -import pytest -from src.base_classes.api_extractors import ( - ESPNFootballExtractor, - ESPNBaseballExtractor, - ESPNHockeyExtractor, - SoccerAPIExtractor, -) - - -# --------------------------------------------------------------------------- -# Shared test data factories -# --------------------------------------------------------------------------- - -def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF", - home_score: str = "14", away_score: str = "7", - date_str: str = "2024-01-15T20:00:00Z", - include_situation: bool = False, - situation: dict | None = None, - status_detail: str = "2nd Qtr 8:42", - period: int = 2) -> dict: - """Build a minimal ESPN-style game event dict.""" - comp_status = { - "type": { - "state": state, - "shortDetail": status_detail, - "detail": status_detail, - "name": "STATUS_IN_PROGRESS", - }, - "period": period, - "displayClock": "8:42", - } - comp = { - "status": comp_status, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"}, - "score": home_score, - }, - { - "homeAway": "away", - "team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"}, - "score": away_score, - }, - ], - } - if include_situation: - comp["situation"] = situation or {} - return { - "id": "test-game-1", - "date": date_str, - "competitions": [comp], - } - - -def _make_logger() -> logging.Logger: - return logging.getLogger("test_extractor") - - -# --------------------------------------------------------------------------- -# ESPNFootballExtractor -# --------------------------------------------------------------------------- - -class TestESPNFootballExtractor: - def setup_method(self): - self.extractor = ESPNFootballExtractor(_make_logger()) - - def test_extract_live_game_basic_fields(self): - event = _make_espn_event(state="in", home_score="14", away_score="7") - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["home_abbr"] == "KC" - assert result["away_abbr"] == "BUF" - assert result["home_score"] == "14" - assert result["away_score"] == "7" - assert result["is_live"] is True - assert result["is_final"] is False - assert result["is_upcoming"] is False - - def test_extract_final_game(self): - event = _make_espn_event(state="post") - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["is_final"] is True - assert result["is_live"] is False - - def test_extract_upcoming_game(self): - event = _make_espn_event(state="pre") - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["is_upcoming"] is True - - def test_sport_specific_fields_default_when_pregame(self): - event = _make_espn_event(state="pre") - fields = self.extractor.get_sport_specific_fields(event) - assert "down" in fields - assert "distance" in fields - assert "possession" in fields - assert "is_redzone" in fields - assert fields["is_redzone"] is False - - def test_sport_specific_fields_live_with_situation(self): - situation = { - "down": 3, - "distance": 7, - "possession": "KC", - "isRedZone": True, - "homeTimeouts": 2, - "awayTimeouts": 1, - } - event = _make_espn_event(state="in", include_situation=True, situation=situation) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["down"] == 3 - assert fields["distance"] == 7 - assert fields["is_redzone"] is True - assert fields["home_timeouts"] == 2 - assert fields["away_timeouts"] == 1 - - def test_scoring_event_detected(self): - # situation must be non-empty (truthy) for the live block to execute - situation = {"down": 1, "distance": 10} - event = _make_espn_event( - state="in", - include_situation=True, - situation=situation, - status_detail="touchdown scored", - ) - fields = self.extractor.get_sport_specific_fields(event) - assert "touchdown" in fields.get("scoring_event", "").lower() - - def test_returns_none_on_empty_event(self): - assert self.extractor.extract_game_details({}) is None - - def test_returns_none_when_teams_missing(self): - event = { - "id": "x", - "date": "2024-01-15T20:00:00Z", - "competitions": [ - { - "status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}}, - "competitors": [], # no competitors - } - ], - } - assert self.extractor.extract_game_details(event) is None - - def test_date_z_suffix_parsed(self): - event = _make_espn_event(date_str="2024-01-15T20:00:00Z") - result = self.extractor.extract_game_details(event) - # Should not raise and should return a result - assert result is not None - - def test_id_propagated(self): - event = _make_espn_event() - result = self.extractor.extract_game_details(event) - assert result["id"] == "test-game-1" - - -# --------------------------------------------------------------------------- -# ESPNBaseballExtractor -# --------------------------------------------------------------------------- - -class TestESPNBaseballExtractor: - def setup_method(self): - self.extractor = ESPNBaseballExtractor(_make_logger()) - - def test_extract_live_game(self): - event = _make_espn_event( - state="in", home_abbr="NYY", away_abbr="BOS", - home_score="3", away_score="2" - ) - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["home_abbr"] == "NYY" - assert result["is_live"] is True - - def test_baseball_sport_fields_defaults(self): - event = _make_espn_event(state="pre") - fields = self.extractor.get_sport_specific_fields(event) - assert "inning" in fields - assert "outs" in fields - assert "bases" in fields - assert "strikes" in fields - assert "balls" in fields - - def test_baseball_sport_fields_live(self): - situation = { - "inning": 7, - "outs": 2, - "bases": "110", - "strikes": 2, - "balls": 3, - "pitcher": "Smith", - "batter": "Jones", - } - event = _make_espn_event(state="in", include_situation=True, situation=situation) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["inning"] == 7 - assert fields["outs"] == 2 - assert fields["strikes"] == 2 - assert fields["pitcher"] == "Smith" - - def test_returns_none_on_empty(self): - assert self.extractor.extract_game_details({}) is None - - -# --------------------------------------------------------------------------- -# ESPNHockeyExtractor -# --------------------------------------------------------------------------- - -class TestESPNHockeyExtractor: - def setup_method(self): - self.extractor = ESPNHockeyExtractor(_make_logger()) - - def test_extract_live_game(self): - event = _make_espn_event( - state="in", home_abbr="BOS", away_abbr="TOR", - home_score="2", away_score="1" - ) - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["is_live"] is True - - def test_hockey_period_text_p1(self): - situation = {"isPowerPlay": False} - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=1 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "P1" - - def test_hockey_period_text_p2(self): - situation = {"isPowerPlay": False} # non-empty so the live block executes - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=2 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "P2" - - def test_hockey_period_text_p3(self): - situation = {"isPowerPlay": False} - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=3 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "P3" - - def test_hockey_period_text_ot(self): - situation = {"isPowerPlay": False} - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=4 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "OT1" - - def test_hockey_power_play(self): - situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8} - event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["power_play"] is True - assert fields["shots_on_goal"]["home"] == 12 - assert fields["shots_on_goal"]["away"] == 8 - - def test_hockey_fields_defaults_pregame(self): - event = _make_espn_event(state="pre") - fields = self.extractor.get_sport_specific_fields(event) - assert "period" in fields - assert "power_play" in fields - assert fields["power_play"] is False - - def test_returns_none_on_empty(self): - assert self.extractor.extract_game_details({}) is None - - -# --------------------------------------------------------------------------- -# SoccerAPIExtractor -# --------------------------------------------------------------------------- - -class TestSoccerAPIExtractor: - def setup_method(self): - self.extractor = SoccerAPIExtractor(_make_logger()) - - def _make_soccer_event(self, is_live: bool = True) -> dict: - return { - "id": "soccer-1", - "home_team": {"abbreviation": "ARS", "name": "Arsenal"}, - "away_team": {"abbreviation": "CHE", "name": "Chelsea"}, - "home_score": "2", - "away_score": "1", - "status": "LIVE", - "is_live": is_live, - "is_final": not is_live, - "is_upcoming": False, - "half": "1", - "stoppage_time": "2", - "home_yellow_cards": 1, - "away_yellow_cards": 2, - "home_red_cards": 0, - "away_red_cards": 0, - "home_possession": 55, - "away_possession": 45, - } - - def test_extract_live_game(self): - event = self._make_soccer_event(is_live=True) - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["home_abbr"] == "ARS" - assert result["away_abbr"] == "CHE" - assert result["is_live"] is True - - def test_sport_specific_cards(self): - event = self._make_soccer_event() - fields = self.extractor.get_sport_specific_fields(event) - assert fields["cards"]["home_yellow"] == 1 - assert fields["cards"]["away_yellow"] == 2 - assert fields["cards"]["home_red"] == 0 - - def test_sport_specific_possession(self): - event = self._make_soccer_event() - fields = self.extractor.get_sport_specific_fields(event) - assert fields["possession"]["home"] == 55 - assert fields["possession"]["away"] == 45 - - def test_sport_specific_half(self): - event = self._make_soccer_event() - fields = self.extractor.get_sport_specific_fields(event) - assert fields["half"] == "1" - - def test_scores_as_strings(self): - event = self._make_soccer_event() - result = self.extractor.extract_game_details(event) - assert result["home_score"] == "2" - assert result["away_score"] == "1" diff --git a/test/test_common_is_hardware_free.py b/test/test_common_is_hardware_free.py index 72cdf512..f385e675 100644 --- a/test/test_common_is_hardware_free.py +++ b/test/test_common_is_hardware_free.py @@ -1,9 +1,8 @@ """src/common must stay importable without display hardware. Plugins import src.common.sports_* in place of their bundled copies. If any of -those modules reaches src.display_manager (and through it rgbmatrix) or the -src.base_classes package (whose core.py imports DisplayManager), a scoreboard -adopting it acquires a hardware dependency it never had, and the headless +those modules reaches src.display_manager (and through it rgbmatrix), a +scoreboard adopting it acquires a hardware dependency it never had, and the headless tooling -- the web preview, check_plugin.py, these tests on a laptop -- stops being able to load it. @@ -26,7 +25,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] COMMON = REPO_ROOT / "src" / "common" -FORBIDDEN = ("src.base_classes", "src.display_manager", "src.plugin_system") +FORBIDDEN = ("src.display_manager", "src.plugin_system") #: Existing module-level violations, by file name, each with the reason it is #: tolerated. Empty when this test was added (core 3.4.0 + sports_helpers): @@ -124,13 +123,13 @@ def test_the_scan_sees_a_direct_import(tmp_path): except ImportError: pass if True: - import src.base_classes.sports + import src.plugin_system.plugin_manager from src import plugin_system def later(): from src.display_manager import DisplayManager """)) targets = {t for n in _module_level_imports(tree) for t in _targets(n)} - assert {"src.base_classes.sports", "src.display_manager", + assert {"src.plugin_system.plugin_manager", "src.display_manager", "src.plugin_system"} <= targets diff --git a/test/test_data_sources.py b/test/test_data_sources.py deleted file mode 100644 index d7366923..00000000 --- a/test/test_data_sources.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Tests for src/base_classes/data_sources.py - -Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource. -All HTTP calls are mocked to avoid network access. -""" - -import logging -from datetime import datetime, date -from unittest.mock import MagicMock, patch, Mock -import pytest -import requests - -from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource - - -def _make_logger() -> logging.Logger: - return logging.getLogger("test_data_sources") - - -def _mock_response(json_data: dict, status_code: int = 200): - resp = Mock(spec=requests.Response) - resp.status_code = status_code - resp.json.return_value = json_data - resp.raise_for_status = Mock() - if status_code >= 400: - resp.raise_for_status.side_effect = requests.HTTPError(response=resp) - return resp - - -# --------------------------------------------------------------------------- -# ESPNDataSource -# --------------------------------------------------------------------------- - -class TestESPNDataSource: - def setup_method(self): - self.source = ESPNDataSource(_make_logger()) - - def test_get_headers(self): - headers = self.source.get_headers() - assert headers["Accept"] == "application/json" - assert "LEDMatrix" in headers["User-Agent"] - - def test_fetch_live_games_returns_live_events(self): - live_event = { - "competitions": [{"status": {"type": {"state": "in"}}}] - } - non_live_event = { - "competitions": [{"status": {"type": {"state": "pre"}}}] - } - payload = {"events": [live_event, non_live_event]} - - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("football", "nfl") - - assert len(result) == 1 - assert result[0] is live_event - - def test_fetch_live_games_empty_when_none_live(self): - payload = {"events": [ - {"competitions": [{"status": {"type": {"state": "post"}}}]} - ]} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("football", "nfl") - assert result == [] - - def test_fetch_live_games_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("network failure")): - result = self.source.fetch_live_games("football", "nfl") - assert result == [] - - def test_fetch_schedule_returns_all_events(self): - events = [{"id": "1"}, {"id": "2"}] - payload = {"events": events} - start = datetime(2024, 1, 1) - end = datetime(2024, 1, 7) - - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_schedule("football", "nfl", (start, end)) - - assert len(result) == 2 - - def test_fetch_schedule_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("timeout")): - result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now())) - assert result == [] - - def test_fetch_standings_success(self): - payload = {"standings": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_standings("football", "nfl") - assert result == payload - - def test_fetch_standings_returns_empty_on_error(self): - # A transport failure is a RequestException, not a bare Exception. - # The old stand-in passed only because the handler caught everything, - # including bugs in the method under test. - with patch.object(self.source.session, "get", - side_effect=requests.ConnectionError("error")): - result = self.source.fetch_standings("football", "nfl") - assert result == {} - - # ------------------------------------------------------------------ - # fetch_standings endpoint selection - # - # College leagues publish a poll at /rankings and a records table at - # /standings; professional leagues have only /standings. Probing them in - # the wrong order still returns 200 -- just without a poll in it -- so - # nothing failed and the rank badge simply never appeared. Order is the - # behaviour here, so these tests assert it directly. - # ------------------------------------------------------------------ - - @staticmethod - def _requested_endpoints(mock_get): - """The endpoint names probed, in the order they were requested.""" - return [call.args[0].rsplit("/", 1)[-1] for call in mock_get.call_args_list] - - def test_professional_league_asks_standings_first(self): - payload = {"standings": []} - with patch.object(self.source.session, "get", - return_value=_mock_response(payload)) as mock_get: - result = self.source.fetch_standings("football", "nfl") - assert result == payload - assert self._requested_endpoints(mock_get) == ["standings"] - - def test_college_league_asks_rankings_first(self): - poll = {"rankings": [{"name": "AP Top 25"}]} - with patch.object(self.source.session, "get", - return_value=_mock_response(poll)) as mock_get: - result = self.source.fetch_standings("football", "college-football") - assert result == poll - assert self._requested_endpoints(mock_get) == ["rankings"] - - def test_rankings_200_without_a_poll_falls_through_to_standings(self): - """A 200 is not the same as an answer. - - This is the case the old code could not see: the endpoint responded, - so nothing raised, but the body carried no poll. - """ - empty_poll = _mock_response({"rankings": []}) - table = _mock_response({"standings": [{"entries": []}]}) - with patch.object(self.source.session, "get", - side_effect=[empty_poll, table]) as mock_get: - result = self.source.fetch_standings( - "basketball", "mens-college-basketball") - assert result == {"standings": [{"entries": []}]} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_404_on_the_first_endpoint_falls_through_quietly(self): - missing = _mock_response({}, status_code=404) - table = _mock_response({"standings": []}) - with patch.object(self.source.session, "get", - side_effect=[missing, table]) as mock_get: - result = self.source.fetch_standings("baseball", "college-baseball") - assert result == {"standings": []} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_recovers_from_a_non_404_failure_on_the_first_endpoint(self): - table = _mock_response({"standings": [{"entries": []}]}) - with patch.object(self.source.session, "get", - side_effect=[requests.ConnectionError("reset"), table]) as mock_get: - result = self.source.fetch_standings("football", "college-football") - assert result == {"standings": [{"entries": []}]} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_both_endpoints_failing_returns_empty(self): - with patch.object(self.source.session, "get", - side_effect=requests.ConnectionError("down")) as mock_get: - result = self.source.fetch_standings("football", "nfl") - assert result == {} - assert self._requested_endpoints(mock_get) == ["standings", "rankings"] - - def test_a_non_object_payload_is_treated_as_a_miss(self): - odd = _mock_response(["not", "an", "object"]) - table = _mock_response({"standings": []}) - with patch.object(self.source.session, "get", - side_effect=[odd, table]) as mock_get: - result = self.source.fetch_standings("football", "college-football") - assert result == {"standings": []} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_a_bug_in_this_method_is_not_swallowed_as_a_failed_endpoint(self): - """The guard for the narrowed handler. - - An error raised while reading the payload used to be caught by the - endpoint handler and reported as 'no poll here', which would silently - drop rankings for a league that has them. It must surface instead. - """ - boom = Mock(spec=requests.Response) - boom.status_code = 200 - boom.raise_for_status = Mock() - boom.json.side_effect = TypeError("a bug, not a network failure") - with patch.object(self.source.session, "get", return_value=boom): - with pytest.raises(TypeError): - self.source.fetch_standings("football", "nfl") - - def test_base_url_set_correctly(self): - assert "espn.com" in self.source.base_url - - -# --------------------------------------------------------------------------- -# MLBAPIDataSource -# --------------------------------------------------------------------------- - -class TestMLBAPIDataSource: - def setup_method(self): - self.source = MLBAPIDataSource(_make_logger()) - - def test_fetch_live_games_filters_live(self): - live_game = {"status": {"abstractGameState": "Live"}} - final_game = {"status": {"abstractGameState": "Final"}} - payload = {"dates": [{"games": [live_game, final_game]}]} - - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("baseball", "mlb") - - assert len(result) == 1 - assert result[0] is live_game - - def test_fetch_live_games_empty_dates(self): - payload = {"dates": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("baseball", "mlb") - assert result == [] - - def test_fetch_live_games_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_live_games("baseball", "mlb") - assert result == [] - - def test_fetch_schedule_aggregates_all_dates(self): - payload = { - "dates": [ - {"games": [{"id": "1"}, {"id": "2"}]}, - {"games": [{"id": "3"}]}, - ] - } - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now())) - assert len(result) == 3 - - def test_fetch_schedule_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now())) - assert result == [] - - def test_fetch_standings_success(self): - payload = {"records": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_standings("baseball", "mlb") - assert result == payload - - def test_fetch_standings_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_standings("baseball", "mlb") - assert result == {} - - -# --------------------------------------------------------------------------- -# SoccerAPIDataSource -# --------------------------------------------------------------------------- - -class TestSoccerAPIDataSource: - def setup_method(self): - self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123") - - def test_headers_include_api_key(self): - headers = self.source.get_headers() - assert headers["X-Auth-Token"] == "test-key-123" - - def test_headers_without_api_key(self): - source = SoccerAPIDataSource(_make_logger()) - headers = source.get_headers() - assert "X-Auth-Token" not in headers - - def test_fetch_live_games_success(self): - payload = {"matches": [{"id": "m1"}, {"id": "m2"}]} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("soccer", "eng.1") - assert len(result) == 2 - - def test_fetch_live_games_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_live_games("soccer", "eng.1") - assert result == [] - - def test_fetch_schedule_success(self): - payload = {"matches": [{"id": "m1"}]} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now())) - assert len(result) == 1 - - def test_fetch_schedule_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now())) - assert result == [] - - def test_fetch_standings_success(self): - payload = {"standings": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_standings("soccer", "PL") - assert result == payload - - def test_fetch_standings_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_standings("soccer", "PL") - assert result == {} diff --git a/test/test_sports_base_characterization.py b/test/test_sports_base_characterization.py deleted file mode 100644 index d81919bb..00000000 --- a/test/test_sports_base_characterization.py +++ /dev/null @@ -1,660 +0,0 @@ -"""Characterization tests for src/base_classes/sports.py. - -These tests PIN the current behavior of SportsCore / SportsUpcoming / -SportsRecent / SportsLive ahead of the sports-unification merge (features -from nine drifted plugin copies are about to be folded in). They assert -what the code DOES today, not what it should do — a few pinned behaviors -look like bugs and are flagged inline with "PINNED AS-IS". - -Coverage: -- `_extract_game_details_common` + the four sport extractors - (football/hockey/baseball/basketball) against realistic ESPN scoreboard - events (adapted from the ledmatrix-plugins monorepo test fixtures). - The output must remain a superset of the frozen skin view-model - contract (GUARANTEED_KEYS, imported from test_skin_system). -- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive - subclasses: population, favorite-team filtering, empty/failed-fetch - tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache - and every instance's requests session raises ConnectionError. -- Rendering smoke: one `display()` per mode class at 128x32 draws - non-zero ink onto a real PIL image. -- Guard rails: the skin-system seam methods on SportsCore must survive - the merge. -""" - -import logging -import sys -from datetime import datetime, timezone -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -import pytz -import requests -from freezegun import freeze_time -from PIL import Image - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so these tests can import the sports base classes off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.baseball import Baseball -from src.base_classes.basketball import Basketball -from src.base_classes.football import Football -from src.base_classes.hockey import Hockey, HockeyLive -from src.base_classes.sports import ( - SportsCore, - SportsLive, - SportsRecent, - SportsUpcoming, -) - -# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it. -from test.test_skin_system import GUARANTEED_KEYS - -SPORT_CLASSES = [Football, Hockey, Baseball, Basketball] -SPORT_IDS = ["football", "hockey", "baseball", "basketball"] - -# All update()-flow tests run at this frozen instant so the 21-day -# SportsRecent window and time.time() interval gates are deterministic. -FROZEN_NOW = "2026-01-20 12:00:00" - - -# --------------------------------------------------------------------------- -# ESPN scoreboard event builders (shape adapted from the monorepo fixtures, -# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json: -# team-shaped competitors with status/score/records). -# --------------------------------------------------------------------------- - -def _competitor(abbr, team_id, score, home_away, record="30-10-5"): - return { - "homeAway": home_away, - "id": team_id, - "score": score, - "team": { - "id": team_id, - "abbreviation": abbr, - "name": abbr.title(), - "displayName": abbr.title(), - "logo": None, - }, - "records": [{"summary": record}], - # The hockey extractor reads competitor["statistics"] for shot counts; - # it now defaults to an empty list when the key is absent rather than - # dropping the whole event (see - # test_hockey_event_without_statistics_still_extracts). - "statistics": [], - } - - -def make_event(event_id, state, date, home=("TB", "20", "3"), - away=("DAL", "9", "2"), period=2, clock="12:45", - name=None, short_detail=None, situation=None, - home_record="30-10-5", away_record="25-14-6"): - """Build a realistic ESPN scoreboard event in the given state - ('in' / 'post' / 'pre').""" - defaults = { - "in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"), - "post": ("STATUS_FINAL", "Final"), - "pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"), - } - default_name, default_detail = defaults[state] - status = { - "clock": 0.0, - "displayClock": clock, - "period": period, - "type": { - "id": "2", - "name": name or default_name, - "state": state, - "completed": state == "post", - "description": short_detail or default_detail, - "detail": short_detail or default_detail, - "shortDetail": short_detail or default_detail, - }, - } - competition = { - "id": event_id, - "date": date, - "status": status, - "competitors": [ - _competitor(home[0], home[1], home[2], "home", home_record), - _competitor(away[0], away[1], away[2], "away", away_record), - ], - } - if situation is not None: - competition["situation"] = situation - return { - "id": event_id, - "date": date, - "name": f"{away[0]} at {home[0]}", - "shortName": f"{away[0]} @ {home[0]}", - "competitions": [competition], - # Real ESPN payloads duplicate status at the event top level; the - # baseball extractor reads it there for live innings. - "status": status, - } - - -def make_probe(favorites=None): - """Bare-bones SportsCore stand-in for exercising the real extractors - unbound (same pattern as TestViewModelContract in test_skin_system).""" - probe = MagicMock() - probe.logger = logging.getLogger("test_sports_base_characterization") - probe.favorite_teams = list(favorites or []) - probe.config = {} - probe.logo_dir = Path("assets/logos") - probe._get_timezone.return_value = pytz.utc - probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th" - # The sport extractors call self._extract_game_details_common — route - # it to the real implementation instead of a MagicMock. - probe._extract_game_details_common = ( - lambda event: SportsCore._extract_game_details_common(probe, event)) - return probe - - -def extract(sport_cls, event, favorites=None): - return sport_cls._extract_game_details(make_probe(favorites), event) - - -# --------------------------------------------------------------------------- -# 1. _extract_game_details_common contract, per wired sport -# --------------------------------------------------------------------------- - -class TestExtractGameDetailsContract: - @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) - def test_live_event_guaranteed_keys_and_values(self, sport_cls): - event = make_event("401", "in", "2026-01-15T18:30:00Z") - details = extract(sport_cls, event) - assert details is not None - missing = [k for k in GUARANTEED_KEYS if k not in details] - assert not missing, ( - f"{sport_cls.__name__} extractor no longer emits {missing} — " - "these keys are the frozen skin view-model contract.") - assert details["id"] == "401" - assert details["home_abbr"] == "TB" - assert details["away_abbr"] == "DAL" - assert details["home_id"] == "20" - assert details["away_id"] == "9" - assert details["home_score"] == "3" - assert details["away_score"] == "2" - assert details["home_record"] == "30-10-5" - assert details["away_record"] == "25-14-6" - assert details["is_live"] is True - assert details["is_final"] is False - assert details["is_upcoming"] is False - assert details["status_text"] == "P2 12:45" - assert details["start_time_utc"] == datetime( - 2026, 1, 15, 18, 30, tzinfo=timezone.utc) - # Sport-specific formatting of the same event: - if sport_cls in (Football, Basketball): - assert details["period_text"] == "Q2" - assert details["clock"] == "12:45" - elif sport_cls is Hockey: - assert details["period_text"] == "P2" - assert details["clock"] == "12:45" - else: # Baseball keys inning/status instead of period_text - assert details["inning"] == 2 - assert details["status_state"] == "in" - - @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) - def test_final_event_classification(self, sport_cls): - event = make_event("402", "post", "2026-01-14T00:00:00Z", - home=("BOS", "1", "4"), away=("TOR", "21", "2"), - period=3, clock="0:00") - details = extract(sport_cls, event) - assert details is not None - assert details["is_final"] is True - assert details["is_live"] is False - assert details["is_upcoming"] is False - assert details["home_score"] == "4" - assert details["away_score"] == "2" - if sport_cls in (Football, Hockey, Basketball): - assert details["period_text"] == "Final" - - @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) - def test_upcoming_event_classification(self, sport_cls): - event = make_event("403", "pre", "2026-01-15T18:30:00Z", - home=("NYR", "13", "0"), away=("PIT", "16", "0"), - period=0, clock="0:00") - details = extract(sport_cls, event) - assert details is not None - assert details["is_upcoming"] is True - assert details["is_live"] is False - assert details["is_final"] is False - # Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM, - # date rendered through display_manager.format_date_with_ordinal. - assert details["game_time"] == "6:30PM" - assert details["game_date"] == "Jan 15th" - - def test_halftime_state_flags(self): - # is_halftime keys off name STATUS_HALFTIME (or state "halftime") - # while state "in" still counts as live. - event = make_event("404", "in", "2026-01-15T18:30:00Z", - name="STATUS_HALFTIME", short_detail="Halftime") - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["is_live"] is True - assert details["is_halftime"] is True - - def test_state_name_conflict_is_both_final_and_upcoming(self): - # PINNED AS-IS (looks like a bug): is_upcoming also matches on - # status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so - # an event with state="post" but name="Scheduled" reports BOTH - # is_final and is_upcoming True. - event = make_event("405", "post", "2026-01-14T00:00:00Z", - name="Scheduled") - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["is_final"] is True - assert details["is_upcoming"] is True - - def test_zero_zero_record_blanked(self): - event = make_event("406", "pre", "2026-01-15T18:30:00Z", - home_record="0-0", away_record="0-0-0") - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["home_record"] == "" - assert details["away_record"] == "" - - def test_missing_abbreviation_uses_name_prefix(self): - event = make_event("407", "pre", "2026-01-15T18:30:00Z") - for comp in event["competitions"][0]["competitors"]: - del comp["team"]["abbreviation"] - comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \ - else "Penguins" - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["home_abbr"] == "Sha" - assert details["away_abbr"] == "Pen" - - def test_empty_or_malformed_event_returns_none_tuple(self): - probe = make_probe() - assert SportsCore._extract_game_details_common(probe, {}) == \ - (None, None, None, None, None) - assert SportsCore._extract_game_details_common(probe, None) == \ - (None, None, None, None, None) - # Malformed event (no competitions) is swallowed, not raised. - assert SportsCore._extract_game_details_common( - probe, {"id": "999"}) == (None, None, None, None, None) - - def test_football_live_situation_fields(self): - event = make_event( - "408", "in", "2026-01-15T18:30:00Z", - situation={ - "shortDownDistanceText": "3rd & 4", - "downDistanceText": "3rd & 4 at TB 30", - "isRedZone": False, - "possession": "20", - "homeTimeouts": 2, - "awayTimeouts": 3, - }) - details = extract(Football, event) - assert details["down_distance_text"] == "3rd & 4" - assert details["down_distance_text_long"] == "3rd & 4 at TB 30" - assert details["possession"] == "20" - assert details["possession_indicator"] == "home" # matches home id - assert details["home_timeouts"] == 2 - assert details["away_timeouts"] == 3 - - def test_hockey_live_power_play_and_default_shots(self): - event = make_event("409", "in", "2026-01-15T18:30:00Z", - situation={"isPowerPlay": True, "penalties": ""}) - details = extract(Hockey, event) - assert details["power_play"] is True - # Empty statistics arrays -> save-percentage math yields 0 shots. - assert details["home_shots"] == 0 - assert details["away_shots"] == 0 - - def test_hockey_event_without_statistics_still_extracts(self): - # FIXED (was pinned as returning None): the hockey extractor used to - # iterate competitor["statistics"] unguarded, so a competitor without - # the key raised KeyError internally and the WHOLE event was dropped - # despite valid scores and status. It now defaults to an empty list, - # matching the behaviour already shipped in the hockey plugin, so the - # event survives with zeroed shot counts -- the same values - # test_hockey_live_power_play_and_default_shots already expects for an - # EMPTY statistics array. - event = make_event("410", "in", "2026-01-15T18:30:00Z") - for comp in event["competitions"][0]["competitors"]: - del comp["statistics"] - details = extract(Hockey, event) - assert details is not None - assert details["home_abbr"] == "TB" - assert details["away_abbr"] == "DAL" - assert details["home_score"] == "3" - assert details["home_shots"] == 0 - assert details["away_shots"] == 0 - - def test_baseball_live_inning_and_count(self): - event = make_event( - "411", "in", "2026-07-16T23:05:00Z", - home=("LAD", "19", "5"), away=("SF", "26", "3"), - period=7, short_detail="Bot 7th", - situation={ - "count": {"balls": 2, "strikes": 1}, - "outs": 2, - "onFirst": True, - "onSecond": False, - "onThird": True, - }) - details = extract(Baseball, event) - assert details["inning"] == 7 # from top-level status period - assert details["inning_half"] == "bottom" - assert details["balls"] == 2 - assert details["strikes"] == 1 - assert details["outs"] == 2 - assert details["bases_occupied"] == [True, False, True] - assert details["status"] == "status_in_progress" - assert details["series_summary"] == "" - - def test_baseball_live_without_top_level_status_still_extracts(self): - # FIXED (was pinned as returning None): the baseball extractor read - # game_event["status"] -- the event TOP-LEVEL status -- for the - # inning, so an otherwise-valid live event lacking that duplicate key - # was dropped entirely. Real ESPN events carry status in both places, - # but MiLB events (synthesized from the MLB Stats API into an - # ESPN-like shape) populate only the competition-level one. It now - # reads the competition-level `status` that - # _extract_game_details_common has already validated, so it can never - # be missing at that point. - event = make_event("412", "in", "2026-07-16T23:05:00Z", period=7) - del event["status"] - details = extract(Baseball, event) - assert details is not None - assert details["inning"] == 7 - - def test_baseball_live_without_top_level_status_extracts_for_favorites(self): - # The favourite-team branch logs the status payload for diagnostics and - # read the same event top-level key the test above proves can be - # absent. So the identical MiLB event that extracts fine for a - # non-favourite raised KeyError and was dropped once the team WAS a - # favourite -- the worst shape for the bug, since it only hit the games - # the user cared most about, and only on the diagnostic path that was - # supposed to help debug them. - event = make_event("413", "in", "2026-07-16T23:05:00Z", period=7) - del event["status"] - details = extract(Baseball, event, favorites=["TB"]) - assert details is not None - assert details["inning"] == 7 - - -# --------------------------------------------------------------------------- -# 2. update() flow on concrete subclasses (offline, cache-fed) -# --------------------------------------------------------------------------- - -class _UpcomingHarness(Hockey, SportsUpcoming): - """Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data.""" - - def _fetch_data(self): - return self.cache_manager.get(f"{self.sport_key}_schedule") - - -class _RecentHarness(Hockey, SportsRecent): - def _fetch_data(self): - return self.cache_manager.get(f"{self.sport_key}_schedule") - - -class _LiveHarness(HockeyLive): - def _fetch_data(self): - return self.cache_manager.get(f"{self.sport_key}_schedule") - - -def make_schedule(): - """A mixed schedule around the frozen 'now' of 2026-01-20.""" - return {"events": [ - # Final 6 days ago — inside the recent 21-day window. - make_event("9001", "post", "2026-01-14T00:00:00Z", - home=("BOS", "1", "4"), away=("TOR", "21", "2"), - period=3, clock="0:00"), - # Live game. - make_event("9002", "in", "2026-01-15T00:30:00Z", - home=("TB", "20", "3"), away=("DAL", "9", "2")), - # Two scheduled games. - make_event("9003", "pre", "2026-01-16T00:00:00Z", - home=("NYR", "13", "0"), away=("PIT", "16", "0"), - period=0), - make_event("9004", "pre", "2026-01-17T00:00:00Z", - home=("BOS", "1", "0"), away=("MTL", "10", "0"), - period=0), - # Final from November — outside the recent 21-day window. - make_event("9005", "post", "2025-11-01T00:00:00Z", - home=("SEA", "124292", "1"), away=("VAN", "22", "5"), - period=3, clock="0:00"), - ]} - - -@pytest.fixture -def build_manager(monkeypatch, tmp_path): - """Factory for concrete sports managers: mocked display/cache managers, - logo dir redirected to tmp, background service stubbed, and the - requests session rigged to prove nothing hits the network.""" - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - - # Rig requests.Session BEFORE any manager is built. Construction creates - # both SportsCore.session and the ESPNDataSource.session; replacing only - # manager.session after the fact (below) leaves data_source.session real, - # so an accidental fetch during or right after construction could reach - # the network. Patching the class makes every session created here raise. - def _offline_get(*args, **kwargs): - raise requests.exceptions.ConnectionError( - "characterization tests are offline") - - monkeypatch.setattr(requests.Session, "get", _offline_get) - - def build(cls, schedule, **mode_cfg): - config = { - "timezone": "UTC", - "display": {}, - "nhl_scoreboard": {"enabled": True, **mode_cfg}, - } - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - display_manager.format_date_with_ordinal.side_effect = ( - lambda dt: dt.strftime("%b %d")) - cache_manager = MagicMock() - cache_manager.get.return_value = schedule - cache_manager.cache_dir = str(tmp_path) - manager = cls(config, display_manager, cache_manager, - logging.getLogger("test_sports_base_characterization"), - "nhl") - # Safety net: any accidental network fetch must fail loudly. - manager.session = MagicMock() - manager.session.get.side_effect = requests.exceptions.ConnectionError( - "characterization tests are offline") - return manager - - return build - - -def _ids(games): - return [g["id"] for g in games] - - -@freeze_time(FROZEN_NOW) -class TestUpcomingUpdateFlow: - def test_populates_games_list_sorted_by_start_time(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule()) - manager.update() - # PINNED AS-IS: SportsUpcoming filters purely on is_upcoming - # (state 'pre') — there is NO date filter, so 'pre' games whose - # start time is already in the past (9003/9004 vs frozen 1/20) - # are still shown. - assert _ids(manager.games_list) == ["9003", "9004"] - assert manager.current_game["id"] == "9003" - - def test_filters_by_favorite_teams(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=["BOS"]) - manager.update() - assert _ids(manager.games_list) == ["9004"] - assert manager.current_game["id"] == "9004" - - def test_favorites_only_with_no_favorites_shows_nothing( - self, build_manager): - # PINNED AS-IS: show_favorite_teams_only=True with an empty - # favorite_teams list drops every game rather than falling back - # to showing all games. - manager = build_manager(_UpcomingHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=[]) - manager.update() - assert manager.games_list == [] - assert manager.current_game is None - - def test_caps_at_upcoming_games_to_show(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule(), - upcoming_games_to_show=1) - manager.update() - assert _ids(manager.games_list) == ["9003"] - - def test_tolerates_empty_events_list(self, build_manager): - manager = build_manager(_UpcomingHarness, {"events": []}) - manager.update() # must not raise - assert manager.games_list == [] - assert manager.current_game is None - - def test_tolerates_fetch_returning_none(self, build_manager): - manager = build_manager(_UpcomingHarness, None) - manager.update() # must not raise - assert manager.games_list == [] - assert manager.current_game is None - - def test_disabled_manager_update_is_noop(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule(), - enabled=False) - manager.update() - assert manager.games_list == [] - manager.cache_manager.get.assert_not_called() - - -@freeze_time(FROZEN_NOW) -class TestRecentUpdateFlow: - def test_populates_only_finals_within_21_day_window(self, build_manager): - manager = build_manager(_RecentHarness, make_schedule()) - manager.update() - # 9001 (final, 6 days old) kept; 9005 (final, ~80 days old) - # excluded by the 21-day cutoff; live/pre games excluded. - assert _ids(manager.games_list) == ["9001"] - assert manager.current_game["id"] == "9001" - assert manager.current_game["is_final"] is True - - def test_filters_by_favorite_teams(self, build_manager): - manager = build_manager(_RecentHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=["TOR"]) - manager.update() - assert _ids(manager.games_list) == ["9001"] - - stranger = build_manager(_RecentHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=["XXX"]) - stranger.update() - assert stranger.games_list == [] - assert stranger.current_game is None - - def test_tolerates_empty_events_list(self, build_manager): - manager = build_manager(_RecentHarness, {"events": []}) - manager.update() # must not raise - assert manager.games_list == [] - assert manager.current_game is None - - -@freeze_time(FROZEN_NOW) -class TestLiveUpdateFlow: - def test_selects_only_live_games(self, build_manager): - manager = build_manager(_LiveHarness, make_schedule()) - manager.update() - assert _ids(manager.live_games) == ["9002"] - assert manager.current_game["id"] == "9002" - assert manager.current_game["is_live"] is True - - def test_no_live_games_clears_current_game(self, build_manager): - schedule = {"events": [ - make_event("9001", "post", "2026-01-14T00:00:00Z"), - make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0), - ]} - manager = build_manager(_LiveHarness, schedule) - manager.update() - assert manager.live_games == [] - assert manager.current_game is None - - -# --------------------------------------------------------------------------- -# 3. Rendering smoke — one display() per mode class at 128x32 -# --------------------------------------------------------------------------- - -def _fake_logo(*args, **kwargs): - return Image.new("RGBA", (24, 24), (180, 30, 30, 255)) - - -@freeze_time(FROZEN_NOW) -class TestRenderingSmoke: - def _assert_rendered(self, manager): - manager.display_manager.update_display.assert_called() - assert manager.display_manager.image.convert("L").getbbox() is not None - - def test_upcoming_display_draws_ink(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule()) - manager.update() - manager._load_and_resize_logo = _fake_logo - assert manager.display(force_clear=True) is True - self._assert_rendered(manager) - - def test_recent_display_draws_ink(self, build_manager): - manager = build_manager(_RecentHarness, make_schedule()) - manager.update() - manager._load_and_resize_logo = _fake_logo - assert manager.display(force_clear=True) is True - self._assert_rendered(manager) - - def test_live_display_draws_ink(self, build_manager): - manager = build_manager(_LiveHarness, make_schedule()) - manager.update() - manager._load_and_resize_logo = _fake_logo - assert manager.display(force_clear=True) is True - self._assert_rendered(manager) - - def test_draw_scorebug_layout_direct_call_does_not_raise( - self, build_manager): - # The base-class placeholder renderer must also stay callable. - manager = build_manager(_UpcomingHarness, make_schedule()) - game = manager._extract_game_details(make_schedule()["events"][2]) - manager._load_and_resize_logo = _fake_logo - SportsCore._draw_scorebug_layout(manager, game) - assert manager.display_manager.image.convert("L").getbbox() is not None - - -# --------------------------------------------------------------------------- -# 4. Guard rails — seams the merge must not silently drop -# --------------------------------------------------------------------------- - -class TestGuardRails: - def test_skin_seam_methods_survive(self): - for name in ("_resolve_skin_id", "_get_skin", "_render_game", - "render_skin_card"): - assert callable(getattr(SportsCore, name, None)), ( - f"SportsCore.{name} is part of the skin-system seam " - "(src/skin_system) — the sports-unification merge must " - "keep it.") - - def test_skin_mode_per_class(self): - assert SportsCore.SKIN_MODE == "live" - assert SportsUpcoming.SKIN_MODE == "upcoming" - assert SportsRecent.SKIN_MODE == "recent" - assert SportsLive.SKIN_MODE == "live" # inherits the default - - def test_core_display_and_extractor_seams_survive(self): - for name in ("display", "_draw_scorebug_layout", - "_extract_game_details_common", "update"): - owner = SportsCore if name != "update" else SportsUpcoming - assert callable(getattr(owner, name, None)), name diff --git a/test/test_sports_capabilities.py b/test/test_sports_capabilities.py deleted file mode 100644 index a3cd0f3f..00000000 --- a/test/test_sports_capabilities.py +++ /dev/null @@ -1,1077 +0,0 @@ -"""Tests for the opt-in sports capabilities (phase B2). - -Two properties matter beyond "the code works": - -1. **Opting out is structural.** A mode class that does not mix in - ``CelebrationMixin`` must have none of its attributes or methods — not - merely a disabled flag. ``TestOptOutIsStructural`` asserts that directly, - because it is the property the whole mixin design exists to buy. - -2. **The promoted behavior matches the plugin copies.** These bodies came from - afl/soccer/nrl (goal dialect) and football (score dialect); the tests pin - the reconciled behavior of both, including the three seams where the - lineages genuinely disagreed. - -See docs/SPORTS_UNIFICATION.md. -""" - -import sys -import time -from unittest.mock import MagicMock - -import pytest - -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.sports.capabilities import ( # noqa: E402 - CelebrationMixin, - RotationStrategy, - SimpleRotation, - SmoothWeightedRotation, - WeightedCycleRotation, - get_rotation_strategy, - register_rotation_strategy, -) - - -def game(gid, home="HOM", away="AWY", home_score=0, away_score=0, **extra): - g = { - "id": gid, - "home_abbr": home, - "away_abbr": away, - "home_id": f"{gid}-h", - "away_id": f"{gid}-a", - "home_score": home_score, - "away_score": away_score, - } - g.update(extra) - return g - - -# --------------------------------------------------------------------------- -# Rotation strategies -# --------------------------------------------------------------------------- - -def boost(favorites, factor=3): - """A weight_for callable of the shape the plugins supply.""" - return lambda g: factor if g.get("home_abbr") in favorites else 1 - - -class TestRegistry: - @pytest.mark.parametrize("name,cls", [ - ("simple", SimpleRotation), - ("weighted", WeightedCycleRotation), - ("swrr", SmoothWeightedRotation), - ]) - def test_builtin_names_resolve(self, name, cls): - assert isinstance(get_rotation_strategy(name), cls) - - def test_unknown_name_falls_back_to_simple(self): - """The name comes from user config; a typo should cost the boost, not - the scoreboard.""" - assert isinstance(get_rotation_strategy("typo"), SimpleRotation) - - def test_a_plugin_can_register_its_own(self): - class MyRotation(SimpleRotation): - pass - - register_rotation_strategy("test-only", MyRotation) - try: - assert isinstance(get_rotation_strategy("test-only"), MyRotation) - assert MyRotation.name == "test-only" - finally: - from src.base_classes.sports.capabilities import rotation - rotation._REGISTRY.pop("test-only", None) - - def test_empty_name_is_rejected(self): - with pytest.raises(ValueError): - register_rotation_strategy("", SimpleRotation) - - @pytest.mark.parametrize("bad", [ - SimpleRotation(), # an instance, not the class - str, # unrelated class - lambda **kw: None, # a factory function - ]) - def test_a_non_strategy_factory_is_rejected(self, bad): - """Fail at registration, not several frames later inside schedule(), - where the cause is no longer on the stack.""" - with pytest.raises(TypeError): - register_rotation_strategy("bad-factory", bad) - - def test_weight_for_is_optional(self): - """Default weights are equal, so every strategy degenerates to a plain - round robin — the pre-boost behavior.""" - games = [game("a"), game("b"), game("c")] - for name in ("simple", "weighted", "swrr"): - assert get_rotation_strategy(name).schedule(games) == ["a", "b", "c"] - - -class TestWeights: - def test_games_without_an_id_are_skipped(self): - strategy = get_rotation_strategy("weighted") - assert strategy.weights([game("a"), {"home_abbr": "X"}]) == {"a": 1} - - @pytest.mark.parametrize("bad", [0, -5]) - def test_non_positive_weights_are_clamped_to_one(self, bad): - """A zero weight would starve the game out of the rotation entirely and - collapse total_weight — no caller means that.""" - strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad) - assert strategy.weights([game("a")]) == {"a": 1} - - @pytest.mark.parametrize("bad", [None, "three", object()]) - def test_unusable_weights_fall_back_to_one(self, bad): - strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad) - assert strategy.weights([game("a")]) == {"a": 1} - - def test_huge_weights_are_clamped(self): - """A cycle is sum(weights) long and each step scans every game, so an - unbounded weight from a misread config spins the display thread.""" - strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000) - assert strategy.weights([game("a")]) == {"a": RotationStrategy.MAX_WEIGHT} - - def test_a_clamped_cycle_stays_bounded(self): - strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000) - order = strategy.schedule([game("a"), game("b")]) - assert len(order) == 2 * RotationStrategy.MAX_WEIGHT - - -class TestSimpleRotation: - def test_one_pass_in_feed_order(self): - games = [game("a"), game("b"), game("c")] - assert SimpleRotation().schedule(games) == ["a", "b", "c"] - - def test_weights_are_ignored(self): - games = [game("a", home="FAV"), game("b")] - strategy = SimpleRotation(weight_for=boost({"FAV"}, 5)) - assert strategy.schedule(games) == ["a", "b"] - - def test_empty(self): - assert SimpleRotation().schedule([]) == [] - assert SimpleRotation().next_game([]) is None - - -class TestWeightedCycleRotation: - def test_no_boost_is_a_single_pass(self): - games = [game("a"), game("b"), game("c")] - strategy = WeightedCycleRotation(weight_for=boost({"NONE"})) - assert strategy.schedule(games) == ["a", "b", "c"] - - def test_favorite_gets_boost_many_slots(self): - games = [game("a", home="FAV"), game("b")] - order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games) - assert len(order) == 4 - assert order.count("a") == 3 - assert order.count("b") == 1 - - def test_repeats_are_spaced_not_clumped(self): - """The point of SWRR over naive repetition: 'aaab' is what we must NOT - produce.""" - games = [game("a", home="FAV"), game("b")] - order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games) - assert order != ["a", "a", "a", "b"] - assert order[0] == "a", "highest weight is scheduled first" - - def test_is_stateless_across_calls(self): - games = [game("a", home="FAV"), game("b")] - strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)) - assert strategy.schedule(games) == strategy.schedule(games) - - def test_next_game_returns_the_first_of_the_cycle(self): - games = [game("a"), game("b", home="FAV")] - strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 4)) - assert strategy.next_game(games)["id"] == "b" - - def test_empty(self): - assert WeightedCycleRotation().schedule([]) == [] - - -class TestSmoothWeightedRotation: - def test_no_boost_is_plain_round_robin(self): - games = [game("a"), game("b"), game("c")] - strategy = SmoothWeightedRotation() - assert [strategy.next_game(games)["id"] for _ in range(6)] == [ - "a", "b", "c", "a", "b", "c"] - - def test_favorite_wins_the_share_over_a_long_run(self): - games = [game("a", home="FAV"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3)) - picks = [strategy.next_game(games)["id"] for _ in range(40)] - assert picks.count("a") == 30 - assert picks.count("b") == 10 - - def test_no_clustering_seam_across_cycle_boundaries(self): - """The property that motivates keeping this strategy separate from the - precomputed one: state persists, so there is no restart every N picks - and therefore no place where repeats bunch up.""" - games = [game("a", home="FAV"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3)) - picks = [strategy.next_game(games)["id"] for _ in range(40)] - assert "aaaa" not in "".join(picks) - - def test_a_new_favorite_is_queued_first(self): - """A favorite's game that has just gone live starts at weight 0, gets - its full weight on the next call, and so wins the first pick after it - appears — without a special-cased branch.""" - games = [game("a"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 5)) - for _ in range(3): - strategy.next_game(games) - games.append(game("c", home="FAV")) - assert strategy.next_game(games)["id"] == "c" - - def test_state_for_games_no_longer_live_is_dropped(self): - games = [game("a"), game("b")] - strategy = SmoothWeightedRotation() - strategy.next_game(games) - strategy.next_game([game("a")]) - assert set(strategy._current) == {"a"} - - def test_reset_clears_state(self): - games = [game("a"), game("b")] - strategy = SmoothWeightedRotation() - strategy.next_game(games) - strategy.reset() - assert strategy._current == {} - assert strategy.next_game(games)["id"] == "a" - - def test_schedule_previews_without_perturbing_state(self): - games = [game("a", home="FAV"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3)) - preview = strategy.schedule(games) - actual = [strategy.next_game(games)["id"] for _ in range(len(preview))] - assert preview == actual - - def test_preview_uses_the_subclass_ordering(self): - """schedule() promises the order repeated next_game calls produce. Built - from the base class, a subclass that overrides next_game gets a preview - of the wrong algorithm.""" - class Reversed(SmoothWeightedRotation): - def next_game(self, games): - return super().next_game(list(reversed(games))) - - games = [game("a"), game("b"), game("c")] - strategy = Reversed() - preview = strategy.schedule(games) - actual = [strategy.next_game(games)["id"] for _ in range(len(preview))] - assert preview == actual - - def test_empty(self): - assert SmoothWeightedRotation().next_game([]) is None - assert SmoothWeightedRotation().schedule([]) == [] - - def test_games_without_ids_are_ignored(self): - assert SmoothWeightedRotation().next_game([{"home_abbr": "X"}]) is None - - -class TestStrategiesAgreeWithinACycle: - """The survey's core finding: the 'three dialects' are one algorithm. They - must produce the same order within a cycle; they differ only at the - boundary, which is why both shapes survive.""" - - @pytest.mark.parametrize("factor", [2, 3, 5]) - def test_first_cycle_matches(self, factor): - games = [game("a", home="FAV"), game("b"), game("c")] - weight_for = boost({"FAV"}, factor) - assert (SmoothWeightedRotation(weight_for=weight_for).schedule(games) - == WeightedCycleRotation(weight_for=weight_for).schedule(games)) - - -# --------------------------------------------------------------------------- -# Differential: core strategies vs. the plugin implementations they replace -# --------------------------------------------------------------------------- - -BOOST = 3 - - -def _is_fav(g): - return g.get("home_abbr") == "FAV" - - -def _weight_for(g): - return BOOST if _is_fav(g) else 1 - - -class _PluginSwrr: - """afl / nrl / soccer ``_swrr_advance``, transcribed verbatim.""" - - favorite_live_boost = BOOST - - def _is_favorite_game(self, g): - return _is_fav(g) - - def advance(self, games): - if not games: - return None - weights = {} - for g in games: - gid = g.get("id") - if gid is None: - continue - weights[gid] = self.favorite_live_boost if self._is_favorite_game(g) else 1 - if not weights: - return None - if not hasattr(self, "_swrr_weights"): - self._swrr_weights = {} - self._swrr_weights = { - gid: w for gid, w in self._swrr_weights.items() if gid in weights} - for gid, w in weights.items(): - self._swrr_weights[gid] = self._swrr_weights.get(gid, 0) + w - total_weight = sum(weights.values()) - ids_in_order = [g.get("id") for g in games if g.get("id") in weights] - best_gid = max(ids_in_order, key=lambda gid: self._swrr_weights[gid]) - self._swrr_weights[best_gid] -= total_weight - return next(g for g in games if g.get("id") == best_gid) - - -def _plugin_weighted_schedule(games): - """football / baseball / basketball ``_build_weighted_schedule``, verbatim.""" - if not games: - return [] - weights = {g["id"]: (BOOST if _is_fav(g) else 1) for g in games} - total_weight = sum(weights.values()) - if total_weight <= len(games): - return [g["id"] for g in games] - current_weight = {gid: 0 for gid in weights} - schedule = [] - for _ in range(total_weight): - for gid in weights: - current_weight[gid] += weights[gid] - picked = max(current_weight, key=lambda gid: current_weight[gid]) - current_weight[picked] -= total_weight - schedule.append(picked) - return schedule - - -def _plugin_rotation_schedule(games): - """hockey ``_build_rotation_schedule``, transcribed verbatim.""" - weights = [(g["id"], BOOST if _is_fav(g) else 1) for g in games] - total_weight = sum(w for _, w in weights) - if not weights or total_weight <= 0: - return [g["id"] for g in games] - current_weights = {gid: 0 for gid, _ in weights} - schedule = [] - for _ in range(total_weight): - best_id, best_current = None, None - for gid, w in weights: - current_weights[gid] += w - if best_current is None or current_weights[gid] > best_current: - best_id, best_current = gid, current_weights[gid] - current_weights[best_id] -= total_weight - schedule.append(best_id) - return schedule - - -def _cases(): - """Every live-game shape up to 4 games: each either a favorite or not. - - Exhaustive rather than random so the gate is deterministic — a rotation - regression must fail the same way on every run. - """ - import itertools - for size in range(1, 5): - for flags in itertools.product(("FAV", "OTH"), repeat=size): - yield [game(f"g{i}", home=abbr) for i, abbr in enumerate(flags)] - - -class TestMatchesThePluginImplementations: - """The promotion is only safe if these reproduce the plugin copies exactly. - - B5 deletes the bundled copies on the strength of this: each core strategy is - checked against the verbatim source it replaces, over every live-game shape - up to four games. - """ - - @pytest.mark.parametrize("games", list(_cases())) - def test_swrr_matches_the_incremental_plugin_picker(self, games): - plugin = _PluginSwrr() - core = SmoothWeightedRotation(weight_for=_weight_for) - # 60 picks: long enough to cross many cycle boundaries, where a - # state-handling divergence would show up. - assert ([plugin.advance(games)["id"] for _ in range(60)] - == [core.next_game(games)["id"] for _ in range(60)]) - - @pytest.mark.parametrize("games", list(_cases())) - def test_weighted_matches_the_football_lineage(self, games): - assert (_plugin_weighted_schedule(games) - == WeightedCycleRotation(weight_for=_weight_for).schedule(games)) - - @pytest.mark.parametrize("games", list(_cases())) - def test_weighted_matches_hockeys_loop_shape(self, games): - assert (_plugin_rotation_schedule(games) - == WeightedCycleRotation(weight_for=_weight_for).schedule(games)) - - -# --------------------------------------------------------------------------- -# Celebrations -# --------------------------------------------------------------------------- - -class _FakeLive: - """Stand-in for SportsLive: just the surface the mixin touches.""" - - def __init__(self, mode_config=None, favorite_teams=None): - self.mode_config = mode_config or {} - self.favorite_teams = favorite_teams or [] - self.logger = MagicMock() - self.display_manager = MagicMock() - self.is_enabled = True - self.current_game = None - self.last_game_switch = 0 - self.display_calls = [] - - def _favorite_key(self, game, side): - return game.get(f"{side}_abbr") - - def display(self, force_clear=False): - self.display_calls.append(force_clear) - return True - - -class _Celebrating(CelebrationMixin, _FakeLive): - pass - - -class _Coalescing(CelebrationMixin, _FakeLive): - COALESCE_SCORING_SEQUENCE = True - - def score_phrase(self, points, team_abbr): - return "TOUCHDOWN!" if points >= 6 else f"{team_abbr} FIELD GOAL!" - - -class _ById(CelebrationMixin, _FakeLive): - """The nrl shape: ambiguous abbreviations, so favorites match on team id.""" - - def _favorite_key(self, game, side): - return game.get(f"{side}_id") - - -@pytest.fixture -def celebrating(): - def _build(cls=_Celebrating, mode_config=None, favorites=None): - return cls(mode_config=mode_config, favorite_teams=favorites) - return _build - - -class TestOptOutIsStructural: - """The property the mixin design exists to buy: a class that does not opt in - has none of this code — not a disabled flag, not an unused attribute.""" - - def test_a_non_celebrating_class_has_no_celebration_surface(self): - plain = _FakeLive() - for attribute in ("active_celebration", "_score_baselines", - "celebration_enabled", "celebration_duration", - "_check_for_score", "_check_for_win", - "has_active_celebration", "_draw_celebration_layout"): - assert not hasattr(plain, attribute), ( - f"{attribute} leaked onto a class that never opted in") - - def test_the_mixin_is_absent_from_a_non_celebrating_mro(self): - assert CelebrationMixin not in _FakeLive.__mro__ - assert CelebrationMixin in _Celebrating.__mro__ - - def test_mixin_does_not_require_the_base_to_know_about_it(self): - """SportsLive must carry no celebration hooks — that would be the - god-class shape the mixin replaces.""" - from src.base_classes.sports import SportsLive - source = __import__("inspect").getsource(SportsLive) - assert "celebration" not in source.lower() - - -class TestCelebrationConfig: - def test_defaults(self, celebrating): - manager = celebrating() - assert manager.celebration_enabled is True - assert manager.celebration_duration == 8 - assert manager.celebrate_opponent_scores is False - assert manager.active_celebration is None - - def test_reads_the_goal_spelling_of_the_opponent_key(self, celebrating): - """The soccer lineage's published schema says `celebrate_opponent_goals`; - adopting the mixin must not silently reset users' setting.""" - manager = celebrating(mode_config={"celebrate_opponent_goals": True}) - assert manager.celebrate_opponent_scores is True - - def test_reads_the_score_spelling_of_the_opponent_key(self, celebrating): - manager = celebrating(mode_config={"celebrate_opponent_scores": True}) - assert manager.celebrate_opponent_scores is True - - def test_score_spelling_wins_when_both_are_present(self, celebrating): - manager = celebrating(mode_config={"celebrate_opponent_scores": False, - "celebrate_opponent_goals": True}) - assert manager.celebrate_opponent_scores is False - - @pytest.mark.parametrize("bad", ["eight", None, {}, []]) - def test_unusable_duration_falls_back(self, celebrating, bad): - """The duration is compared numerically on the display path, outside - any try block — a string from a hand-edited config would propagate a - TypeError straight out of display().""" - manager = celebrating(mode_config={"celebration_duration": bad}) - assert manager.celebration_duration == 8.0 - - @pytest.mark.parametrize("bad", [0, -5]) - def test_non_positive_duration_is_floored(self, celebrating, bad): - """Zero or negative would arm a celebration that can never render.""" - manager = celebrating(mode_config={"celebration_duration": bad}) - assert manager.celebration_duration == 1.0 - - def test_numeric_string_duration_is_accepted(self, celebrating): - assert celebrating( - mode_config={"celebration_duration": "12"}).celebration_duration == 12.0 - - -class TestScoreDetection: - def test_first_sighting_never_celebrates(self, celebrating): - """A game already in progress at boot must not false-fire.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - assert manager._score_baselines["g1"] == {"away": 1, "home": 3} - - def test_increment_arms_a_celebration(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=0, away_score=0)) - manager._check_for_score(game("g1", home_score=1, away_score=0)) - assert manager.active_celebration["kind"] == "score" - assert manager.active_celebration["scored_side"] == "home" - - def test_no_change_does_not_fire(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=2)) - manager._check_for_score(game("g1", home_score=2)) - assert manager.active_celebration is None - - def test_decrement_rebases_silently(self, celebrating): - """A disallowed goal / correction must not celebrate, and must not leave - a stale baseline that fires on the way back up.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=2)) - manager._check_for_score(game("g1", home_score=1)) - assert manager.active_celebration is None - assert manager._score_baselines["g1"]["home"] == 1 - - def test_disabled_never_fires(self, celebrating): - manager = celebrating(mode_config={"celebration_enabled": False}) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - assert manager.active_celebration is None - - def test_game_without_an_id_is_ignored(self, celebrating): - manager = celebrating() - manager._check_for_score({"home_score": 1, "away_score": 0}) - assert manager.active_celebration is None - - @pytest.mark.parametrize("score", [None, "", "not-a-number-at-all"]) - def test_unusable_scores_are_ignored(self, celebrating, score): - manager = celebrating() - manager._check_for_score(game("g1", home_score=score)) - assert manager._score_baselines == {} - - @pytest.mark.parametrize("raw,expected", [ - ("7", 7), (7, 7), (7.0, 7), (" 7 ", 7), ("7 (SO)", 7), - ({"value": 7}, 7), ({"displayValue": "7"}, 7), - ]) - def test_score_coercion(self, raw, expected): - assert CelebrationMixin._score_to_int(raw) == expected - - def test_away_side_is_detected(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", away_score=0)) - manager._check_for_score(game("g1", away_score=1)) - assert manager.active_celebration["scored_side"] == "away" - - -class TestWhoGetsCelebrated: - def test_no_favorites_celebrates_everyone(self, celebrating): - """The user opted to show this game at all, so any score in it counts.""" - manager = celebrating(favorites=[]) - manager._check_for_score(game("g1", home="XXX", home_score=0)) - manager._check_for_score(game("g1", home="XXX", home_score=1)) - assert manager.active_celebration is not None - - def test_favorite_scores(self, celebrating): - manager = celebrating(favorites=["FAV"]) - manager._check_for_score(game("g1", home="FAV", home_score=0)) - manager._check_for_score(game("g1", home="FAV", home_score=1)) - assert manager.active_celebration is not None - - def test_opponent_suppressed_by_default(self, celebrating): - manager = celebrating(favorites=["FAV"]) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0)) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1)) - assert manager.active_celebration is None - - def test_opponent_celebrated_when_opted_in(self, celebrating): - manager = celebrating(mode_config={"celebrate_opponent_scores": True}, - favorites=["FAV"]) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0)) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1)) - assert manager.active_celebration is not None - - def test_matching_goes_through_the_favorite_key_seam(self, celebrating): - """nrl matches on team id because its abbreviations are ambiguous - ('NEW' is both Newcastle and New Zealand). Core must not care why.""" - manager = celebrating(_ById, favorites=["g1-h"]) - manager._check_for_score(game("g1", home="NEW", home_score=0)) - manager._check_for_score(game("g1", home="NEW", home_score=1)) - assert manager.active_celebration is not None - - def test_favorite_key_seam_also_excludes(self, celebrating): - manager = celebrating(_ById, favorites=["someone-else"]) - manager._check_for_score(game("g1", home="NEW", home_score=0)) - manager._check_for_score(game("g1", home="NEW", home_score=1)) - assert manager.active_celebration is None - - -class TestPhrasing: - def test_default_phrase_is_sport_neutral(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home="HOM", home_score=0)) - manager._check_for_score(game("g1", home="HOM", home_score=1)) - assert manager.active_celebration["phrase"] == "HOM SCORES!" - - def test_score_phrase_hook_sees_the_points_delta(self, celebrating): - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=6)) - assert manager.active_celebration["phrase"] == "TOUCHDOWN!" - - def test_score_phrase_hook_distinguishes_smaller_plays(self, celebrating): - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=3)) - assert manager.active_celebration["phrase"] == "HOM FIELD GOAL!" - - def test_win_phrase(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=2, away_score=1)) - assert manager.active_celebration["phrase"] == "HOM WINS!" - - -class TestCoalescing: - def test_off_by_default_two_goals_are_two_celebrations(self, celebrating): - """Soccer/afl/nrl: consecutive increments are distinct events, so - suppressing the second would swallow a real goal.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - first = manager.active_celebration["started_at"] - manager._check_for_score(game("g1", home_score=2)) - assert manager.active_celebration["started_at"] != first - assert manager.active_celebration["home_score"] == 2 - - def test_on_suppresses_the_extra_point_follow_up(self, celebrating): - """Football: a touchdown lands as +6, then +1 seconds later. One - takeover per scoring sequence.""" - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=6)) - armed = manager.active_celebration - manager._check_for_score(game("g1", home_score=7)) - assert manager.active_celebration is armed - - def test_suppression_still_advances_the_baseline(self, celebrating): - """Nothing may re-fire once the window closes.""" - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=6)) - manager._check_for_score(game("g1", home_score=7)) - assert manager._score_baselines["g1"]["home"] == 7 - - -class TestWinDetection: - def test_win_requires_a_baseline(self, celebrating): - """A game seen for the first time already-final (board started after - full time) must not fire.""" - manager = celebrating(favorites=["HOM"]) - manager._check_for_win(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - - def test_win_fires_once_only(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=3, away_score=1)) - manager.active_celebration = None - manager._check_for_win(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - - def test_draw_does_not_celebrate(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=2, away_score=2)) - assert manager.active_celebration is None - - def test_win_is_gated_strictly_on_favorites(self, celebrating): - """Unlike scores, a win with no favorites configured does NOT celebrate: - every game ends, so the fallback would be constant noise.""" - manager = celebrating(favorites=[]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - - def test_losing_favorite_does_not_celebrate(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=1, away_score=4)) - assert manager.active_celebration is None - - def test_away_favorite_wins(self, celebrating): - manager = celebrating(favorites=["AWY"]) - manager._check_for_score(game("g1", away_score=1)) - manager._check_for_win(game("g1", home_score=1, away_score=4)) - assert manager.active_celebration["scored_side"] == "away" - - -class TestCelebrationSnapshot: - def test_the_game_is_snapshotted_not_referenced(self, celebrating): - """A win must survive the game leaving live_games.""" - manager = celebrating() - live = game("g1", home_score=0) - manager._check_for_score(live) - live = game("g1", home_score=1) - manager._check_for_score(live) - live["home_abbr"] = "MUTATED" - assert manager.active_celebration["game"]["home_abbr"] == "HOM" - - def test_focus_is_pinned_to_the_involved_game(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - assert manager.current_game["id"] == "g1" - - -class TestDisplayTakeover: - def test_no_celebration_defers_to_the_scorebug(self, celebrating): - manager = celebrating() - assert manager.display(force_clear=True) is True - assert manager.display_calls == [True] - - def test_active_celebration_takes_over(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager._draw_celebration_layout = MagicMock() - assert manager.display() is True - assert manager.display_calls == [], "the scorebug must not also render" - manager._draw_celebration_layout.assert_called_once() - - def test_expired_celebration_clears_and_defers(self, celebrating): - # A short-but-valid duration: celebration_duration is clamped to a 1.0s - # floor, so a config of 0 does NOT expire on the next frame. Backdate - # started_at past the window to exercise the real expiry branch — - # otherwise the celebration is still active and this only passes - # because _draw_celebration_layout happens to raise in the harness - # (that path is covered by test_a_render_failure_falls_through). - manager = celebrating(mode_config={"celebration_duration": 1}) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window - # Mock the layout so a render can't raise: otherwise the exception - # branch clears the celebration too, and this test would pass whether - # or not expiry actually fired. An expired celebration must NOT render. - manager._draw_celebration_layout = MagicMock() - assert manager.display() is True - manager._draw_celebration_layout.assert_not_called() - assert manager.active_celebration is None - assert manager.display_calls == [False] - - def test_expiry_resets_the_dwell_clock(self, celebrating): - """So the scorebug resumes on the scoring game for a full duration - before rotation can move on.""" - manager = celebrating(mode_config={"celebration_duration": 1}) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window - manager._draw_celebration_layout = MagicMock() # expiry, not a render failure - manager.display() - manager._draw_celebration_layout.assert_not_called() - assert manager.last_game_switch > 0 - - def test_a_render_failure_falls_through_to_the_scorebug(self, celebrating): - """A broken celebration must never blank the display.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom")) - assert manager.display() is True - assert manager.display_calls == [False] - - def test_a_render_failure_disarms_rather_than_retrying(self, celebrating): - """Left armed, the same render fails on every frame for the rest of the - window — a traceback per frame, and no scorebug.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom")) - manager.display() - assert manager.active_celebration is None - manager.display() - assert manager._draw_celebration_layout.call_count == 1 - - -class TestBaselinePruning: - """`_score_baselines` gains an entry per game and only _check_for_win ever - removed one, so a board running all season grows the dict without bound.""" - - def test_prunes_games_no_longer_live(self, celebrating): - manager = celebrating() - for gid in ("g1", "g2", "g3"): - manager._check_for_score(game(gid, home_score=1)) - manager.prune_score_baselines([game("g2")]) - assert set(manager._score_baselines) == {"g2"} - - def test_keeps_every_still_live_game(self, celebrating): - manager = celebrating() - for gid in ("g1", "g2"): - manager._check_for_score(game(gid, home_score=1)) - manager.prune_score_baselines([game("g1"), game("g2")]) - assert set(manager._score_baselines) == {"g1", "g2"} - - def test_empty_live_set_clears_everything(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=1)) - manager.prune_score_baselines([]) - assert manager._score_baselines == {} - - def test_pruning_does_not_disturb_a_surviving_baseline(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=2)) - manager.prune_score_baselines([game("g1")]) - manager._check_for_score(game("g1", home_score=3)) - assert manager.active_celebration is not None, ( - "pruning must not drop a live game's baseline and re-trigger the " - "first-sighting suppression") - - def test_disabled_manager_renders_nothing(self, celebrating): - manager = celebrating() - manager.is_enabled = False - assert manager.display() is False - - -class TestFitFont: - def test_returns_the_first_font_that_fits(self, celebrating): - manager = celebrating() - draw = MagicMock() - draw.textlength.side_effect = [100, 20] - big, small = MagicMock(), MagicMock() - assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small - - def test_falls_back_to_the_smallest(self, celebrating): - manager = celebrating() - draw = MagicMock() - draw.textlength.return_value = 999 - big, small = MagicMock(), MagicMock() - assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small - - -class TestCapabilityExports: - @pytest.mark.parametrize("name", [ - "CelebrationMixin", "RotationStrategy", "SimpleRotation", - "SmoothWeightedRotation", "WeightedCycleRotation", - "get_rotation_strategy", "register_rotation_strategy", - ]) - def test_public_name_is_importable(self, name): - """Plugins import these behind a guarded fallback; the names are the - contract.""" - import src.base_classes.sports.capabilities as capabilities - assert hasattr(capabilities, name) - - def test_rotation_strategy_base_requires_a_schedule(self): - with pytest.raises(NotImplementedError): - RotationStrategy().schedule([game("a")]) - - -# --------------------------------------------------------------------------- -# Celebrations: rendering + previously untested edges -# --------------------------------------------------------------------------- - -from PIL import Image, ImageDraw, ImageFont # noqa: E402 - - -class _RenderableLive(_FakeLive): - """A _FakeLive that can actually execute _draw_celebration_layout: - real fonts, a display manager holding a real PIL image, and the two - SportsCore drawing seams the mixin calls.""" - - def __init__(self, mode_config=None, favorite_teams=None, - width=128, height=32, with_matrix=True): - super().__init__(mode_config=mode_config, favorite_teams=favorite_teams) - font = ImageFont.load_default() - self.fonts = {"time": font, "status": font, "score": font} - self.display_width = width - self.display_height = height - dm = MagicMock() - if with_matrix: - dm.matrix.width = width - dm.matrix.height = height - else: - dm.matrix = None - dm.image = Image.new("RGB", (width, height)) - self.display_manager = dm - self.logo_calls = [] - - def _load_and_resize_logo(self, team_id, abbr, path, url): - self.logo_calls.append(abbr) - logo = Image.new("RGBA", (10, 10), (0, 200, 0, 255)) - return logo - - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255)): - draw.text(position, str(text), font=font, fill=fill) - - -class _RenderableCelebrating(CelebrationMixin, _RenderableLive): - pass - - -def _armed(manager, *, kind="score", side="home", started_ago=0.0): - manager._start_celebration( - game("g1", home_score=7, away_score=3), kind, - scored_side=side, team_abbr="HOM", away_score=3, home_score=7, - points=7, - ) - manager.active_celebration["started_at"] = time.time() - started_ago - return manager.active_celebration - - -class TestDrawCelebrationLayout: - """The takeover render path, executed for real (previously always - mocked out).""" - - def test_renders_and_hands_frame_to_display_manager(self): - manager = _RenderableCelebrating() - celebration = _armed(manager) - manager._draw_celebration_layout(celebration) - # The final frame was assigned and pushed. - assert isinstance(manager.display_manager.image, Image.Image) - assert manager.display_manager.image.mode == "RGB" - assert manager.display_manager.image.size == (128, 32) - manager.display_manager.update_display.assert_called_once() - assert manager.display_manager.image.convert("L").getbbox() is not None - - def test_force_clear_clears_display_first(self): - manager = _RenderableCelebrating() - celebration = _armed(manager) - manager._draw_celebration_layout(celebration, force_clear=True) - manager.display_manager.clear.assert_called_once() - - def test_flash_background_within_first_window(self): - # elapsed < 1.2 with int(elapsed/0.2) even -> flash color backdrop. - manager = _RenderableCelebrating() - celebration = _armed(manager, started_ago=0.05) - manager._draw_celebration_layout(celebration) - flash = manager.display_manager.image - # After the flash window: plain black backdrop. - celebration["started_at"] = time.time() - 5 - manager._draw_celebration_layout(celebration) - steady = manager.display_manager.image - # Corner pixels (away from logos/text) show the two backgrounds. - assert flash.getpixel((64, 30)) != steady.getpixel((64, 30)) or \ - flash.getpixel((3, 0)) != steady.getpixel((3, 0)) - - def test_matrix_dims_fallback_to_display_attrs(self): - manager = _RenderableCelebrating(width=96, height=48, with_matrix=False) - celebration = _armed(manager) - manager._draw_celebration_layout(celebration) - assert manager.display_manager.image.size == (96, 48) - - def test_highlight_color_alternates_with_elapsed(self): - manager = _RenderableCelebrating() - celebration = _armed(manager) - # int(elapsed*4) % 2 == 0 -> yellow; == 1 -> orange. Force each phase - # and diff the frames. - celebration["started_at"] = time.time() - 2.0 # 8 -> even - manager._draw_celebration_layout(celebration) - even = manager.display_manager.image.tobytes() - celebration["started_at"] = time.time() - 2.25 # 9 -> odd - manager._draw_celebration_layout(celebration) - odd = manager.display_manager.image.tobytes() - assert even != odd - - def test_logo_failure_still_renders_text(self): - manager = _RenderableCelebrating() - - def boom(*a, **k): - raise RuntimeError("disk gone") - - manager._load_and_resize_logo = boom - celebration = _armed(manager, started_ago=5) # steady background - manager._draw_celebration_layout(celebration) # must not raise - assert manager.display_manager.image.convert("L").getbbox() is not None - manager.display_manager.update_display.assert_called_once() - - -class TestCelebrationEdges: - def test_should_celebrate_for_three_way_branch(self, celebrating): - g = game("g1", home="FAV", away="OPP") - favored = celebrating(favorites=["FAV"]) - assert favored._should_celebrate_for(g, "home") is True # favorite - assert favored._should_celebrate_for(g, "away") is False # opponent - favored.celebrate_opponent_scores = True - assert favored._should_celebrate_for(g, "away") is True # opted in - unconfigured = celebrating(favorites=[]) - assert unconfigured._should_celebrate_for(g, "away") is True # no favs - - def test_active_celebration_boundary_is_strict(self, celebrating): - manager = celebrating(mode_config={"celebration_duration": 3}) - manager.active_celebration = {"started_at": time.time() - 3.0} - # elapsed == duration -> strictly-less-than comparison says done. - assert manager.has_active_celebration() is False - manager.active_celebration = None - assert manager.has_active_celebration() is False - - @pytest.mark.parametrize("value,expected", [ - ({"value": None}, None), # int(float(None)) TypeError -> caught - ({"value": "abc"}, None), - ({"other": 1}, 0), # neither key -> default 0 - ([3], None), # list -> TypeError -> caught - ("-4", None), # regex fallback finds digits -> 4? No: - ]) - def test_score_to_int_edges(self, value, expected): - result = CelebrationMixin._score_to_int(value) - if value == "-4": - # int(float("-4")) parses directly: -4. - assert result == -4 - else: - assert result == expected - - def test_both_teams_scoring_prefers_away(self, celebrating): - manager = celebrating(favorites=[]) - manager._check_for_score(game("g1", home_score=0, away_score=0)) - manager._check_for_score(game("g1", home_score=7, away_score=3)) - assert manager.active_celebration["scored_side"] == "away" - - def test_away_not_celebratable_falls_through_to_home(self, celebrating): - manager = celebrating(favorites=["HOM"]) # away is the opponent - manager._check_for_score(game("g1", home_score=0, away_score=0)) - manager._check_for_score(game("g1", home_score=7, away_score=3)) - assert manager.active_celebration["scored_side"] == "home" - - def test_coalesce_expired_celebration_fires_fresh(self, celebrating): - manager = celebrating(cls=_Coalescing, - mode_config={"celebration_duration": 1}) - manager._check_for_score(game("g1")) - manager._check_for_score(game("g1", home_score=6)) - first = manager.active_celebration - assert first is not None - first["started_at"] = time.time() - 2 # expired - manager._check_for_score(game("g1", home_score=7)) - # A new celebration replaced the expired one (coalescing only - # suppresses while one is actively on screen). - assert manager.active_celebration is not first - assert manager.active_celebration["home_score"] == 7 - - def test_disabled_win_check_preserves_baseline(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1")) - assert "g1" in manager._score_baselines - manager.celebration_enabled = False - manager._check_for_win(game("g1", home_score=7)) - # Early return BEFORE consuming the baseline: re-enabling later can - # still fire for this game. - assert "g1" in manager._score_baselines - - def test_prune_drops_baselines_for_idless_live_games(self, celebrating): - manager = celebrating() - manager._score_baselines = {"g1": {"away": 0, "home": 0}} - manager.prune_score_baselines([{"no_id_here": True}]) - # live ids collapse to {None}; g1 is not live -> dropped. - assert manager._score_baselines == {} diff --git a/test/test_sports_core_promotions.py b/test/test_sports_core_promotions.py deleted file mode 100644 index eb905002..00000000 --- a/test/test_sports_core_promotions.py +++ /dev/null @@ -1,613 +0,0 @@ -"""Tests for the methods promoted onto SportsCore from the nine bundled -plugin copies of ``sports.py`` (phase B1 of docs/SPORTS_UNIFICATION.md). - -Three methods and their seams land here: - -- ``cleanup()`` — byte-identical in all nine copies. The tests pin the - ordering (session close, then caches, then the completion log) and the - deliberate *omission*: the process-wide background service must never be - shut down by one unloading plugin. -- ``_get_layout_offset()`` — football's resolver-backed variant, with the - classic inline config read as the fallback used by every plugin that - doesn't hand core a ``_config_schema_path()``. -- ``_load_custom_font_from_element_config()`` — baseball's body (the only - copy that handles BDF strikes correctly) under hockey's wider signature, - resolving font files through the ``_font_root()`` seam instead of the - process cwd. -""" - -import ast -import json -import logging -import os -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from PIL import Image, ImageFont - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so these tests can import the sports base classes off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.sports import SportsCore - -LOGGER = logging.getLogger("test_sports_core_promotions") - -CORE_ROOT = Path(__file__).resolve().parents[1] -FONTS_DIR = CORE_ROOT / "assets" / "fonts" -TTF_NAME = "PressStart2P-Regular.ttf" -BDF_NAME = "5x7.bdf" # a BDF whose only valid strike is 7px -BDF_NATIVE_SIZE = 7 - - -class _StubSports(SportsCore): - """Minimal concrete SportsCore — the abstract methods are never called - by anything under test here.""" - - def _fetch_data(self): - return None - - def _extract_game_details(self, game_event): - return None - - -@pytest.fixture -def build(monkeypatch, tmp_path): - """Factory for real SportsCore instances: logo dir redirected to tmp and - the process-wide background service replaced with a MagicMock so the - tests can assert nothing ever calls it.""" - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - - def _build(config=None, cls=_StubSports): - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - cache_manager = MagicMock() - cache_manager.cache_dir = str(tmp_path) - return cls(config if config is not None else {"timezone": "UTC"}, - display_manager, cache_manager, LOGGER, "nhl") - - return _build - - -def probe(config=None): - """Unbound-call stand-in for hosts we don't need a full instance for - (same pattern as make_probe in test_sports_base_characterization).""" - host = MagicMock() - host.logger = LOGGER - host.config = config if config is not None else {} - host._font_cache = {} - host._bdf_native_size_cache = {} - host._config_schema_path.return_value = None - host._font_root.side_effect = lambda: SportsCore._font_root(host) - host._resolve_font_path.side_effect = ( - lambda name: SportsCore._resolve_font_path(host, name)) - return host - - -def offset(host, element, axis, default=0): - return SportsCore._get_layout_offset(host, element, axis, default) - - -def load_font(host, *args, **kwargs): - return SportsCore._load_custom_font_from_element_config(host, *args, **kwargs) - - -# --------------------------------------------------------------------------- -# 1. cleanup() -# --------------------------------------------------------------------------- - -class TestCleanup: - def test_closes_session_and_clears_all_caches(self, build): - manager = build() - session = MagicMock() - manager.session = session - manager._logo_cache["TB"] = object() - manager._font_cache[("PressStart2P-Regular.ttf", 8)] = object() - manager._bdf_native_size_cache["assets/fonts/5x7.bdf"] = 7 - - manager.cleanup() - - session.close.assert_called_once_with() - assert manager._logo_cache == {} - # Promoted alongside the font loader: these hold PIL faces and are - # an unbounded leak across enable/disable cycles if never released. - assert manager._font_cache == {} - assert manager._bdf_native_size_cache == {} - - def test_second_cleanup_is_a_noop(self, build): - manager = build() - manager.session = MagicMock() - manager._logo_cache["TB"] = object() - manager._font_cache[("x", 8)] = object() - - manager.cleanup() - manager.cleanup() # must not raise on already-released state - - assert manager._logo_cache == {} - assert manager._font_cache == {} - assert manager._bdf_native_size_cache == {} - assert manager.session.close.call_count == 2 - - def test_does_not_shut_down_the_shared_background_service(self, build): - # get_background_service() hands out a PROCESS-WIDE singleton shared - # by every scoreboard. One plugin unloading must not stop background - # fetching for the other eight — cleanup() touches it not at all. - manager = build() - service = manager.background_service - manager.session = MagicMock() - - manager.cleanup() - - assert service.shutdown.called is False - assert service.stop.called is False - assert service.method_calls == [], ( - "cleanup() called into the shared background service: " - f"{service.method_calls}") - - def test_completion_is_logged_even_when_session_close_raises(self, build, caplog): - manager = build() - manager.session = MagicMock() - manager.session.close.side_effect = RuntimeError("socket already gone") - manager._logo_cache["TB"] = object() - - with caplog.at_level(logging.DEBUG, logger=LOGGER.name): - manager.cleanup() - - messages = [r.message for r in caplog.records] - assert any("Error closing session" in m for m in messages) - # Ordering is load-bearing: the caches still get cleared and the - # completion log still fires after a failed close. - assert manager._logo_cache == {} - assert any("cleanup completed" in m for m in messages) - - def test_tolerates_missing_attributes(self): - # The hasattr guards exist so a partially constructed instance (an - # __init__ that raised) can still be cleaned up. - host = MagicMock(spec=["logger"]) - host.logger = LOGGER - SportsCore.cleanup(host) - - -# --------------------------------------------------------------------------- -# 2. _get_layout_offset() + the _config_schema_path() seam -# --------------------------------------------------------------------------- - -def layout_config(element, axis, value): - return {"customization": {"layout": {element: {axis: value}}}} - - -class TestLayoutOffsetClassicPath: - """The default path: _config_schema_path() returns None, so offsets come - from the inline customization.layout read every plugin ships today.""" - - def test_config_schema_path_defaults_to_none(self, build): - manager = build() - assert manager._config_schema_path() is None - - def test_reads_configured_int(self): - host = probe(layout_config("home_logo", "x_offset", 5)) - assert offset(host, "home_logo", "x_offset") == 5 - - def test_float_is_truncated_to_int(self): - host = probe(layout_config("score", "y_offset", 2.9)) - result = offset(host, "score", "y_offset") - assert result == 2 and isinstance(result, int) - - def test_numeric_string_is_coerced(self): - host = probe(layout_config("score", "x_offset", "-3.5")) - assert offset(host, "score", "x_offset") == -3 - - def test_unconfigured_element_and_axis_use_default(self): - host = probe(layout_config("score", "x_offset", 5)) - assert offset(host, "status_text", "x_offset", 7) == 7 - assert offset(host, "score", "y_offset", -1) == -1 - assert offset(probe(), "score", "x_offset", 4) == 4 - - def test_non_numeric_string_degrades_to_default(self): - host = probe(layout_config("score", "x_offset", "left")) - assert offset(host, "score", "x_offset", 3) == 3 - - def test_unsupported_type_degrades_to_default(self): - host = probe(layout_config("score", "x_offset", {"nested": 1})) - assert offset(host, "score", "x_offset", 2) == 2 - host = probe(layout_config("score", "x_offset", None)) - assert offset(host, "score", "x_offset", 2) == 2 - - def test_broken_config_object_degrades_to_default(self): - host = probe() - host.config = "not a dict" - assert offset(host, "score", "x_offset", 6) == 6 - - def test_boolean_counts_as_one(self): - # PINNED AS-IS: the classic read predates the resolver and treats a - # bool as its int value (True -> 1). See the resolver test below for - # the stricter, more correct handling. - host = probe(layout_config("score", "x_offset", True)) - assert offset(host, "score", "x_offset", 4) == 1 - - -class TestLayoutOffsetResolverPath: - """When a plugin supplies its config_schema.json, offsets resolve through - src.element_style instead.""" - - @pytest.fixture - def schema_path(self, tmp_path): - path = tmp_path / "config_schema.json" - path.write_text(json.dumps({ - "type": "object", - "properties": { - "customization": { - "type": "object", - "properties": { - "layout": {"type": "object", "properties": {}}, - }, - }, - }, - })) - return str(path) - - def host(self, schema_path, config): - host = probe(config) - host._config_schema_path.return_value = schema_path - del host._style_resolver_cached # MagicMock auto-attrs otherwise - host._style_resolver_cached = None - return host - - def test_reads_configured_offsets(self, schema_path): - host = self.host(schema_path, layout_config("home_logo", "x_offset", 5)) - assert offset(host, "home_logo", "x_offset") == 5 - - def test_numeric_string_is_coerced(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", "-3.5")) - assert offset(host, "score", "x_offset") == -3 - - def test_missing_value_uses_default(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", 5)) - assert offset(host, "score", "y_offset", 9) == 9 - - def test_bad_input_degrades_to_default(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", "left")) - assert offset(host, "score", "x_offset", 3) == 3 - host = self.host(schema_path, {"customization": {"layout": "nope"}}) - assert offset(host, "score", "x_offset", 3) == 3 - - def test_boolean_is_rejected_unlike_the_classic_path(self, schema_path): - # The intended behavior difference: a bool is not a pixel offset, so - # the resolver returns the default where the classic read returns 1. - host = self.host(schema_path, layout_config("score", "x_offset", True)) - assert offset(host, "score", "x_offset", 4) == 4 - - def test_resolver_is_cached_and_rebuilt_when_config_is_swapped(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", 5)) - assert offset(host, "score", "x_offset") == 5 - first = host._style_resolver_cached - assert offset(host, "score", "x_offset") == 5 - assert host._style_resolver_cached is first - - # on_config_change swaps the dict object; the resolver must follow. - host.config = layout_config("score", "x_offset", 11) - assert offset(host, "score", "x_offset") == 11 - assert host._style_resolver_cached is not first - - def test_missing_schema_file_still_resolves_offsets(self, tmp_path): - # Offsets don't depend on schema defaults, so an unreadable schema - # must not cost the plugin its layout customization. - host = self.host(str(tmp_path / "absent.json"), - layout_config("score", "x_offset", 5)) - assert offset(host, "score", "x_offset") == 5 - - -# --------------------------------------------------------------------------- -# 3. _load_custom_font_from_element_config() + the _font_root() seam -# --------------------------------------------------------------------------- - -class TestFontRootSeam: - def test_default_font_root_is_the_core_install_root(self, build): - manager = build() - assert Path(manager._font_root()) == CORE_ROOT - assert (Path(manager._font_root()) / "assets" / "fonts").is_dir() - - def test_resolve_font_path_honors_an_overridden_root(self, tmp_path): - fonts = tmp_path / "assets" / "fonts" - fonts.mkdir(parents=True) - (fonts / "Bundled.ttf").write_bytes(b"not really a font") - host = probe() - host._font_root.side_effect = lambda: str(tmp_path) - assert SportsCore._resolve_font_path(host, "Bundled.ttf") == str( - fonts / "Bundled.ttf") - - def test_unknown_font_returns_the_familiar_relative_path(self): - host = probe() - assert SportsCore._resolve_font_path(host, "Nope.ttf") == os.path.join( - "assets", "fonts", "Nope.ttf") - - -class TestFontLoaderSignature: - """Hockey's signature is the only safe superset: basketball's positional - ``default_font: str`` blows up on an explicit None.""" - - def test_two_arg_call(self): - font = load_font(probe(), {"font": TTF_NAME, "font_size": 10}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.size == 10 - - def test_default_size_is_used_when_config_omits_it(self): - assert load_font(probe(), {}, 12).size == 12 - - def test_three_positional_args(self): - font = load_font(probe(), {}, 6, "4x6-font.ttf") - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.size == 6 - assert font.path.endswith("4x6-font.ttf") - - def test_explicit_default_font_none(self): - # The regression this signature guards: os.path.join(..., None). - font = load_font(probe(), {"font_size": 9}, default_font=None) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.path.endswith(TTF_NAME) - - def test_config_font_wins_over_default_font(self): - font = load_font(probe(), {"font": TTF_NAME}, 8, "4x6-font.ttf") - assert font.path.endswith(TTF_NAME) - - def test_string_font_size_is_coerced(self): - assert load_font(probe(), {"font": TTF_NAME, "font_size": "11"}).size == 11 - - -class TestFontLoaderBehavior: - def test_family_alias_resolves_through_the_font_manager_catalog(self): - # "press_start" is a FontManager catalog family, not a filename; the - # promoted loader must not carry its own duplicate alias table. - host = probe() - font = load_font(host, {"font": "press_start", "font_size": 8}) - assert font.path.endswith(TTF_NAME) - assert ("PressStart2P-Regular.ttf", 8) in host._font_cache - - def test_memo_cache_returns_the_same_face(self): - host = probe() - first = load_font(host, {"font": TTF_NAME, "font_size": 8}) - second = load_font(host, {"font": TTF_NAME, "font_size": 8}) - assert first is second - assert len(host._font_cache) == 1 - # A different size is a different face. - assert load_font(host, {"font": TTF_NAME, "font_size": 9}) is not first - assert len(host._font_cache) == 2 - - def test_bdf_loads_at_its_native_strike_when_the_request_misses(self): - # BDF is a fixed-size bitmap format: FreeType raises "invalid pixel - # size" for anything but the file's own strike. Baseball's retry is - # the only copy that gets this right. - host = probe() - font = load_font(host, {"font": BDF_NAME, "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.size == BDF_NATIVE_SIZE - assert font.path.endswith(BDF_NAME) - assert set(host._bdf_native_size_cache.values()) == {BDF_NATIVE_SIZE} - # The retried face is memoized under the REQUESTED size. - assert host._font_cache[(BDF_NAME, 8)] is font - - def test_bdf_at_its_native_size_needs_no_retry(self): - host = probe() - font = load_font(host, {"font": BDF_NAME, "font_size": BDF_NATIVE_SIZE}) - assert font.size == BDF_NATIVE_SIZE - assert host._bdf_native_size_cache == {} - - def test_bdf_strike_lookup_is_memoized(self, monkeypatch): - calls = [] - real = SportsCore.__module__ - - def counting(path): - calls.append(path) - from src.font_manager import FontManager - return FontManager._read_bdf_native_size(path) - - monkeypatch.setattr(f"{real}._read_bdf_native_size", counting) - host = probe() - load_font(host, {"font": BDF_NAME, "font_size": 8}) - host._font_cache.clear() # force the load path again - load_font(host, {"font": BDF_NAME, "font_size": 8}) - assert len(calls) == 1 - - def test_missing_font_falls_back_and_caches_the_fallback(self, caplog): - host = probe() - with caplog.at_level(logging.WARNING, logger=LOGGER.name): - font = load_font(host, {"font": "DoesNotExist.ttf", "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.path.endswith(TTF_NAME) - assert any("Font file not found" in r.message for r in caplog.records) - # Cached under the requested name so a misconfiguration costs one - # disk probe, not one per frame. - assert host._font_cache[("DoesNotExist.ttf", 8)] is font - - def test_unknown_extension_falls_back(self): - host = probe() - font = load_font(host, {"font": "AUTHORS", "font_size": 8}) - assert font.path.endswith(TTF_NAME) - - def test_fallback_honors_the_supplied_default_font(self): - host = probe() - font = load_font(host, {"font": "DoesNotExist.ttf"}, 6, "4x6-font.ttf") - assert font.path.endswith("4x6-font.ttf") - - -class TestFontLoaderCwdIndependence: - """The bug the _font_root() seam exists to prevent: every plugin copy - joins 'assets/fonts' onto the process cwd, so a process started anywhere - else silently degrades to PIL's default bitmap face (the same defect - already fixed in FontManager — see CHANGELOG Unreleased/Fixed).""" - - @pytest.mark.parametrize("font_name,expected_size", - [(TTF_NAME, 8), (BDF_NAME, BDF_NATIVE_SIZE)]) - def test_fonts_load_from_an_unrelated_cwd(self, monkeypatch, font_name, - expected_size): - monkeypatch.chdir("/") - host = probe() - font = load_font(host, {"font": font_name, "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont), ( - f"{font_name} degraded to PIL's default face when the process " - "runs outside the install root") - assert font.size == expected_size - assert Path(font.path) == FONTS_DIR / font_name - - def test_fallback_font_also_survives_an_unrelated_cwd(self, monkeypatch): - monkeypatch.chdir("/") - font = load_font(probe(), {"font": "DoesNotExist.ttf", "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert Path(font.path) == FONTS_DIR / TTF_NAME - - @pytest.mark.parametrize("key", ["score", "time", "team", "status", - "detail", "rank"]) - def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key): - """`_load_fonts` had the same cwd-relative literals the seam exists to - remove; every scoreboard font silently became PIL's default bitmap face - when the process started outside the install root.""" - monkeypatch.chdir("/") - fonts = SportsCore._load_fonts(probe()) - assert isinstance(fonts[key], ImageFont.FreeTypeFont), ( - f"fonts['{key}'] degraded to PIL's default face outside the " - "install root") - - -class TestShouldLogCooldown: - """`_should_log` reads `self._last_warning_time` unguarded, so it must be - initialized in __init__ — otherwise the first warning of a run raises - AttributeError instead of logging.""" - - def test_cooldown_clock_is_initialized(self, build): - assert build()._last_warning_time == 0 - - def test_first_call_logs_then_cools_down(self, build): - manager = build() - assert manager._should_log("api", cooldown=60) is True - assert manager._should_log("api", cooldown=60) is False - - def test_cooldown_expires(self, build): - manager = build() - assert manager._should_log("api", cooldown=60) is True - manager._warning_cooldowns["api"] -= 61 - assert manager._should_log("api", cooldown=60) is True - - def test_cooldowns_are_tracked_per_warning_type(self, build): - """The parameter was accepted and ignored: one shared timestamp meant an - API warning silenced an unrelated cache warning for the next minute.""" - manager = build() - assert manager._should_log("api", cooldown=60) is True - assert manager._should_log("cache", cooldown=60) is True - assert manager._should_log("api", cooldown=60) is False - assert manager._should_log("cache", cooldown=60) is False - - def test_one_type_expiring_does_not_free_another(self, build): - manager = build() - manager._should_log("api") - manager._should_log("cache") - manager._warning_cooldowns["api"] -= 61 - assert manager._should_log("api") is True - assert manager._should_log("cache") is False - - def test_legacy_single_clock_field_is_kept_in_step(self, build): - """Subclasses in the plugin copies read _last_warning_time directly.""" - manager = build() - manager._should_log("api") - assert manager._last_warning_time == manager._warning_cooldowns["api"] - - -# --------------------------------------------------------------------------- -# 4. Seam guard rails -# --------------------------------------------------------------------------- - -class TestPromotedSeamsExist: - @pytest.mark.parametrize("name", [ - "cleanup", "_get_layout_offset", "_load_custom_font_from_element_config", - "_config_schema_path", "_font_root", "_resolve_font_path", - ]) - def test_method_is_callable_on_the_base_class(self, name): - assert callable(getattr(SportsCore, name, None)), ( - f"SportsCore.{name} is part of the promoted plugin-facing seam " - "(docs/SPORTS_UNIFICATION.md) — plugins probe for it with " - "hasattr before delegating.") - - def test_no_sport_names_leaked_into_core(self): - """core.py must never branch on which sport it is (prose and skin-id - examples in docstrings are fine — executable code is not).""" - tree = ast.parse((CORE_ROOT / "src" / "base_classes" / "sports" - / "core.py").read_text()) - docstrings = set() - for node in ast.walk(tree): - if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, - ast.AsyncFunctionDef)): - first = node.body[0] if node.body else None - if (isinstance(first, ast.Expr) - and isinstance(first.value, ast.Constant) - and isinstance(first.value.value, str)): - docstrings.add(id(first.value)) - - tokens = [] - for node in ast.walk(tree): - if isinstance(node, ast.Constant) and isinstance(node.value, str): - if id(node) not in docstrings: - tokens.append(node.value) - elif isinstance(node, ast.Name): - tokens.append(node.id) - elif isinstance(node, ast.Attribute): - tokens.append(node.attr) - elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, - ast.ClassDef)): - tokens.append(node.name) - - haystack = " ".join(tokens).lower() - for sport in ("afl", "nrl", "hockey", "baseball", "basketball", - "football", "lacrosse", "soccer", "ufc"): - assert sport not in haystack, ( - f"core.py code mentions '{sport}' — core must never learn " - "sport names; add an override point instead.") - - -class TestInstallRootResolution: - """Guards the depth bug the sports.py -> package move introduced. - - The move was byte-identical in every class body, but `__file__` gained a - directory, so `Path(__file__).resolve().parents[2]` silently changed from - the repo root to `/src`. Textual identity is not semantic identity - when code measures its own location: these tests assert the resolved - values, not the index. - """ - - def test_install_root_is_the_repo_root(self): - from src.base_classes.sports.core import _INSTALL_ROOT - - # The repo root is the directory that actually holds src/ and assets/. - assert (_INSTALL_ROOT / "src").is_dir() - assert (_INSTALL_ROOT / "src" / "base_classes" / "sports").is_dir() - assert _INSTALL_ROOT.name != "src", ( - "_INSTALL_ROOT resolved to src/ — the parents[] depth is off by " - "one, which is exactly the regression the package move caused.") - - def test_resolve_project_path_roots_at_repo_not_src(self): - from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT - - resolved = SportsCore._resolve_project_path(None, Path("assets/fonts")) - assert resolved == _INSTALL_ROOT / "assets" / "fonts" - assert "src" not in resolved.relative_to(_INSTALL_ROOT).parts - - def test_absolute_paths_pass_through_unchanged(self): - from src.base_classes.sports.core import SportsCore - - absolute = Path("/tmp/some/logo/dir") - assert SportsCore._resolve_project_path(None, absolute) == absolute - - def test_font_root_and_project_path_share_one_anchor(self): - """Both consumers must derive from the same constant, so a future - move needs exactly one line changed rather than two.""" - from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT - - assert SportsCore._font_root(None) == str(_INSTALL_ROOT) diff --git a/test/test_sports_logo_cache_bounded.py b/test/test_sports_logo_cache_bounded.py deleted file mode 100644 index 8570b81e..00000000 --- a/test/test_sports_logo_cache_bounded.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tests that SportsCore's decoded-logo cache is LRU-bounded. - -``self._logo_cache`` was a plain dict keyed by team abbreviation, with no -eviction. The entries are decoded RGBA thumbnails sized to display*1.5 -- about -36KB on a 256x64 panel, more for wide wordmarks -- and -assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league -therefore held the whole league in memory: roughly 11-18MB per manager -instance, and a league runs three of them (live/recent/upcoming) each with its -own cache. On a 1GB Pi 3B+ with ~290MB available that is worth recovering. - -The bound has to be LRU rather than "clear when full": the logos on screen -right now are exactly the ones that must not be thrown away. -""" - -import logging -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from PIL import Image - -# src.base_classes.sports transitively imports the hardware matrix driver. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.sports import SportsCore # noqa: E402 - -LOGGER = logging.getLogger("test_sports_logo_cache_bounded") - - -class _StubSports(SportsCore): - def _fetch_data(self): - return None - - def _extract_game_details(self, game_event): - return None - - -@pytest.fixture -def host(monkeypatch, tmp_path): - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - # Never reach for the network: every logo these tests ask for exists. - monkeypatch.setattr( - "src.base_classes.sports.core.download_missing_logo", - lambda *a, **k: None) - - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - cache_manager = MagicMock() - cache_manager.cache_dir = str(tmp_path) - instance = _StubSports({"timezone": "UTC"}, display_manager, - cache_manager, LOGGER, "nhl") - instance._logo_dir = tmp_path - return instance, tmp_path - - -def _load(host, abbrev): - """Create a real logo file for `abbrev` and load it through the cache.""" - instance, tmp_path = host - path = tmp_path / f"{abbrev}.png" - if not path.exists(): - Image.new("RGBA", (64, 64), (1, 2, 3, 255)).save(path) - return instance._load_and_resize_logo(abbrev, abbrev, path, None) - - -class TestTheCacheIsBounded: - def test_it_never_exceeds_the_limit(self, host): - instance, _ = host - limit = instance._LOGO_CACHE_MAX - for i in range(limit + 40): - assert _load(host, f"T{i}") is not None - # The regression: this grew to limit + 40, and for NCAA to 307. - assert len(instance._logo_cache) == limit - - def test_the_limit_is_smaller_than_a_real_league(self, host): - """ncaa_logos ships 307 files; the cap has to be well under that.""" - instance, _ = host - assert instance._LOGO_CACHE_MAX < 307 - - -class TestEvictionIsLRUNotArbitrary: - def test_the_least_recently_used_goes_first(self, host): - instance, _ = host - limit = instance._LOGO_CACHE_MAX - for i in range(limit): - _load(host, f"T{i}") - _load(host, "NEW") - assert "T0" not in instance._logo_cache, "oldest should have been evicted" - assert "NEW" in instance._logo_cache - - def test_touching_an_entry_saves_it(self, host): - instance, _ = host - limit = instance._LOGO_CACHE_MAX - for i in range(limit): - _load(host, f"T{i}") - _load(host, "T0") # a cache hit -- T0 is on screen again - _load(host, "NEW") # forces one eviction - - assert "T0" in instance._logo_cache, "a logo in use was thrown away" - assert "T1" not in instance._logo_cache, "T1 was the true LRU entry" - - -class TestHitsStillAvoidDiskWork: - def test_a_second_request_returns_the_cached_object(self, host, monkeypatch): - instance, _ = host - first = _load(host, "TB") - - def _boom(*a, **k): - raise AssertionError("cache hit re-opened the file from disk") - - monkeypatch.setattr(Image, "open", _boom) - assert _load(host, "TB") is first diff --git a/test/test_sports_modes_promotions.py b/test/test_sports_modes_promotions.py deleted file mode 100644 index 72f6a211..00000000 --- a/test/test_sports_modes_promotions.py +++ /dev/null @@ -1,585 +0,0 @@ -"""Tests for the methods promoted onto SportsUpcoming / SportsRecent / -SportsLive from the nine plugin copies (phase B1 of the sports unification; -see docs/SPORTS_UNIFICATION.md). - -Covered promotions: -- SportsRecent: `_get_zero_clock_duration` / `_clear_zero_clock_tracking` - (+ the `_zero_clock_timestamps` initializer). -- SportsLive: `_is_game_really_over` / `_detect_stale_games` - (+ `game_update_timestamps` / `stale_game_timeout`, and the - `FINAL_PERIOD` / `CLOCK_COUNTS_DOWN` class attributes). -- SportsUpcoming: `_select_games_for_display`. -- SportsRecent: `_select_recent_games_for_display`. - -The live pair is the risk centre: `_detect_stale_games` is the only caller -that *removes* games, so `_is_game_really_over` returning a false positive -silently drops a live game from the display. The canonical form deliberately -declines to treat a missing clock as 0:00 — the plugin variant that did so -dropped clockless sports (baseball) from the FINAL_PERIOD-th period onward. -That regression is pinned by -`test_missing_clock_at_late_period_is_not_over`. -""" - -import logging -import sys -import time -from datetime import datetime, timezone -from unittest.mock import MagicMock - -import pytest -import requests -from freezegun import freeze_time -from PIL import Image - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so these tests can import the sports base classes off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.hockey import Hockey, HockeyLive -from src.base_classes.sports import ( - SportsCore, - SportsLive, - SportsRecent, - SportsUpcoming, -) - - -# --------------------------------------------------------------------------- -# Harnesses -# --------------------------------------------------------------------------- - -class _UpcomingHarness(Hockey, SportsUpcoming): - """Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data. - - `_favorite_key` is inherited from SportsCore — these harnesses - deliberately do NOT define it, so the selection tests exercise the real - seam rather than a local stand-in. - """ - - def _fetch_data(self): - return None - - -class _RecentHarness(Hockey, SportsRecent): - def _fetch_data(self): - return None - - -class _LiveHarness(HockeyLive): - def _fetch_data(self): - return None - - -class _ThreePeriodLiveHarness(_LiveHarness): - """Hockey-shaped: regulation ends after period 3.""" - - FINAL_PERIOD = 3 - - -class _CountUpLiveHarness(_LiveHarness): - """Soccer/AFL/NRL-shaped: the clock counts up, so 0:00 is kickoff.""" - - CLOCK_COUNTS_DOWN = False - - -class _IdFavoriteUpcomingHarness(_UpcomingHarness): - """NRL-shaped: abbreviations are ambiguous, so favorites match on team id.""" - - def _favorite_key(self, game, side): - team_id = game.get(f"{side}_id") - return str(team_id) if team_id is not None else None - - -class _IdFavoriteRecentHarness(_RecentHarness): - def _favorite_key(self, game, side): - team_id = game.get(f"{side}_id") - return str(team_id) if team_id is not None else None - - -@pytest.fixture -def build_manager(monkeypatch, tmp_path): - """Factory for concrete sports managers: mocked display/cache managers, - logo dir redirected to tmp, background service stubbed, and the requests - session rigged to prove nothing hits the network.""" - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - - def build(cls, **mode_cfg): - config = { - "timezone": "UTC", - "display": {}, - "nhl_scoreboard": {"enabled": True, **mode_cfg}, - } - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - cache_manager = MagicMock() - cache_manager.get.return_value = None - cache_manager.cache_dir = str(tmp_path) - manager = cls(config, display_manager, cache_manager, - logging.getLogger("test_sports_modes_promotions"), - "nhl") - manager.session = MagicMock() - manager.session.get.side_effect = requests.exceptions.ConnectionError( - "promotion tests are offline") - return manager - - return build - - -def game(game_id="1", home="BOS", away="TOR", start=None, home_id=None, - away_id=None, **extra): - g = { - "id": game_id, - "home_abbr": home, - "away_abbr": away, - "home_id": home_id, - "away_id": away_id, - "start_time_utc": start, - } - g.update(extra) - return g - - -def at(day, hour=12): - return datetime(2026, 1, day, hour, tzinfo=timezone.utc) - - -def _ids(games): - return [g["id"] for g in games] - - -# --------------------------------------------------------------------------- -# Tier 1 — zero-clock tracking (SportsRecent) -# --------------------------------------------------------------------------- - -class TestZeroClockTracking: - def test_initializer_present_and_empty(self, build_manager): - manager = build_manager(_RecentHarness) - assert manager._zero_clock_timestamps == {} - - def test_first_call_returns_zero_and_starts_tracking(self, build_manager): - manager = build_manager(_RecentHarness) - assert manager._get_zero_clock_duration("g1") == 0.0 - assert "g1" in manager._zero_clock_timestamps - - def test_subsequent_call_returns_elapsed_seconds(self, build_manager): - manager = build_manager(_RecentHarness) - with freeze_time("2026-01-20 12:00:00") as frozen: - assert manager._get_zero_clock_duration("g1") == 0.0 - frozen.tick(45) - assert manager._get_zero_clock_duration("g1") == pytest.approx(45.0) - frozen.tick(15) - assert manager._get_zero_clock_duration("g1") == pytest.approx(60.0) - - def test_tracking_is_per_game(self, build_manager): - manager = build_manager(_RecentHarness) - with freeze_time("2026-01-20 12:00:00") as frozen: - manager._get_zero_clock_duration("g1") - frozen.tick(30) - assert manager._get_zero_clock_duration("g2") == 0.0 - assert manager._get_zero_clock_duration("g1") == pytest.approx(30.0) - - def test_clear_resets_tracking(self, build_manager): - manager = build_manager(_RecentHarness) - with freeze_time("2026-01-20 12:00:00") as frozen: - manager._get_zero_clock_duration("g1") - frozen.tick(30) - manager._clear_zero_clock_tracking("g1") - assert "g1" not in manager._zero_clock_timestamps - # Restarts from zero after clearing. - assert manager._get_zero_clock_duration("g1") == 0.0 - - def test_clear_unknown_game_is_a_noop(self, build_manager): - manager = build_manager(_RecentHarness) - manager._clear_zero_clock_tracking("never-seen") # must not raise - assert manager._zero_clock_timestamps == {} - - -# --------------------------------------------------------------------------- -# Tier 2a — _is_game_really_over (SportsLive) -# --------------------------------------------------------------------------- - -class TestIsGameReallyOver: - def test_missing_clock_at_late_period_is_not_over(self, build_manager): - """THE baseball regression: no `clock` key at all, period 7. - - The rejected variant coerced a missing clock to the literal "0:00" and - declared the game over — dropping every MLB game from the 5th inning - onward, since baseball has no game clock and `period` is the inning. - A missing clock must fail safe. - """ - manager = build_manager(_LiveHarness) - g = game(period=7, period_text="Top 7th") - assert "clock" not in g - assert manager._is_game_really_over(g) is False - - def test_none_clock_at_late_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=None, period=7, period_text="Top 7th")) is False - - def test_non_string_clock_at_late_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=0, period=9, period_text="Bot 9th")) is False - - def test_blank_clock_at_late_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=" ", period=5, period_text="5th")) is False - - @pytest.mark.parametrize("period_text", ["Final", "final", "Final/OT", - "FINAL", "Final - SO"]) - def test_final_period_text_is_over(self, build_manager, period_text): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="12:00", period=2, period_text=period_text)) is True - - def test_none_period_text_does_not_raise(self, build_manager): - """All nine plugin copies called `.lower()` on `game.get("period_text", "")`, - which is None when the key is present-but-None; `_detect_stale_games` - has no try/except around the call.""" - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="12:00", period=2, period_text=None)) is False - - def test_missing_period_text_does_not_raise(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over(game(clock="12:00", period=2)) is False - - @pytest.mark.parametrize( - "clock", ["0:00", ":00", "00", "000", " 0:00 ", "00:00", "0", "0000"]) - def test_expired_clock_at_final_period_is_over(self, build_manager, clock): - """Every spelling of a zeroed clock counts, not a hand-listed few. - - "00:00" is the one that motivated comparing numerically: it normalizes - to "0000", which matched none of the literals the plugin copies listed, - so a two-digit-minute expired clock kept the game on screen forever. - """ - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=clock, period=4, period_text="Q4")) is True - - def test_none_period_at_expired_clock_does_not_raise(self, build_manager): - """`period` present-but-None: `None >= FINAL_PERIOD` is a TypeError, and - `_detect_stale_games` has no try/except — the same failure shape as the - `period_text` case above.""" - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=None, period_text="Q4")) is False - - def test_none_period_with_running_clock_does_not_raise(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="8:12", period=None, period_text="Q2")) is False - - def test_expired_clock_after_final_period_is_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=5, period_text="OT")) is True - - def test_expired_clock_before_final_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=3, period_text="Q3")) is False - - @pytest.mark.parametrize("clock", [":40", "0:40", "1:00"]) - def test_running_clock_is_not_over(self, build_manager, clock): - """Sub-minute clocks like ':40' are legitimate, not expired.""" - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=clock, period=4, period_text="Q4")) is False - - def test_defaults_are_four_period_countdown(self): - assert SportsLive.FINAL_PERIOD == 4 - assert SportsLive.CLOCK_COUNTS_DOWN is True - - def test_final_period_override_three(self, build_manager): - """Hockey-shaped subclass: regulation ends after period 3.""" - manager = build_manager(_ThreePeriodLiveHarness) - assert manager.FINAL_PERIOD == 3 - assert manager._is_game_really_over( - game(clock="0:00", period=3, period_text="P3")) is True - assert manager._is_game_really_over( - game(clock="0:00", period=2, period_text="P2")) is False - # And the unmodified default still requires period 4. - assert build_manager(_LiveHarness)._is_game_really_over( - game(clock="0:00", period=3, period_text="P3")) is False - - def test_count_up_clock_never_expires(self, build_manager): - """Soccer/AFL/NRL: 0:00 means kickoff, so the clock branch must not run.""" - manager = build_manager(_CountUpLiveHarness) - assert manager.CLOCK_COUNTS_DOWN is False - for period in (1, 2, 4, 9): - assert manager._is_game_really_over( - game(clock="0:00", period=period, period_text="1st Half")) is False - - def test_count_up_clock_still_honors_final_text(self, build_manager): - manager = build_manager(_CountUpLiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=2, period_text="Final")) is True - - -# --------------------------------------------------------------------------- -# Tier 2b — _detect_stale_games (SportsLive) -# --------------------------------------------------------------------------- - -class TestDetectStaleGames: - def test_initializer_defaults(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager.game_update_timestamps == {} - assert manager.stale_game_timeout == 300 - - def test_stale_timeout_is_configurable(self, build_manager): - manager = build_manager(_LiveHarness, stale_game_timeout=42) - assert manager.stale_game_timeout == 42 - - def test_mutates_caller_list_in_place_and_returns_none(self, build_manager): - manager = build_manager(_LiveHarness) - fresh = game("1", period_text="P2", clock="10:00", period=2) - over = game("2", period_text="Final", clock="0:00", period=3) - games = [fresh, over] - original = games - - result = manager._detect_stale_games(games) - - assert result is None - assert games is original # same object, mutated in place - assert _ids(games) == ["1"] - - def test_evicts_only_past_timeout_games(self, build_manager): - manager = build_manager(_LiveHarness) - with freeze_time("2026-01-20 12:00:00"): - now = time.time() - manager.game_update_timestamps = { - "1": {"last_seen": now - 10}, # fresh - "2": {"last_seen": now - 299}, # just inside the timeout - "3": {"last_seen": now - 301}, # past the timeout - } - games = [game("1", period_text="P1", clock="10:00", period=1), - game("2", period_text="P1", clock="10:00", period=1), - game("3", period_text="P1", clock="10:00", period=1)] - manager._detect_stale_games(games) - - assert _ids(games) == ["1", "2"] - assert "3" not in manager.game_update_timestamps - assert set(manager.game_update_timestamps) == {"1", "2"} - - def test_unknown_last_seen_is_never_stale(self, build_manager): - """last_seen == 0 (or no entry) means 'never recorded', not 'ancient'.""" - manager = build_manager(_LiveHarness) - games = [game("1", period_text="P1", clock="10:00", period=1), - game("2", period_text="P1", clock="10:00", period=1)] - manager.game_update_timestamps = {"1": {"last_seen": 0}} - manager._detect_stale_games(games) - assert _ids(games) == ["1", "2"] - - def test_removes_games_that_are_really_over(self, build_manager): - manager = build_manager(_LiveHarness) - manager.game_update_timestamps = {"2": {"last_seen": 0}} - games = [game("1", period_text="Q2", clock="5:00", period=2), - game("2", period_text="Final", clock="0:00", period=4)] - manager._detect_stale_games(games) - assert _ids(games) == ["1"] - assert "2" not in manager.game_update_timestamps - - def test_keeps_clockless_late_game(self, build_manager): - """The end-to-end form of the baseball regression: a clockless game in - the 7th must survive the removal path.""" - manager = build_manager(_LiveHarness) - games = [game("mlb-1", period=7, period_text="Top 7th")] - manager._detect_stale_games(games) - assert _ids(games) == ["mlb-1"] - - def test_games_without_id_are_skipped(self, build_manager): - manager = build_manager(_LiveHarness) - no_id = {"home_abbr": "BOS", "away_abbr": "TOR", - "period_text": "Final", "clock": "0:00", "period": 4} - games = [no_id] - manager._detect_stale_games(games) - # `continue` fires before the "really over" check, so it stays. - assert games == [no_id] - - def test_empty_list_is_tolerated(self, build_manager): - manager = build_manager(_LiveHarness) - games = [] - assert manager._detect_stale_games(games) is None - assert games == [] - - def test_removal_is_by_value_not_identity(self, build_manager): - """Sharp edge worth pinning: `list.remove` compares with `dict.__eq__`, - so two structurally-equal dicts drop the FIRST occurrence.""" - manager = build_manager(_LiveHarness) - first = game("1", period_text="Final", clock="0:00", period=4) - twin = dict(first) - games = [first, twin] - manager._detect_stale_games(games) - # Both entries are removed here (two iterations, two removals), but the - # first removal deletes `first`, not the dict being iterated. - assert games == [] - - -# --------------------------------------------------------------------------- -# Tier 3a — _select_games_for_display (SportsUpcoming) -# --------------------------------------------------------------------------- - -class TestSelectGamesForDisplay: - def test_no_favorites_returns_all_sorted_ascending(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("late", start=at(20)), game("early", start=at(10)), - game("mid", start=at(15))] - assert _ids(manager._select_games_for_display(games, [])) == [ - "early", "mid", "late"] - - def test_missing_start_time_sorts_last(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("none", start=None), game("early", start=at(10))] - assert _ids(manager._select_games_for_display(games, [])) == [ - "early", "none"] - - def test_filters_to_favorite_teams(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("1", home="BOS", away="TOR", start=at(10)), - game("2", home="NYR", away="PIT", start=at(11)), - game("3", home="MTL", away="BOS", start=at(12))] - assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["1", "3"] - - def test_respects_upcoming_games_to_show_per_team(self, build_manager): - manager = build_manager(_UpcomingHarness, upcoming_games_to_show=2) - games = [game(str(i), home="BOS", away="TOR", start=at(10 + i)) - for i in range(5)] - assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["0", "1"] - - def test_game_between_two_favorites_counts_for_both(self, build_manager): - manager = build_manager(_UpcomingHarness, upcoming_games_to_show=1) - games = [game("shared", home="BOS", away="TOR", start=at(10)), - game("bos2", home="BOS", away="NYR", start=at(11)), - game("tor2", home="TOR", away="PIT", start=at(12))] - # "shared" fills both BOS's and TOR's single slot, so nothing else fits. - assert _ids(manager._select_games_for_display( - games, ["BOS", "TOR"])) == ["shared"] - - def test_deduplicates_by_game_id(self, build_manager): - manager = build_manager(_UpcomingHarness, upcoming_games_to_show=5) - g = game("dupe", home="BOS", away="TOR", start=at(10)) - assert _ids(manager._select_games_for_display( - [g, dict(g)], ["BOS"])) == ["dupe"] - - def test_non_favorite_games_excluded(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("1", home="NYR", away="PIT", start=at(10))] - assert manager._select_games_for_display(games, ["BOS"]) == [] - - def test_favorite_key_seam_supports_id_matching(self, build_manager): - """The NRL case: two clubs share the abbreviation 'NEW', so favorites - must be matched on team id. Only the seam changes — the promoted - method is identical.""" - games = [ - game("knights", home="NEW", away="SYD", home_id=1, away_id=2, - start=at(10)), - game("warriors", home="NEW", away="SYD", home_id=99, away_id=2, - start=at(11)), - ] - abbr_manager = build_manager(_UpcomingHarness) - # Abbreviation matching cannot tell the two "NEW" clubs apart. - assert _ids(abbr_manager._select_games_for_display(games, ["NEW"])) == [ - "knights", "warriors"] - - id_manager = build_manager(_IdFavoriteUpcomingHarness) - assert _ids(id_manager._select_games_for_display(games, ["99"])) == [ - "warriors"] - - def test_favorite_key_none_never_matches(self, build_manager): - """`_favorite_key` returning None (missing id) must not match, even - against a favorites list holding the string 'None'.""" - manager = build_manager(_IdFavoriteUpcomingHarness) - games = [game("1", home="BOS", away="TOR", start=at(10))] # no ids - assert manager._select_games_for_display(games, ["None"]) == [] - - -# --------------------------------------------------------------------------- -# Tier 3b — _select_recent_games_for_display (SportsRecent) -# --------------------------------------------------------------------------- - -class TestSelectRecentGamesForDisplay: - def test_no_favorites_returns_all_sorted_descending(self, build_manager): - manager = build_manager(_RecentHarness) - games = [game("early", start=at(10)), game("late", start=at(20)), - game("mid", start=at(15))] - assert _ids(manager._select_recent_games_for_display(games, [])) == [ - "late", "mid", "early"] - - def test_missing_start_time_sorts_last(self, build_manager): - manager = build_manager(_RecentHarness) - games = [game("none", start=None), game("late", start=at(20))] - assert _ids(manager._select_recent_games_for_display(games, [])) == [ - "late", "none"] - - def test_respects_recent_games_to_show_per_team(self, build_manager): - manager = build_manager(_RecentHarness, recent_games_to_show=2) - games = [game(str(i), home="BOS", away="TOR", start=at(10 + i)) - for i in range(5)] - # Most recent first. - assert _ids(manager._select_recent_games_for_display( - games, ["BOS"])) == ["4", "3"] - - def test_game_between_two_favorites_counts_for_both(self, build_manager): - manager = build_manager(_RecentHarness, recent_games_to_show=1) - games = [game("shared", home="BOS", away="TOR", start=at(20)), - game("bos2", home="BOS", away="NYR", start=at(19)), - game("tor2", home="TOR", away="PIT", start=at(18))] - assert _ids(manager._select_recent_games_for_display( - games, ["BOS", "TOR"])) == ["shared"] - - def test_deduplicates_by_game_id(self, build_manager): - manager = build_manager(_RecentHarness, recent_games_to_show=5) - g = game("dupe", home="BOS", away="TOR", start=at(10)) - assert _ids(manager._select_recent_games_for_display( - [g, dict(g)], ["BOS"])) == ["dupe"] - - def test_non_favorite_games_excluded(self, build_manager): - manager = build_manager(_RecentHarness) - games = [game("1", home="NYR", away="PIT", start=at(10))] - assert manager._select_recent_games_for_display(games, ["BOS"]) == [] - - def test_favorite_key_seam_supports_id_matching(self, build_manager): - games = [ - game("knights", home="NEW", away="SYD", home_id=1, away_id=2, - start=at(10)), - game("warriors", home="NEW", away="SYD", home_id=99, away_id=2, - start=at(11)), - ] - id_manager = build_manager(_IdFavoriteRecentHarness) - assert _ids(id_manager._select_recent_games_for_display( - games, ["99"])) == ["warriors"] - - -# --------------------------------------------------------------------------- -# Seam / inertness guards -# --------------------------------------------------------------------------- - -class TestPromotionShape: - def test_promoted_methods_live_on_the_right_classes(self): - assert hasattr(SportsRecent, "_get_zero_clock_duration") - assert hasattr(SportsRecent, "_clear_zero_clock_tracking") - assert hasattr(SportsRecent, "_select_recent_games_for_display") - assert hasattr(SportsUpcoming, "_select_games_for_display") - assert hasattr(SportsLive, "_is_game_really_over") - assert hasattr(SportsLive, "_detect_stale_games") - - def test_modes_does_not_define_the_favorite_key_seam(self): - """`_favorite_key` is a SportsCore override point. modes.py must call - it, never define it — this test fails if the seam is added in the - wrong file.""" - import src.base_classes.sports.modes as modes - - for cls in (SportsUpcoming, SportsRecent, SportsLive): - assert "_favorite_key" not in vars(cls) - assert "_favorite_key" not in modes.__dict__ diff --git a/test/test_sports_odds_fanout.py b/test/test_sports_odds_fanout.py deleted file mode 100644 index c33930e9..00000000 --- a/test/test_sports_odds_fanout.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Odds must be fetched for the games shown, not every game in the window. - -SportsUpcoming.update() collected every upcoming game in the schedule window -and called _fetch_odds() on each one *inside* that collection loop, narrowing -to upcoming_games_to_show only afterwards. The comment there said odds were -fetched "only for games that will be displayed", but the sole narrowing it -applied was show_favorite_teams_only, which is not the default -- so in the -usual configuration nothing narrowed it at all. - -Measured on a live rig: a college league produced 946 upcoming games in one -cycle and displayed 1 of them. The same shape on the football plugin produced -a burst of 467 sequential ESPN requests that ran for 35s and blew that -plugin's 30s update budget, and it repeats every time the 1h odds TTL expires. - -SportsLive is deliberately different: it walks the raw event list because it -has to find which games are live, but only fetches odds for a game that has -already passed the is_live/is_halftime test, so the fan-out is bounded by how -many games are actually in progress. -""" -import ast -from pathlib import Path - -import pytest - -MODES = (Path(__file__).resolve().parent.parent - / "src" / "base_classes" / "sports" / "modes.py") -TREE = ast.parse(MODES.read_text(encoding="utf-8")) - - -def _fetch_sites(): - """(class name, method name, lineno) for every self._fetch_odds(...) call.""" - calls = [n.lineno for n in ast.walk(TREE) - if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) - and n.func.attr == "_fetch_odds"] - sites = [] - for cls in [n for n in ast.walk(TREE) if isinstance(n, ast.ClassDef)]: - for fn in [n for n in cls.body if isinstance(n, ast.FunctionDef)]: - for lineno in calls: - if fn.lineno <= lineno <= (fn.end_lineno or fn.lineno): - sites.append((cls.name, fn.name, lineno)) - assert len(sites) == len(calls), "a _fetch_odds call sits outside any method" - return sites - - -def _innermost_loop_iterable(lineno): - best = None - for node in ast.walk(TREE): - if isinstance(node, ast.For) and \ - node.lineno <= lineno <= (node.end_lineno or node.lineno): - if best is None or node.lineno > best.lineno: - best = node - return None if best is None else ast.unparse(best.iter) - - -def _spans(body, lineno): - """True when `lineno` falls inside this list of statements.""" - return any(n.lineno <= lineno <= (n.end_lineno or n.lineno) for n in body) - - -def _parents(tree): - table = {} - for node in ast.walk(tree): - for child in ast.iter_child_nodes(node): - table[child] = node - return table - - -PARENTS = _parents(TREE) - - -def _mentions_positively(test, names): - """True when `test` references every name, none of them under a `not`. - - Structural, not textual. Matching the unparsed source would accept - `not (details["is_live"] or details["is_halftime"])` -- which selects - exactly the non-live games this guard exists to exclude -- because the - names still appear in the text. - """ - found = set() - for node in ast.walk(test): - if not (isinstance(node, ast.Constant) and node.value in names): - continue - negated = False - cursor = node - while cursor is not test and cursor in PARENTS: - cursor = PARENTS[cursor] - if isinstance(cursor, ast.UnaryOp) and isinstance(cursor.op, ast.Not): - negated = True - break - if not negated: - found.add(node.value) - return found >= set(names) - - -def _guarded_by_positive(lineno, names): - """True when some enclosing `if` runs this line only if `names` hold. - - Only the TRUE branch counts: an `if` whose `else` contains the call would - otherwise look like a guard while doing the opposite. - """ - for node in ast.walk(TREE): - if isinstance(node, ast.If) and _spans(node.body, lineno) \ - and _mentions_positively(node.test, names): - return True - return False - - -def test_every_fetch_site_is_accounted_for(): - """A new call site must be classified deliberately, not inherited silently.""" - found = {(cls, fn) for cls, fn, _ in _fetch_sites()} - assert found == {("SportsUpcoming", "update"), ("SportsLive", "update")}, ( - f"unexpected _fetch_odds call sites: {sorted(found)}. Each one is a " - "sequential ESPN request per game -- classify it here on purpose.") - - -def test_upcoming_fetches_only_the_selected_games(): - for cls, _fn, lineno in _fetch_sites(): - if cls != "SportsUpcoming": - continue - iterable = _innermost_loop_iterable(lineno) - assert iterable == "team_games", ( - f"SportsUpcoming._fetch_odds at line {lineno} iterates over " - f"{iterable!r}. It must run over team_games -- already narrowed to " - "upcoming_games_to_show -- not over every event in the schedule " - "window. Each item costs one sequential ESPN request.") - - -def test_live_only_fetches_for_games_actually_in_progress(): - for cls, _fn, lineno in _fetch_sites(): - if cls != "SportsLive": - continue - assert _guarded_by_positive(lineno, {"is_live", "is_halftime"}), ( - f"SportsLive._fetch_odds at line {lineno} does not sit in the true " - "branch of a test requiring the game to be in progress. Without " - "that, it fans out across the whole event list -- one sequential " - "ESPN request per game.")