refactor: remove the unused src/base_classes package

No scoreboard plugin builds on src.base_classes: the nine monorepo
scoreboards ship their own sports.py and share code through src/common
(docs/SPORTS_UNIFICATION.md), and none of the third-party registry plugins
imports it. The one import anywhere, baseball-scoreboard's
rankings_manager.py, is a lazy import of ESPNDataSource in a class nothing
instantiates.

Removed the package and the eight test files that only tested it
(test_api_extractors, test_data_sources, test_sports_base_characterization,
test_sports_capabilities, test_sports_core_promotions,
test_sports_logo_cache_bounded, test_sports_modes_promotions,
test_sports_odds_fanout). test_common_is_hardware_free no longer lists
src.base_classes as a forbidden import, and comments in sports_helpers.py
and base_odds_manager.py stop pointing at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 09:58:52 -04:00
co-authored by Claude Opus 5.5
parent 21c05c9326
commit 7f2bd6b369
23 changed files with 15 additions and 9290 deletions
-371
View File
@@ -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
-692
View File
@@ -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
-300
View File
@@ -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
-344
View File
@@ -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
-387
View File
@@ -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
-380
View File
@@ -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
-17
View File
@@ -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",
]
@@ -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.<feature>_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",
]
@@ -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)
@@ -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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -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
+9 -10
View File
@@ -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")
-342
View File
@@ -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"
+5 -6
View File
@@ -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
-307
View File
@@ -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 == {}
-660
View File
@@ -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
File diff suppressed because it is too large Load Diff
-613
View File
@@ -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 `<root>/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)
-120
View File
@@ -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
-585
View File
@@ -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__
-136
View File
@@ -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.")