mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-16 09:38:38 +00:00
Compare commits
8 Commits
33f76b4895
...
claude/tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f67b9c25f1 | ||
|
|
4977c5fbc9 | ||
|
|
327e87f735 | ||
|
|
b5426da2a7 | ||
|
|
302ab1da4f | ||
|
|
9cd2bd14ce | ||
|
|
53ee184bc5 | ||
|
|
e00d75bbb5 |
16
README.md
16
README.md
@@ -1,5 +1,10 @@
|
||||
# LEDMatrix
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/RdrC37rEag)
|
||||
[](https://github.com/ChuckBuilds/ledmatrix)
|
||||
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
|
||||
|
||||
## Welcome to LEDMatrix!
|
||||
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
||||
|
||||
@@ -127,10 +132,15 @@ The system supports live, recent, and upcoming game information for multiple spo
|
||||
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
||||
|
||||
### Raspberry Pi
|
||||
- Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output.
|
||||
- **Raspberry Pi 3B or 4 (NOT RPi 5!)**
|
||||
- Raspberry Pi Zero's don't have enough processing power for this project.
|
||||
- **Raspberry Pi 3B, 4, or 5**
|
||||
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
||||
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
||||
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
|
||||
```bash
|
||||
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
||||
```
|
||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
||||
|
||||
|
||||
### RGB Matrix Bonnet / HAT
|
||||
@@ -582,7 +592,7 @@ These settings control runtime behavior and GPIO timing:
|
||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||
- **Raspberry Pi 3**: Use 3
|
||||
- **Raspberry Pi 4**: Use 4
|
||||
- **Raspberry Pi 5**: Use 5 (or higher if needed)
|
||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||
- **Raspberry Pi Zero/1**: Use 1-2
|
||||
- Incorrect values can cause display corruption, flickering, or system instability
|
||||
- If you experience issues, try adjusting this value up or down by 1
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
"schedule": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"mode": "per-day",
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00",
|
||||
"days": {
|
||||
"monday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"tuesday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"wednesday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"thursday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"friday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"saturday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"sunday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
}
|
||||
@@ -51,46 +51,46 @@
|
||||
"end_time": "07:00",
|
||||
"days": {
|
||||
"monday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"tuesday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"wednesday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"thursday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"friday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"saturday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"sunday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timezone": "America/Chicago",
|
||||
"timezone": "America/New_York",
|
||||
"location": {
|
||||
"city": "Dallas",
|
||||
"state": "Texas",
|
||||
"city": "Tampa",
|
||||
"state": "Florida",
|
||||
"country": "US"
|
||||
},
|
||||
"display": {
|
||||
|
||||
@@ -36,9 +36,17 @@ if [ -r /proc/device-tree/model ]; then
|
||||
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
||||
echo "Detected device: $DEVICE_MODEL"
|
||||
else
|
||||
DEVICE_MODEL=""
|
||||
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
||||
fi
|
||||
|
||||
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
|
||||
IS_PI5=0
|
||||
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
|
||||
IS_PI5=1
|
||||
echo "Raspberry Pi 5 detected — will verify RP1 library support."
|
||||
fi
|
||||
|
||||
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
||||
echo ""
|
||||
echo "Checking operating system requirements..."
|
||||
@@ -783,9 +791,28 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||
echo "-----------------------------------------------------"
|
||||
|
||||
# If already installed and not forcing rebuild, skip expensive build
|
||||
# On Pi 5, also check that the installed library has rp1_rio support.
|
||||
# A library built before Pi 5 support was added imports fine but maps to the
|
||||
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
|
||||
_HAS_RP1=0
|
||||
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
||||
_HAS_RP1=1
|
||||
fi
|
||||
|
||||
_SKIP_BUILD=0
|
||||
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
||||
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
|
||||
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
|
||||
echo " Forcing rebuild to get Pi 5 RP1 support..."
|
||||
else
|
||||
_SKIP_BUILD=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$_SKIP_BUILD" = "1" ]; then
|
||||
_skip_suffix=""
|
||||
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
|
||||
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||
else
|
||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||
@@ -852,6 +879,17 @@ except Exception as e:
|
||||
PY
|
||||
then
|
||||
echo "✓ rpi-rgb-led-matrix installed and verified"
|
||||
# Pi 5: confirm the freshly-built library has rp1_rio support
|
||||
if [ "$IS_PI5" = "1" ]; then
|
||||
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
||||
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
|
||||
else
|
||||
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
|
||||
echo " Try updating the submodule and rebuilding:"
|
||||
echo " git submodule update --remote rpi-rgb-led-matrix-master"
|
||||
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "✗ rpi-rgb-led-matrix import test failed"
|
||||
exit 1
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "March Madness Plugin Configuration",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the March Madness tournament display"
|
||||
},
|
||||
"leagues": {
|
||||
"type": "object",
|
||||
"title": "Tournament Leagues",
|
||||
"description": "Which NCAA tournaments to display",
|
||||
"properties": {
|
||||
"ncaam": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show NCAA Men's Tournament games"
|
||||
},
|
||||
"ncaaw": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show NCAA Women's Tournament games"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"favorite_teams": {
|
||||
"type": "array",
|
||||
"title": "Favorite Teams",
|
||||
"description": "Team abbreviations to highlight (e.g., DUKE, UNC). Leave empty to show all teams equally.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
},
|
||||
"display_options": {
|
||||
"type": "object",
|
||||
"title": "Display Options",
|
||||
"x-collapsed": true,
|
||||
"properties": {
|
||||
"show_seeds": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show tournament seeds (1-16) next to team names"
|
||||
},
|
||||
"show_round_logos": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show round logo separators between game groups"
|
||||
},
|
||||
"highlight_upsets": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Highlight upset winners (higher seed beating lower seed) in gold"
|
||||
},
|
||||
"show_bracket_progress": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show which teams are still alive in each region"
|
||||
},
|
||||
"scroll_speed": {
|
||||
"type": "number",
|
||||
"default": 1.0,
|
||||
"minimum": 0.5,
|
||||
"maximum": 5.0,
|
||||
"description": "Scroll speed (pixels per frame)"
|
||||
},
|
||||
"scroll_delay": {
|
||||
"type": "number",
|
||||
"default": 0.02,
|
||||
"minimum": 0.001,
|
||||
"maximum": 0.1,
|
||||
"description": "Delay between scroll frames (seconds)"
|
||||
},
|
||||
"target_fps": {
|
||||
"type": "integer",
|
||||
"default": 120,
|
||||
"minimum": 30,
|
||||
"maximum": 200,
|
||||
"description": "Target frames per second"
|
||||
},
|
||||
"loop": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Loop the scroll continuously"
|
||||
},
|
||||
"dynamic_duration": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically adjust display duration based on content width"
|
||||
},
|
||||
"min_duration": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 10,
|
||||
"maximum": 300,
|
||||
"description": "Minimum display duration in seconds"
|
||||
},
|
||||
"max_duration": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"minimum": 30,
|
||||
"maximum": 600,
|
||||
"description": "Maximum display duration in seconds"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"data_settings": {
|
||||
"type": "object",
|
||||
"title": "Data Settings",
|
||||
"x-collapsed": true,
|
||||
"properties": {
|
||||
"update_interval": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"minimum": 60,
|
||||
"maximum": 3600,
|
||||
"description": "How often to refresh tournament data (seconds). Automatically shortens to 60s when live games are detected."
|
||||
},
|
||||
"request_timeout": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 5,
|
||||
"maximum": 60,
|
||||
"description": "API request timeout in seconds"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"x-propertyOrder": ["enabled", "leagues", "favorite_teams", "display_options", "data_settings"]
|
||||
}
|
||||
@@ -1,910 +0,0 @@
|
||||
"""March Madness Plugin — NCAA Tournament bracket tracker for LED Matrix.
|
||||
|
||||
Displays a horizontally-scrolling ticker of NCAA Tournament games grouped by
|
||||
round, with seeds, round logos, live scores, and upset highlighting.
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pytz
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
try:
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
except ImportError:
|
||||
ScrollHelper = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCOREBOARD_URLS = {
|
||||
"ncaam": "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard",
|
||||
"ncaaw": "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard",
|
||||
}
|
||||
|
||||
ROUND_ORDER = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6}
|
||||
|
||||
ROUND_DISPLAY_NAMES = {
|
||||
"NCG": "Championship",
|
||||
"F4": "Final Four",
|
||||
"E8": "Elite Eight",
|
||||
"S16": "Sweet Sixteen",
|
||||
"R32": "Round of 32",
|
||||
"R64": "Round of 64",
|
||||
}
|
||||
|
||||
ROUND_LOGO_FILES = {
|
||||
"NCG": "CHAMPIONSHIP.png",
|
||||
"F4": "FINAL_4.png",
|
||||
"E8": "ELITE_8.png",
|
||||
"S16": "SWEET_16.png",
|
||||
"R32": "ROUND_32.png",
|
||||
"R64": "ROUND_64.png",
|
||||
}
|
||||
|
||||
REGION_ORDER = {"E": 0, "W": 1, "S": 2, "MW": 3, "": 4}
|
||||
|
||||
# Colors
|
||||
COLOR_WHITE = (255, 255, 255)
|
||||
COLOR_GOLD = (255, 215, 0)
|
||||
COLOR_GRAY = (160, 160, 160)
|
||||
COLOR_DIM = (100, 100, 100)
|
||||
COLOR_RED = (255, 60, 60)
|
||||
COLOR_GREEN = (60, 200, 60)
|
||||
COLOR_BLACK = (0, 0, 0)
|
||||
COLOR_DARK_BG = (20, 20, 20)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MarchMadnessPlugin(BasePlugin):
|
||||
"""NCAA March Madness tournament bracket tracker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# Config
|
||||
leagues_config = config.get("leagues", {})
|
||||
self.show_ncaam: bool = leagues_config.get("ncaam", True)
|
||||
self.show_ncaaw: bool = leagues_config.get("ncaaw", True)
|
||||
self.favorite_teams: List[str] = [t.upper() for t in config.get("favorite_teams", [])]
|
||||
|
||||
display_options = config.get("display_options", {})
|
||||
self.show_seeds: bool = display_options.get("show_seeds", True)
|
||||
self.show_round_logos: bool = display_options.get("show_round_logos", True)
|
||||
self.highlight_upsets: bool = display_options.get("highlight_upsets", True)
|
||||
self.show_bracket_progress: bool = display_options.get("show_bracket_progress", True)
|
||||
self.scroll_speed: float = display_options.get("scroll_speed", 1.0)
|
||||
self.scroll_delay: float = display_options.get("scroll_delay", 0.02)
|
||||
self.target_fps: int = display_options.get("target_fps", 120)
|
||||
self.loop: bool = display_options.get("loop", True)
|
||||
self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True)
|
||||
self.min_duration: int = display_options.get("min_duration", 30)
|
||||
self.max_duration: int = display_options.get("max_duration", 300)
|
||||
if self.min_duration > self.max_duration:
|
||||
self.logger.warning(
|
||||
f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values"
|
||||
)
|
||||
self.min_duration, self.max_duration = self.max_duration, self.min_duration
|
||||
|
||||
data_settings = config.get("data_settings", {})
|
||||
self.update_interval: int = data_settings.get("update_interval", 300)
|
||||
self.request_timeout: int = data_settings.get("request_timeout", 30)
|
||||
|
||||
# Scrolling flag for display controller
|
||||
self.enable_scrolling = True
|
||||
|
||||
# State
|
||||
self.games_data: List[Dict] = []
|
||||
self.ticker_image: Optional[Image.Image] = None
|
||||
self.last_update: float = 0
|
||||
self.dynamic_duration: float = 60
|
||||
self.total_scroll_width: int = 0
|
||||
self._display_start_time: Optional[float] = None
|
||||
self._end_reached_logged: bool = False
|
||||
self._update_lock = threading.Lock()
|
||||
self._has_live_games: bool = False
|
||||
self._cached_dynamic_duration: Optional[float] = None
|
||||
self._duration_cache_time: float = 0
|
||||
|
||||
# Display dimensions
|
||||
self.display_width: int = self.display_manager.matrix.width
|
||||
self.display_height: int = self.display_manager.matrix.height
|
||||
|
||||
# HTTP session with retry
|
||||
self.session = requests.Session()
|
||||
retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
|
||||
self.session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
self.headers = {"User-Agent": "LEDMatrix/2.0"}
|
||||
|
||||
# ScrollHelper
|
||||
if ScrollHelper:
|
||||
self.scroll_helper = ScrollHelper(self.display_width, self.display_height, logger=self.logger)
|
||||
if hasattr(self.scroll_helper, "set_frame_based_scrolling"):
|
||||
self.scroll_helper.set_frame_based_scrolling(True)
|
||||
self.scroll_helper.set_scroll_speed(self.scroll_speed)
|
||||
self.scroll_helper.set_scroll_delay(self.scroll_delay)
|
||||
if hasattr(self.scroll_helper, "set_target_fps"):
|
||||
self.scroll_helper.set_target_fps(self.target_fps)
|
||||
self.scroll_helper.set_dynamic_duration_settings(
|
||||
enabled=self.dynamic_duration_enabled,
|
||||
min_duration=self.min_duration,
|
||||
max_duration=self.max_duration,
|
||||
buffer=0.1,
|
||||
)
|
||||
else:
|
||||
self.scroll_helper = None
|
||||
self.logger.warning("ScrollHelper not available")
|
||||
|
||||
# Fonts
|
||||
self.fonts = self._load_fonts()
|
||||
|
||||
# Logos
|
||||
self._round_logos: Dict[str, Image.Image] = {}
|
||||
self._team_logo_cache: Dict[str, Optional[Image.Image]] = {}
|
||||
self._march_madness_logo: Optional[Image.Image] = None
|
||||
self._load_round_logos()
|
||||
|
||||
self.logger.info(
|
||||
f"MarchMadnessPlugin initialized — NCAAM: {self.show_ncaam}, "
|
||||
f"NCAAW: {self.show_ncaaw}, favorites: {self.favorite_teams}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fonts
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
|
||||
fonts = {}
|
||||
try:
|
||||
fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
||||
except IOError:
|
||||
fonts["score"] = ImageFont.load_default()
|
||||
try:
|
||||
fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
except IOError:
|
||||
fonts["time"] = ImageFont.load_default()
|
||||
try:
|
||||
fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||
except IOError:
|
||||
fonts["detail"] = ImageFont.load_default()
|
||||
return fonts
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Logo loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_round_logos(self) -> None:
|
||||
logo_dir = Path("assets/sports/ncaa_logos")
|
||||
for round_key, filename in ROUND_LOGO_FILES.items():
|
||||
path = logo_dir / filename
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
# Resize to fit display height
|
||||
target_h = self.display_height - 4
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(f"Could not load round logo {filename}: {e}")
|
||||
except Exception:
|
||||
self.logger.exception(f"Unexpected error loading round logo {filename}")
|
||||
|
||||
# March Madness logo
|
||||
mm_path = logo_dir / "MARCH_MADNESS.png"
|
||||
try:
|
||||
img = Image.open(mm_path).convert("RGBA")
|
||||
target_h = self.display_height - 4
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(f"Could not load March Madness logo: {e}")
|
||||
except Exception:
|
||||
self.logger.exception("Unexpected error loading March Madness logo")
|
||||
|
||||
def _get_team_logo(self, abbr: str) -> Optional[Image.Image]:
|
||||
if abbr in self._team_logo_cache:
|
||||
return self._team_logo_cache[abbr]
|
||||
logo_dir = Path("assets/sports/ncaa_logos")
|
||||
path = logo_dir / f"{abbr}.png"
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
target_h = self.display_height - 6
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
self._team_logo_cache[abbr] = img
|
||||
return img
|
||||
except (FileNotFoundError, OSError, ValueError):
|
||||
self._team_logo_cache[abbr] = None
|
||||
return None
|
||||
except Exception:
|
||||
self.logger.exception(f"Unexpected error loading team logo for {abbr}")
|
||||
self._team_logo_cache[abbr] = None
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data fetching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_tournament_window(self) -> bool:
|
||||
today = datetime.now(pytz.utc)
|
||||
return (3, 10) <= (today.month, today.day) <= (4, 10)
|
||||
|
||||
def _fetch_tournament_data(self) -> List[Dict]:
|
||||
"""Fetch tournament games from ESPN scoreboard API."""
|
||||
all_games: List[Dict] = []
|
||||
|
||||
leagues = []
|
||||
if self.show_ncaam:
|
||||
leagues.append("ncaam")
|
||||
if self.show_ncaaw:
|
||||
leagues.append("ncaaw")
|
||||
|
||||
for league_key in leagues:
|
||||
url = SCOREBOARD_URLS.get(league_key)
|
||||
if not url:
|
||||
continue
|
||||
|
||||
cache_key = f"march_madness_{league_key}_scoreboard"
|
||||
cache_max_age = 60 if self._has_live_games else self.update_interval
|
||||
cached = self.cache_manager.get(cache_key, max_age=cache_max_age)
|
||||
if cached:
|
||||
all_games.extend(cached)
|
||||
continue
|
||||
|
||||
try:
|
||||
# NCAA basketball scoreboard without dates param returns current games
|
||||
params = {"limit": 1000, "groups": 100}
|
||||
resp = self.session.get(url, params=params, headers=self.headers, timeout=self.request_timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
events = data.get("events", [])
|
||||
|
||||
league_games = []
|
||||
for event in events:
|
||||
game = self._parse_event(event, league_key)
|
||||
if game:
|
||||
league_games.append(game)
|
||||
|
||||
self.cache_manager.set(cache_key, league_games)
|
||||
self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games")
|
||||
all_games.extend(league_games)
|
||||
|
||||
except Exception:
|
||||
self.logger.exception(f"Error fetching {league_key} tournament data")
|
||||
|
||||
return all_games
|
||||
|
||||
def _parse_event(self, event: Dict, league_key: str) -> Optional[Dict]:
|
||||
"""Parse an ESPN event into a game dict."""
|
||||
competitions = event.get("competitions", [])
|
||||
if not competitions:
|
||||
return None
|
||||
comp = competitions[0]
|
||||
|
||||
# Confirm tournament game
|
||||
comp_type = comp.get("type", {})
|
||||
is_tournament = comp_type.get("abbreviation") == "TRNMNT"
|
||||
notes = comp.get("notes", [])
|
||||
headline = ""
|
||||
if notes:
|
||||
headline = notes[0].get("headline", "")
|
||||
if not is_tournament and "Championship" in headline:
|
||||
is_tournament = True
|
||||
if not is_tournament:
|
||||
return None
|
||||
|
||||
# Status
|
||||
status = comp.get("status", {}).get("type", {})
|
||||
state = status.get("state", "pre")
|
||||
status_detail = status.get("shortDetail", "")
|
||||
|
||||
# Teams
|
||||
competitors = comp.get("competitors", [])
|
||||
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:
|
||||
return None
|
||||
|
||||
home_abbr = home_team.get("team", {}).get("abbreviation", "???")
|
||||
away_abbr = away_team.get("team", {}).get("abbreviation", "???")
|
||||
home_score = home_team.get("score", "0")
|
||||
away_score = away_team.get("score", "0")
|
||||
|
||||
# Seeds
|
||||
home_seed = home_team.get("curatedRank", {}).get("current", 0)
|
||||
away_seed = away_team.get("curatedRank", {}).get("current", 0)
|
||||
if home_seed >= 99:
|
||||
home_seed = 0
|
||||
if away_seed >= 99:
|
||||
away_seed = 0
|
||||
|
||||
# Round and region
|
||||
tournament_round = self._parse_round(headline)
|
||||
tournament_region = self._parse_region(headline)
|
||||
|
||||
# Date/time
|
||||
date_str = event.get("date", "")
|
||||
start_time_utc = None
|
||||
game_date = ""
|
||||
game_time = ""
|
||||
try:
|
||||
if date_str.endswith("Z"):
|
||||
date_str = date_str.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
if dt.tzinfo is None:
|
||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
||||
else:
|
||||
start_time_utc = dt.astimezone(pytz.UTC)
|
||||
local = start_time_utc.astimezone(pytz.timezone("US/Eastern"))
|
||||
game_date = local.strftime("%-m/%-d")
|
||||
game_time = local.strftime("%-I:%M%p").replace("AM", "am").replace("PM", "pm")
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Period / clock for live games
|
||||
period = 0
|
||||
clock = ""
|
||||
period_text = ""
|
||||
is_halftime = False
|
||||
if state == "in":
|
||||
status_obj = comp.get("status", {})
|
||||
period = status_obj.get("period", 0)
|
||||
clock = status_obj.get("displayClock", "")
|
||||
detail_lower = status_detail.lower()
|
||||
uses_quarters = league_key == "ncaaw" or "quarter" in detail_lower or detail_lower.startswith("q")
|
||||
if period <= (4 if uses_quarters else 2):
|
||||
period_text = f"Q{period}" if uses_quarters else f"H{period}"
|
||||
else:
|
||||
ot_num = period - (4 if uses_quarters else 2)
|
||||
period_text = f"OT{ot_num}" if ot_num > 1 else "OT"
|
||||
if "halftime" in detail_lower:
|
||||
is_halftime = True
|
||||
elif state == "post":
|
||||
period_text = status.get("shortDetail", "Final")
|
||||
if "Final" not in period_text:
|
||||
period_text = "Final"
|
||||
|
||||
# Determine winner and upset
|
||||
is_final = state == "post"
|
||||
is_upset = False
|
||||
winner_side = ""
|
||||
if is_final:
|
||||
try:
|
||||
h = int(float(home_score))
|
||||
a = int(float(away_score))
|
||||
if h > a:
|
||||
winner_side = "home"
|
||||
if home_seed > away_seed > 0:
|
||||
is_upset = True
|
||||
elif a > h:
|
||||
winner_side = "away"
|
||||
if away_seed > home_seed > 0:
|
||||
is_upset = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": event.get("id", ""),
|
||||
"league": league_key,
|
||||
"home_abbr": home_abbr,
|
||||
"away_abbr": away_abbr,
|
||||
"home_score": str(home_score),
|
||||
"away_score": str(away_score),
|
||||
"home_seed": home_seed,
|
||||
"away_seed": away_seed,
|
||||
"tournament_round": tournament_round,
|
||||
"tournament_region": tournament_region,
|
||||
"state": state,
|
||||
"is_final": is_final,
|
||||
"is_live": state == "in",
|
||||
"is_upcoming": state == "pre",
|
||||
"is_halftime": is_halftime,
|
||||
"period": period,
|
||||
"period_text": period_text,
|
||||
"clock": clock,
|
||||
"status_detail": status_detail,
|
||||
"game_date": game_date,
|
||||
"game_time": game_time,
|
||||
"start_time_utc": start_time_utc,
|
||||
"is_upset": is_upset,
|
||||
"winner_side": winner_side,
|
||||
"headline": headline,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_round(headline: str) -> str:
|
||||
hl = headline.lower()
|
||||
if "national championship" in hl:
|
||||
return "NCG"
|
||||
if "final four" in hl:
|
||||
return "F4"
|
||||
if "elite 8" in hl or "elite eight" in hl:
|
||||
return "E8"
|
||||
if "sweet 16" in hl or "sweet sixteen" in hl:
|
||||
return "S16"
|
||||
if "2nd round" in hl or "second round" in hl:
|
||||
return "R32"
|
||||
if "1st round" in hl or "first round" in hl:
|
||||
return "R64"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_region(headline: str) -> str:
|
||||
if "East Region" in headline:
|
||||
return "E"
|
||||
if "West Region" in headline:
|
||||
return "W"
|
||||
if "South Region" in headline:
|
||||
return "S"
|
||||
if "Midwest Region" in headline:
|
||||
return "MW"
|
||||
m = re.search(r"Regional (\d+)", headline)
|
||||
if m:
|
||||
return f"R{m.group(1)}"
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Game processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _process_games(self, games: List[Dict]) -> Dict[str, List[Dict]]:
|
||||
"""Group games by round, sorted by round significance then region/seed."""
|
||||
grouped: Dict[str, List[Dict]] = {}
|
||||
for game in games:
|
||||
rnd = game.get("tournament_round", "")
|
||||
grouped.setdefault(rnd, []).append(game)
|
||||
|
||||
# Sort each round's games by region then seed matchup
|
||||
for rnd, round_games in grouped.items():
|
||||
round_games.sort(
|
||||
key=lambda g: (
|
||||
REGION_ORDER.get(g.get("tournament_region", ""), 4),
|
||||
min(g.get("away_seed", 99), g.get("home_seed", 99)),
|
||||
)
|
||||
)
|
||||
|
||||
return grouped
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _draw_text_with_outline(
|
||||
self,
|
||||
draw: ImageDraw.Draw,
|
||||
text: str,
|
||||
xy: tuple,
|
||||
font: ImageFont.FreeTypeFont,
|
||||
fill: tuple = COLOR_WHITE,
|
||||
outline: tuple = COLOR_BLACK,
|
||||
) -> None:
|
||||
x, y = xy
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
if dx or dy:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
def _create_round_separator(self, round_key: str) -> Image.Image:
|
||||
"""Create a separator tile for a tournament round."""
|
||||
height = self.display_height
|
||||
name = ROUND_DISPLAY_NAMES.get(round_key, round_key)
|
||||
font = self.fonts["time"]
|
||||
|
||||
# Measure text
|
||||
tmp = Image.new("RGB", (1, 1))
|
||||
tmp_draw = ImageDraw.Draw(tmp)
|
||||
text_width = int(tmp_draw.textlength(name, font=font))
|
||||
|
||||
# Logo on each side
|
||||
logo = self._round_logos.get(round_key, self._march_madness_logo)
|
||||
logo_w = logo.width if logo else 0
|
||||
padding = 6
|
||||
|
||||
total_w = padding + logo_w + padding + text_width + padding + logo_w + padding
|
||||
total_w = max(total_w, 80)
|
||||
|
||||
img = Image.new("RGB", (total_w, height), COLOR_DARK_BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw logos
|
||||
x = padding
|
||||
if logo:
|
||||
logo_y = (height - logo.height) // 2
|
||||
img.paste(logo, (x, logo_y), logo)
|
||||
x += logo_w + padding
|
||||
|
||||
# Draw round name
|
||||
text_y = (height - 8) // 2 # 8px font
|
||||
self._draw_text_with_outline(draw, name, (x, text_y), font, fill=COLOR_GOLD)
|
||||
x += text_width + padding
|
||||
|
||||
if logo:
|
||||
logo_y = (height - logo.height) // 2
|
||||
img.paste(logo, (x, logo_y), logo)
|
||||
|
||||
return img
|
||||
|
||||
def _create_game_tile(self, game: Dict) -> Image.Image:
|
||||
"""Create a single game tile for the scrolling ticker."""
|
||||
height = self.display_height
|
||||
font_score = self.fonts["score"]
|
||||
font_time = self.fonts["time"]
|
||||
font_detail = self.fonts["detail"]
|
||||
|
||||
# Load team logos
|
||||
away_logo = self._get_team_logo(game["away_abbr"])
|
||||
home_logo = self._get_team_logo(game["home_abbr"])
|
||||
logo_w = 0
|
||||
if away_logo:
|
||||
logo_w = max(logo_w, away_logo.width)
|
||||
if home_logo:
|
||||
logo_w = max(logo_w, home_logo.width)
|
||||
if logo_w == 0:
|
||||
logo_w = 24
|
||||
|
||||
# Build text elements
|
||||
away_seed_str = f"({game['away_seed']})" if self.show_seeds and game.get("away_seed", 0) > 0 else ""
|
||||
home_seed_str = f"({game['home_seed']})" if self.show_seeds and game.get("home_seed", 0) > 0 else ""
|
||||
away_text = f"{away_seed_str}{game['away_abbr']}"
|
||||
home_text = f"{game['home_abbr']}{home_seed_str}"
|
||||
|
||||
# Measure text widths
|
||||
tmp = Image.new("RGB", (1, 1))
|
||||
tmp_draw = ImageDraw.Draw(tmp)
|
||||
away_text_w = int(tmp_draw.textlength(away_text, font=font_detail))
|
||||
home_text_w = int(tmp_draw.textlength(home_text, font=font_detail))
|
||||
|
||||
# Center content: status line
|
||||
if game["is_live"]:
|
||||
if game["is_halftime"]:
|
||||
status_text = "Halftime"
|
||||
else:
|
||||
status_text = f"{game['period_text']} {game['clock']}".strip()
|
||||
elif game["is_final"]:
|
||||
status_text = game.get("period_text", "Final")
|
||||
else:
|
||||
status_text = f"{game['game_date']} {game['game_time']}".strip()
|
||||
|
||||
status_w = int(tmp_draw.textlength(status_text, font=font_time))
|
||||
|
||||
# Score line (for live/final)
|
||||
score_text = ""
|
||||
if game["is_live"] or game["is_final"]:
|
||||
score_text = f"{game['away_score']}-{game['home_score']}"
|
||||
score_w = int(tmp_draw.textlength(score_text, font=font_score)) if score_text else 0
|
||||
|
||||
# Calculate tile width
|
||||
h_pad = 4
|
||||
center_w = max(status_w, score_w, 40)
|
||||
tile_w = h_pad + logo_w + h_pad + away_text_w + h_pad + center_w + h_pad + home_text_w + h_pad + logo_w + h_pad
|
||||
|
||||
img = Image.new("RGB", (tile_w, height), COLOR_BLACK)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Paste away logo
|
||||
x = h_pad
|
||||
if away_logo:
|
||||
logo_y = (height - away_logo.height) // 2
|
||||
img.paste(away_logo, (x, logo_y), away_logo)
|
||||
x += logo_w + h_pad
|
||||
|
||||
# Away team text (seed + abbr)
|
||||
is_fav_away = game["away_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||
away_color = COLOR_GOLD if is_fav_away else COLOR_WHITE
|
||||
if game["is_final"] and game["winner_side"] == "away" and self.highlight_upsets and game["is_upset"]:
|
||||
away_color = COLOR_GOLD
|
||||
team_text_y = (height - 6) // 2 - 5 # Upper half
|
||||
self._draw_text_with_outline(draw, away_text, (x, team_text_y), font_detail, fill=away_color)
|
||||
x += away_text_w + h_pad
|
||||
|
||||
# Center block
|
||||
center_x = x
|
||||
center_mid = center_x + center_w // 2
|
||||
|
||||
# Status text (top center of center block)
|
||||
status_x = center_mid - status_w // 2
|
||||
status_y = 2
|
||||
status_color = COLOR_GREEN if game["is_live"] else COLOR_GRAY
|
||||
self._draw_text_with_outline(draw, status_text, (status_x, status_y), font_time, fill=status_color)
|
||||
|
||||
# Score (bottom center of center block, for live/final)
|
||||
if score_text:
|
||||
score_x = center_mid - score_w // 2
|
||||
score_y = height - 13
|
||||
# Upset highlighting
|
||||
if game["is_final"] and game["is_upset"] and self.highlight_upsets:
|
||||
score_color = COLOR_GOLD
|
||||
elif game["is_live"]:
|
||||
score_color = COLOR_WHITE
|
||||
else:
|
||||
score_color = COLOR_WHITE
|
||||
self._draw_text_with_outline(draw, score_text, (score_x, score_y), font_score, fill=score_color)
|
||||
|
||||
# Date for final games (below score)
|
||||
if game["is_final"] and game.get("game_date"):
|
||||
date_w = int(draw.textlength(game["game_date"], font=font_detail))
|
||||
date_x = center_mid - date_w // 2
|
||||
date_y = height - 6
|
||||
self._draw_text_with_outline(draw, game["game_date"], (date_x, date_y), font_detail, fill=COLOR_DIM)
|
||||
|
||||
x = center_x + center_w + h_pad
|
||||
|
||||
# Home team text
|
||||
is_fav_home = game["home_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||
home_color = COLOR_GOLD if is_fav_home else COLOR_WHITE
|
||||
if game["is_final"] and game["winner_side"] == "home" and self.highlight_upsets and game["is_upset"]:
|
||||
home_color = COLOR_GOLD
|
||||
self._draw_text_with_outline(draw, home_text, (x, team_text_y), font_detail, fill=home_color)
|
||||
x += home_text_w + h_pad
|
||||
|
||||
# Paste home logo
|
||||
if home_logo:
|
||||
logo_y = (height - home_logo.height) // 2
|
||||
img.paste(home_logo, (x, logo_y), home_logo)
|
||||
|
||||
return img
|
||||
|
||||
def _create_ticker_image(self) -> None:
|
||||
"""Build the full scrolling ticker image from game tiles."""
|
||||
if not self.games_data:
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
grouped = self._process_games(self.games_data)
|
||||
content_items: List[Image.Image] = []
|
||||
|
||||
# Order rounds by significance (most important first)
|
||||
sorted_rounds = sorted(grouped.keys(), key=lambda r: ROUND_ORDER.get(r, 6))
|
||||
|
||||
for rnd in sorted_rounds:
|
||||
games = grouped[rnd]
|
||||
if not games:
|
||||
continue
|
||||
|
||||
# Add round separator
|
||||
if self.show_round_logos and rnd:
|
||||
separator = self._create_round_separator(rnd)
|
||||
content_items.append(separator)
|
||||
|
||||
# Add game tiles
|
||||
for game in games:
|
||||
tile = self._create_game_tile(game)
|
||||
content_items.append(tile)
|
||||
|
||||
if not content_items:
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
if not self.scroll_helper:
|
||||
self.ticker_image = None
|
||||
return
|
||||
|
||||
gap_width = 16
|
||||
|
||||
# Use ScrollHelper to create the scrolling image
|
||||
self.ticker_image = self.scroll_helper.create_scrolling_image(
|
||||
content_items=content_items,
|
||||
item_gap=gap_width,
|
||||
element_gap=0,
|
||||
)
|
||||
|
||||
self.total_scroll_width = self.scroll_helper.total_scroll_width
|
||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||
|
||||
self.logger.info(
|
||||
f"Ticker image created: {self.ticker_image.width}px wide, "
|
||||
f"{len(self.games_data)} games, dynamic_duration={self.dynamic_duration:.0f}s"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plugin lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch and process tournament data."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
# Use shorter interval if live games detected
|
||||
interval = 60 if self._has_live_games else self.update_interval
|
||||
if current_time - self.last_update < interval:
|
||||
return
|
||||
|
||||
with self._update_lock:
|
||||
self.last_update = current_time
|
||||
|
||||
if not self._is_tournament_window():
|
||||
self.logger.debug("Outside tournament window, skipping fetch")
|
||||
self.games_data = []
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
try:
|
||||
games = self._fetch_tournament_data()
|
||||
self._has_live_games = any(g["is_live"] for g in games)
|
||||
self.games_data = games
|
||||
self._create_ticker_image()
|
||||
self.logger.info(
|
||||
f"Updated: {len(games)} games, "
|
||||
f"live={self._has_live_games}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Update error: {e}", exc_info=True)
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""Render one scroll frame."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if force_clear or self._display_start_time is None:
|
||||
self._display_start_time = time.time()
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.reset_scroll()
|
||||
self._end_reached_logged = False
|
||||
|
||||
if not self.games_data or self.ticker_image is None:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
if not self.scroll_helper:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
try:
|
||||
if self.loop or not self.scroll_helper.is_scroll_complete():
|
||||
self.scroll_helper.update_scroll_position()
|
||||
elif not self._end_reached_logged:
|
||||
self.logger.info("Scroll complete")
|
||||
self._end_reached_logged = True
|
||||
|
||||
visible = self.scroll_helper.get_visible_portion()
|
||||
if visible is None:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||
|
||||
matrix_w = self.display_manager.matrix.width
|
||||
matrix_h = self.display_manager.matrix.height
|
||||
if not hasattr(self.display_manager, "image") or self.display_manager.image is None:
|
||||
self.display_manager.image = Image.new("RGB", (matrix_w, matrix_h), COLOR_BLACK)
|
||||
self.display_manager.image.paste(visible, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
self.scroll_helper.log_frame_rate()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Display error: {e}", exc_info=True)
|
||||
self._display_fallback()
|
||||
|
||||
def _display_fallback(self) -> None:
|
||||
w = self.display_manager.matrix.width
|
||||
h = self.display_manager.matrix.height
|
||||
img = Image.new("RGB", (w, h), COLOR_BLACK)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if self._is_tournament_window():
|
||||
text = "No games"
|
||||
else:
|
||||
text = "Off-season"
|
||||
|
||||
text_w = int(draw.textlength(text, font=self.fonts["time"]))
|
||||
text_x = (w - text_w) // 2
|
||||
text_y = (h - 8) // 2
|
||||
draw.text((text_x, text_y), text, font=self.fonts["time"], fill=COLOR_GRAY)
|
||||
|
||||
# Show March Madness logo if available
|
||||
if self._march_madness_logo:
|
||||
logo_y = (h - self._march_madness_logo.height) // 2
|
||||
img.paste(self._march_madness_logo, (2, logo_y), self._march_madness_logo)
|
||||
|
||||
self.display_manager.image = img
|
||||
self.display_manager.update_display()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Duration / cycle management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
current_time = time.time()
|
||||
if self._cached_dynamic_duration is not None:
|
||||
cache_age = current_time - self._duration_cache_time
|
||||
if cache_age < 5.0:
|
||||
return self._cached_dynamic_duration
|
||||
|
||||
self._cached_dynamic_duration = self.dynamic_duration
|
||||
self._duration_cache_time = current_time
|
||||
return self.dynamic_duration
|
||||
|
||||
def supports_dynamic_duration(self) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
return self.dynamic_duration_enabled
|
||||
|
||||
def is_cycle_complete(self) -> bool:
|
||||
if not self.supports_dynamic_duration():
|
||||
return True
|
||||
if self._display_start_time is not None and self.dynamic_duration > 0:
|
||||
elapsed = time.time() - self._display_start_time
|
||||
if elapsed >= self.dynamic_duration:
|
||||
return True
|
||||
if not self.loop and self.scroll_helper and self.scroll_helper.is_scroll_complete():
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset_cycle_state(self) -> None:
|
||||
super().reset_cycle_state()
|
||||
self._display_start_time = None
|
||||
self._end_reached_logged = False
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.reset_scroll()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Vegas mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_vegas_content(self):
|
||||
if not self.games_data:
|
||||
return None
|
||||
tiles = []
|
||||
for game in self.games_data:
|
||||
tiles.append(self._create_game_tile(game))
|
||||
return tiles if tiles else None
|
||||
|
||||
def get_vegas_content_type(self) -> str:
|
||||
return "multi"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Info / cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_info(self) -> Dict:
|
||||
info = super().get_info()
|
||||
info["total_games"] = len(self.games_data)
|
||||
info["has_live_games"] = self._has_live_games
|
||||
info["dynamic_duration"] = self.dynamic_duration
|
||||
info["tournament_window"] = self._is_tournament_window()
|
||||
return info
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.games_data = []
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
self._team_logo_cache.clear()
|
||||
if self.session:
|
||||
self.session.close()
|
||||
self.session = None
|
||||
super().cleanup()
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"id": "march-madness",
|
||||
"name": "March Madness",
|
||||
"version": "1.0.0",
|
||||
"description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting",
|
||||
"author": "ChuckBuilds",
|
||||
"category": "sports",
|
||||
"tags": [
|
||||
"ncaa",
|
||||
"basketball",
|
||||
"march-madness",
|
||||
"tournament",
|
||||
"bracket",
|
||||
"scrolling"
|
||||
],
|
||||
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
||||
"branch": "main",
|
||||
"plugin_path": "plugins/march-madness",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"ledmatrix_min": "2.0.0",
|
||||
"released": "2026-02-16"
|
||||
}
|
||||
],
|
||||
"stars": 0,
|
||||
"downloads": 0,
|
||||
"last_updated": "2026-02-16",
|
||||
"verified": true,
|
||||
"screenshot": "",
|
||||
"display_modes": [
|
||||
"march_madness"
|
||||
],
|
||||
"dependencies": {},
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "MarchMadnessPlugin"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
requests>=2.33.0
|
||||
urllib3>=2.6.3
|
||||
Pillow>=12.2.0
|
||||
pytz>=2022.1
|
||||
numpy>=1.24.0
|
||||
@@ -110,9 +110,10 @@ class DisplayManager:
|
||||
options.rp1_rio = runtime_config.get('rp1_rio')
|
||||
else:
|
||||
logger.warning(
|
||||
"rp1_rio is set in config but the current RGBMatrixOptions "
|
||||
"implementation does not support it (RGBMatrixEmulator or older "
|
||||
"library version) — value will be ignored"
|
||||
"rp1_rio is set in config but the installed rgbmatrix library does "
|
||||
"not support it — the library was likely built without Pi 5 RP1 "
|
||||
"support (mmap to 0x3f000000 instead of RP1 chip). "
|
||||
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
||||
)
|
||||
|
||||
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import stat
|
||||
import subprocess
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
import tempfile
|
||||
import requests
|
||||
@@ -100,6 +101,10 @@ class PluginStoreManager:
|
||||
# handlers. Bumping the cached-entry timestamp on failure serves
|
||||
# the stale payload cheaply until the backoff expires.
|
||||
self._failure_backoff_seconds = 60
|
||||
# Prevents concurrent callers from each firing a network request when
|
||||
# the registry cache expires. Only one thread fetches; others wait and
|
||||
# then get the result from the warm cache (double-checked locking).
|
||||
self._registry_fetch_lock = threading.Lock()
|
||||
|
||||
# Ensure plugins directory exists
|
||||
self.plugins_dir.mkdir(exist_ok=True)
|
||||
@@ -575,41 +580,50 @@ class PluginStoreManager:
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
|
||||
try:
|
||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
self.registry_cache = response.json()
|
||||
self.registry_cache_time = current_time
|
||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||
return self.registry_cache
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Error fetching registry: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
# Prefer stale cache over an empty list so the plugin list UI
|
||||
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||
# registry_cache_time into a short backoff window so the next
|
||||
# request serves the stale payload cheaply instead of
|
||||
# re-hitting the network on every request (matches the
|
||||
# pattern used by github_cache / commit_info_cache).
|
||||
if self.registry_cache:
|
||||
self.logger.warning("Falling back to stale registry cache")
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
with self._registry_fetch_lock:
|
||||
# Re-check inside the lock — a concurrent caller that was waiting
|
||||
# may have already populated the cache while we blocked.
|
||||
current_time = time.time()
|
||||
if (self.registry_cache and self.registry_cache_time and
|
||||
not force_refresh and
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
if self.registry_cache:
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
|
||||
try:
|
||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
self.registry_cache = response.json()
|
||||
self.registry_cache_time = current_time
|
||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Error fetching registry: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
# Prefer stale cache over an empty list so the plugin list UI
|
||||
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||
# registry_cache_time into a short backoff window so the next
|
||||
# request serves the stale payload cheaply instead of
|
||||
# re-hitting the network on every request (matches the
|
||||
# pattern used by github_cache / commit_info_cache).
|
||||
if self.registry_cache:
|
||||
self.logger.warning("Falling back to stale registry cache")
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
if self.registry_cache:
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
|
||||
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
||||
"""
|
||||
|
||||
342
test/test_api_extractors.py
Normal file
342
test/test_api_extractors.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
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"
|
||||
299
test/test_background_data_service.py
Normal file
299
test/test_background_data_service.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Tests for src/background_data_service.py
|
||||
|
||||
Covers BackgroundDataService: submit_fetch_request, get_result,
|
||||
is_request_complete, get_request_status, cancel_request, get_statistics,
|
||||
_cleanup_completed_requests, shutdown, and get_background_service singleton.
|
||||
"""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, Mock
|
||||
from concurrent.futures import Future
|
||||
|
||||
from src.background_data_service import (
|
||||
BackgroundDataService,
|
||||
FetchStatus,
|
||||
FetchResult,
|
||||
FetchRequest,
|
||||
get_background_service,
|
||||
shutdown_background_service,
|
||||
)
|
||||
import src.background_data_service as bds_module
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_global_service():
|
||||
"""Ensure each test starts with no global singleton."""
|
||||
shutdown_background_service()
|
||||
yield
|
||||
shutdown_background_service()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cache_manager():
|
||||
m = MagicMock()
|
||||
m.get.return_value = None
|
||||
m.set.return_value = None
|
||||
m.generate_sport_cache_key.return_value = "test_key"
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(mock_cache_manager):
|
||||
svc = BackgroundDataService(mock_cache_manager, max_workers=2, request_timeout=5)
|
||||
yield svc
|
||||
svc.shutdown(wait=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInitialisation:
|
||||
def test_stats_zeroed(self, service):
|
||||
stats = service.get_statistics()
|
||||
assert stats["total_requests"] == 0
|
||||
assert stats["completed_requests"] == 0
|
||||
assert stats["failed_requests"] == 0
|
||||
|
||||
def test_no_active_requests(self, service):
|
||||
assert len(service.active_requests) == 0
|
||||
|
||||
def test_not_shutdown(self, service):
|
||||
assert service._shutdown is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache hit path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCacheHit:
|
||||
def test_cache_hit_returns_request_id(self, service, mock_cache_manager):
|
||||
mock_cache_manager.get.return_value = {"events": [{"id": "1"}]}
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nfl", year=2024,
|
||||
url="https://example.com/nfl",
|
||||
cache_key="nfl_key",
|
||||
)
|
||||
assert req_id is not None
|
||||
# Request should be immediately complete due to cache hit
|
||||
result = service.get_result(req_id)
|
||||
assert result is not None
|
||||
assert result.success is True
|
||||
assert result.cached is True
|
||||
|
||||
def test_cache_hit_increments_stat(self, service, mock_cache_manager):
|
||||
mock_cache_manager.get.return_value = {"events": []}
|
||||
service.submit_fetch_request(sport="nba", year=2024, url="https://x.com", cache_key="k")
|
||||
stats = service.get_statistics()
|
||||
assert stats["cached_hits"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Actual fetch path (mocked HTTP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFetchPath:
|
||||
def _valid_payload(self) -> dict:
|
||||
return {"events": [{"id": "g1"}, {"id": "g2"}]}
|
||||
|
||||
def test_successful_fetch_completes(self, service, mock_cache_manager):
|
||||
mock_resp = Mock()
|
||||
mock_resp.json.return_value = self._valid_payload()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(service.session, "get", return_value=mock_resp):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nfl", year=2024,
|
||||
url="https://example.com/nfl",
|
||||
cache_key="nfl_test",
|
||||
)
|
||||
# Wait for the background thread
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
result = service.get_result(req_id)
|
||||
assert result is not None
|
||||
assert result.success is True
|
||||
assert result.data == self._valid_payload()
|
||||
|
||||
def test_failed_fetch_records_error(self, service, mock_cache_manager):
|
||||
with patch.object(service.session, "get", side_effect=Exception("network error")):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nba", year=2024,
|
||||
url="https://example.com/nba",
|
||||
cache_key="nba_test",
|
||||
max_retries=0,
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
result = service.get_result(req_id)
|
||||
assert result is not None
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
|
||||
def test_cache_miss_increments_stat(self, service, mock_cache_manager):
|
||||
mock_resp = Mock()
|
||||
mock_resp.json.return_value = self._valid_payload()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(service.session, "get", return_value=mock_resp):
|
||||
service.submit_fetch_request(
|
||||
sport="nfl", year=2024, url="https://x.com", cache_key="new_key",
|
||||
)
|
||||
stats = service.get_statistics()
|
||||
assert stats["cache_misses"] == 1
|
||||
|
||||
def test_callback_called_on_success(self, service, mock_cache_manager):
|
||||
callback = Mock()
|
||||
mock_resp = Mock()
|
||||
mock_resp.json.return_value = self._valid_payload()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(service.session, "get", return_value=mock_resp):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nfl", year=2024, url="https://x.com",
|
||||
cache_key="cb_key", callback=callback, max_retries=0,
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
callback.assert_called_once()
|
||||
call_arg = callback.call_args[0][0]
|
||||
assert isinstance(call_arg, FetchResult)
|
||||
|
||||
def test_data_cached_after_successful_fetch(self, service, mock_cache_manager):
|
||||
mock_resp = Mock()
|
||||
mock_resp.json.return_value = self._valid_payload()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(service.session, "get", return_value=mock_resp):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nfl", year=2024, url="https://x.com", cache_key="cache_after_key",
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
mock_cache_manager.set.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request status / cancel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRequestStatusAndCancel:
|
||||
def test_unknown_request_status_is_none(self, service):
|
||||
assert service.get_request_status("nonexistent") is None
|
||||
|
||||
def test_cancel_active_request(self, service, mock_cache_manager):
|
||||
# Manually insert an active request
|
||||
req = FetchRequest(
|
||||
id="r1", sport="nfl", year=2024,
|
||||
cache_key="k", url="https://x.com",
|
||||
)
|
||||
req.status = FetchStatus.PENDING
|
||||
service.active_requests["r1"] = req
|
||||
result = service.cancel_request("r1")
|
||||
assert result is True
|
||||
assert "r1" not in service.active_requests
|
||||
|
||||
def test_cancel_nonexistent_request(self, service):
|
||||
assert service.cancel_request("does-not-exist") is False
|
||||
|
||||
def test_is_request_complete_false_for_active(self, service, mock_cache_manager):
|
||||
req = FetchRequest(
|
||||
id="r2", sport="mlb", year=2024,
|
||||
cache_key="k2", url="https://x.com",
|
||||
)
|
||||
service.active_requests["r2"] = req
|
||||
assert service.is_request_complete("r2") is False
|
||||
|
||||
def test_is_request_complete_true_for_done(self, service):
|
||||
result = FetchResult(request_id="r3", success=True)
|
||||
service.completed_requests["r3"] = result
|
||||
assert service.is_request_complete("r3") is True
|
||||
|
||||
def test_get_result_returns_none_for_unknown(self, service):
|
||||
assert service.get_result("unknown") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shutdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShutdown:
|
||||
def test_shutdown_sets_flag(self, service):
|
||||
service.shutdown(wait=False)
|
||||
assert service._shutdown is True
|
||||
|
||||
def test_submit_after_shutdown_raises(self, service, mock_cache_manager):
|
||||
service.shutdown(wait=False)
|
||||
with pytest.raises(RuntimeError, match="shutting down"):
|
||||
service.submit_fetch_request(
|
||||
sport="nfl", year=2024, url="https://x.com", cache_key="k"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCleanup:
|
||||
def test_cleanup_removes_old_requests(self, service):
|
||||
old_result = FetchResult(request_id="old", success=True)
|
||||
old_result.completed_at = time.time() - 7200 # 2 hours ago
|
||||
service.completed_requests["old"] = old_result
|
||||
service._last_completed_requests_cleanup = 0 # force cleanup
|
||||
removed = service._cleanup_completed_requests(force=True)
|
||||
assert removed >= 1
|
||||
assert "old" not in service.completed_requests
|
||||
|
||||
def test_cleanup_respects_interval(self, service):
|
||||
old_result = FetchResult(request_id="r", success=True)
|
||||
old_result.completed_at = time.time() - 7200
|
||||
service.completed_requests["r"] = old_result
|
||||
# Cleanup interval not passed, should skip
|
||||
service._last_completed_requests_cleanup = time.time()
|
||||
removed = service._cleanup_completed_requests(force=False)
|
||||
assert removed == 0
|
||||
|
||||
def test_size_limit_enforcement(self, service):
|
||||
service._max_completed_requests = 3
|
||||
for i in range(5):
|
||||
result = FetchResult(request_id=str(i), success=True)
|
||||
result.completed_at = time.time() - (5 - i) * 100 # oldest first
|
||||
service.completed_requests[str(i)] = result
|
||||
service._last_completed_requests_cleanup = 0
|
||||
service._cleanup_completed_requests(force=True)
|
||||
assert len(service.completed_requests) <= 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton get_background_service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetBackgroundService:
|
||||
def test_first_call_requires_cache_manager(self):
|
||||
with pytest.raises(ValueError, match="cache_manager is required"):
|
||||
get_background_service()
|
||||
|
||||
def test_creates_singleton(self, mock_cache_manager):
|
||||
svc1 = get_background_service(mock_cache_manager)
|
||||
svc2 = get_background_service()
|
||||
assert svc1 is svc2
|
||||
|
||||
def test_shutdown_clears_singleton(self, mock_cache_manager):
|
||||
get_background_service(mock_cache_manager)
|
||||
shutdown_background_service()
|
||||
with pytest.raises(ValueError):
|
||||
get_background_service()
|
||||
209
test/test_data_sources.py
Normal file
209
test/test_data_sources.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
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):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("error")):
|
||||
result = self.source.fetch_standings("football", "nfl")
|
||||
assert result == {}
|
||||
|
||||
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 == {}
|
||||
317
test/test_game_helper.py
Normal file
317
test/test_game_helper.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Tests for src/common/game_helper.py
|
||||
|
||||
Covers GameHelper: extract_game_details, filter_*, sort_games_by_time,
|
||||
process_games, get_game_summary, and all private helpers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from src.common.game_helper import GameHelper
|
||||
|
||||
|
||||
def _make_logger() -> logging.Logger:
|
||||
return logging.getLogger("test_game_helper")
|
||||
|
||||
|
||||
def _make_espn_event(
|
||||
state: str = "in",
|
||||
home_abbr: str = "LAL",
|
||||
away_abbr: str = "BOS",
|
||||
home_score: str = "105",
|
||||
away_score: str = "98",
|
||||
date_str: str = "2024-01-15T20:00:00Z",
|
||||
period: int = 4,
|
||||
status_name: str = "STATUS_IN_PROGRESS",
|
||||
home_record: str = "30-10",
|
||||
away_record: str = "25-15",
|
||||
event_id: str = "game-1",
|
||||
) -> dict:
|
||||
return {
|
||||
"id": event_id,
|
||||
"date": date_str,
|
||||
"competitions": [
|
||||
{
|
||||
"status": {
|
||||
"type": {
|
||||
"state": state,
|
||||
"shortDetail": "Q4 2:30",
|
||||
"name": status_name,
|
||||
},
|
||||
"period": period,
|
||||
"displayClock": "2:30",
|
||||
},
|
||||
"competitors": [
|
||||
{
|
||||
"homeAway": "home",
|
||||
"id": "h1",
|
||||
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
|
||||
"score": home_score,
|
||||
"records": [{"summary": home_record}],
|
||||
},
|
||||
{
|
||||
"homeAway": "away",
|
||||
"id": "a1",
|
||||
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
|
||||
"score": away_score,
|
||||
"records": [{"summary": away_record}],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def helper():
|
||||
return GameHelper(timezone_str="UTC", logger=_make_logger())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_game_details
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGameDetails:
|
||||
def test_live_game(self, helper):
|
||||
event = _make_espn_event(state="in")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result is not None
|
||||
assert result["is_live"] is True
|
||||
assert result["is_final"] is False
|
||||
assert result["is_upcoming"] is False
|
||||
|
||||
def test_final_game(self, helper):
|
||||
event = _make_espn_event(state="post")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["is_final"] is True
|
||||
|
||||
def test_upcoming_game(self, helper):
|
||||
event = _make_espn_event(state="pre")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["is_upcoming"] is True
|
||||
|
||||
def test_halftime_detection(self, helper):
|
||||
event = _make_espn_event(state="halftime", status_name="STATUS_HALFTIME")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["is_halftime"] is True
|
||||
|
||||
def test_basic_fields_present(self, helper):
|
||||
event = _make_espn_event()
|
||||
result = helper.extract_game_details(event)
|
||||
for key in ("id", "home_abbr", "away_abbr", "home_score", "away_score",
|
||||
"home_record", "away_record", "start_time_utc"):
|
||||
assert key in result
|
||||
|
||||
def test_team_abbreviations(self, helper):
|
||||
event = _make_espn_event(home_abbr="MIA", away_abbr="PHX")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["home_abbr"] == "MIA"
|
||||
assert result["away_abbr"] == "PHX"
|
||||
|
||||
def test_scores_as_strings(self, helper):
|
||||
event = _make_espn_event(home_score="110", away_score="99")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["home_score"] == "110"
|
||||
assert result["away_score"] == "99"
|
||||
|
||||
def test_returns_none_on_empty(self, helper):
|
||||
assert helper.extract_game_details({}) is None
|
||||
assert helper.extract_game_details(None) is None
|
||||
|
||||
def test_returns_none_when_no_competitors(self, helper):
|
||||
event = _make_espn_event()
|
||||
event["competitions"][0]["competitors"] = []
|
||||
assert helper.extract_game_details(event) is None
|
||||
|
||||
def test_date_z_suffix_parsed(self, helper):
|
||||
event = _make_espn_event(date_str="2024-06-01T19:30:00Z")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["start_time_utc"] is not None
|
||||
assert result["start_time_utc"].tzinfo is not None
|
||||
|
||||
def test_zero_zero_record_suppressed(self, helper):
|
||||
event = _make_espn_event(home_record="0-0", away_record="0-0-0")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["home_record"] == ""
|
||||
assert result["away_record"] == ""
|
||||
|
||||
def test_basketball_sport_fields(self, helper):
|
||||
event = _make_espn_event(period=3)
|
||||
result = helper.extract_game_details(event, sport="basketball")
|
||||
assert result["period_text"] == "Q3"
|
||||
assert "clock" in result
|
||||
|
||||
def test_basketball_overtime_period(self, helper):
|
||||
event = _make_espn_event(period=5)
|
||||
result = helper.extract_game_details(event, sport="basketball")
|
||||
assert result["period_text"] == "OT1"
|
||||
|
||||
def test_football_sport_fields(self, helper):
|
||||
event = _make_espn_event(period=2)
|
||||
result = helper.extract_game_details(event, sport="football")
|
||||
assert result["period_text"] == "Q2"
|
||||
|
||||
def test_hockey_sport_fields_period_1(self, helper):
|
||||
event = _make_espn_event(period=1)
|
||||
result = helper.extract_game_details(event, sport="hockey")
|
||||
assert result["period_text"] == "P1"
|
||||
|
||||
def test_hockey_sport_fields_ot(self, helper):
|
||||
event = _make_espn_event(period=4)
|
||||
result = helper.extract_game_details(event, sport="hockey")
|
||||
assert result["period_text"] == "OT1"
|
||||
|
||||
def test_baseball_sport_fields(self, helper):
|
||||
event = _make_espn_event(period=7)
|
||||
result = helper.extract_game_details(event, sport="baseball")
|
||||
assert result["period_text"] == "INN 7"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilterMethods:
|
||||
def _make_games(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
return [
|
||||
{"is_live": True, "is_final": False, "is_upcoming": False, "home_abbr": "LAL", "away_abbr": "BOS", "start_time_utc": now},
|
||||
{"is_live": False, "is_final": True, "is_upcoming": False, "home_abbr": "MIA", "away_abbr": "PHX", "start_time_utc": now - timedelta(hours=3)},
|
||||
{"is_live": False, "is_final": False, "is_upcoming": True, "home_abbr": "DAL", "away_abbr": "CHI", "start_time_utc": now + timedelta(hours=2)},
|
||||
]
|
||||
|
||||
def test_filter_live_games(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_live_games(games)
|
||||
assert len(result) == 1
|
||||
assert result[0]["home_abbr"] == "LAL"
|
||||
|
||||
def test_filter_final_games(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_final_games(games)
|
||||
assert len(result) == 1
|
||||
assert result[0]["home_abbr"] == "MIA"
|
||||
|
||||
def test_filter_upcoming_games(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_upcoming_games(games)
|
||||
assert len(result) == 1
|
||||
assert result[0]["home_abbr"] == "DAL"
|
||||
|
||||
def test_filter_favorite_teams_match(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_favorite_teams(games, ["LAL"])
|
||||
assert len(result) == 1
|
||||
assert result[0]["home_abbr"] == "LAL"
|
||||
|
||||
def test_filter_favorite_teams_empty_list_returns_all(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_favorite_teams(games, [])
|
||||
assert len(result) == 3
|
||||
|
||||
def test_filter_favorite_teams_away_match(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_favorite_teams(games, ["BOS"])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_filter_recent_games_within_window(self, helper):
|
||||
now = datetime.now(timezone.utc)
|
||||
games = [
|
||||
{"start_time_utc": now - timedelta(days=2), "is_final": True},
|
||||
{"start_time_utc": now - timedelta(days=10), "is_final": True},
|
||||
]
|
||||
result = helper.filter_recent_games(games, days_back=7)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_filter_recent_games_all_within(self, helper):
|
||||
now = datetime.now(timezone.utc)
|
||||
games = [
|
||||
{"start_time_utc": now - timedelta(days=1)},
|
||||
{"start_time_utc": now - timedelta(days=3)},
|
||||
]
|
||||
result = helper.filter_recent_games(games, days_back=7)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_sort_games_ascending(self, helper):
|
||||
now = datetime.now(timezone.utc)
|
||||
games = [
|
||||
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
|
||||
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
|
||||
]
|
||||
result = helper.sort_games_by_time(games)
|
||||
assert result[0]["id"] == "early"
|
||||
|
||||
def test_sort_games_descending(self, helper):
|
||||
now = datetime.now(timezone.utc)
|
||||
games = [
|
||||
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
|
||||
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
|
||||
]
|
||||
result = helper.sort_games_by_time(games, reverse=True)
|
||||
assert result[0]["id"] == "late"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_games
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProcessGames:
|
||||
def test_processes_valid_events(self, helper):
|
||||
events = [
|
||||
_make_espn_event(event_id="1"),
|
||||
_make_espn_event(event_id="2"),
|
||||
]
|
||||
result = helper.process_games(events)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_skips_invalid_events(self, helper):
|
||||
events = [
|
||||
_make_espn_event(event_id="1"),
|
||||
{}, # invalid
|
||||
]
|
||||
result = helper.process_games(events)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_events(self, helper):
|
||||
assert helper.process_games([]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_game_summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetGameSummary:
|
||||
def test_live_summary(self, helper):
|
||||
game = {
|
||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
||||
"home_score": "105", "away_score": "98",
|
||||
"status_text": "Q4 2:30",
|
||||
"is_live": True, "is_final": False,
|
||||
}
|
||||
summary = helper.get_game_summary(game)
|
||||
assert "BOS" in summary
|
||||
assert "LAL" in summary
|
||||
assert "98" in summary
|
||||
assert "105" in summary
|
||||
|
||||
def test_final_summary(self, helper):
|
||||
game = {
|
||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
||||
"home_score": "110", "away_score": "102",
|
||||
"status_text": "Final",
|
||||
"is_live": False, "is_final": True,
|
||||
}
|
||||
summary = helper.get_game_summary(game)
|
||||
assert "Final" in summary
|
||||
|
||||
def test_upcoming_summary(self, helper):
|
||||
game = {
|
||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
||||
"home_score": "0", "away_score": "0",
|
||||
"status_text": "7:30 PM",
|
||||
"is_live": False, "is_final": False,
|
||||
}
|
||||
summary = helper.get_game_summary(game)
|
||||
assert "7:30 PM" in summary
|
||||
307
test/test_health_monitor.py
Normal file
307
test/test_health_monitor.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Tests for src/plugin_system/health_monitor.py
|
||||
|
||||
Covers PluginHealthMonitor: get_plugin_health_status, get_plugin_health_metrics,
|
||||
get_all_plugin_health, _get_recovery_suggestions, start/stop_monitoring,
|
||||
register_health_check.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
from src.plugin_system.health_monitor import (
|
||||
PluginHealthMonitor,
|
||||
HealthStatus,
|
||||
HealthMetrics,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_health_tracker(
|
||||
summary: dict | None = None,
|
||||
all_summaries: dict | None = None,
|
||||
):
|
||||
"""Return a mock PluginHealthTracker."""
|
||||
tracker = MagicMock()
|
||||
tracker.get_health_summary.return_value = summary
|
||||
tracker.get_all_health_summaries.return_value = all_summaries or {}
|
||||
return tracker
|
||||
|
||||
|
||||
def _healthy_summary() -> dict:
|
||||
return {
|
||||
"success_rate": 100.0,
|
||||
"circuit_state": "closed",
|
||||
"consecutive_failures": 0,
|
||||
"total_failures": 0,
|
||||
"total_successes": 50,
|
||||
"last_success_time": datetime.now().isoformat(),
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
|
||||
def _degraded_summary() -> dict:
|
||||
return {
|
||||
"success_rate": 40.0, # 60% error rate
|
||||
"circuit_state": "closed",
|
||||
"consecutive_failures": 3,
|
||||
"total_failures": 6,
|
||||
"total_successes": 4,
|
||||
"last_success_time": None,
|
||||
"last_error": "timeout occurred",
|
||||
}
|
||||
|
||||
|
||||
def _unhealthy_summary() -> dict:
|
||||
return {
|
||||
"success_rate": 10.0, # 90% error rate
|
||||
"circuit_state": "open",
|
||||
"consecutive_failures": 10,
|
||||
"total_failures": 9,
|
||||
"total_successes": 1,
|
||||
"last_success_time": None,
|
||||
"last_error": "ImportError: missing module",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def monitor():
|
||||
tracker = _make_health_tracker(_healthy_summary())
|
||||
return PluginHealthMonitor(health_tracker=tracker)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_plugin_health_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetPluginHealthStatus:
|
||||
def test_healthy_status(self):
|
||||
tracker = _make_health_tracker(_healthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
status = monitor.get_plugin_health_status("plugin_a")
|
||||
assert status == HealthStatus.HEALTHY
|
||||
|
||||
def test_degraded_status(self):
|
||||
tracker = _make_health_tracker(_degraded_summary())
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
status = monitor.get_plugin_health_status("plugin_b")
|
||||
assert status == HealthStatus.DEGRADED
|
||||
|
||||
def test_unhealthy_status(self):
|
||||
tracker = _make_health_tracker(_unhealthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
status = monitor.get_plugin_health_status("plugin_c")
|
||||
assert status == HealthStatus.UNHEALTHY
|
||||
|
||||
def test_open_circuit_breaker_is_unhealthy(self):
|
||||
summary = _healthy_summary()
|
||||
summary["circuit_state"] = "open"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
status = monitor.get_plugin_health_status("plugin_d")
|
||||
assert status == HealthStatus.UNHEALTHY
|
||||
|
||||
def test_unknown_when_no_tracker(self):
|
||||
monitor = PluginHealthMonitor(health_tracker=None)
|
||||
status = monitor.get_plugin_health_status("plugin_e")
|
||||
assert status == HealthStatus.UNKNOWN
|
||||
|
||||
def test_unknown_when_no_summary(self):
|
||||
tracker = _make_health_tracker(None)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
status = monitor.get_plugin_health_status("plugin_f")
|
||||
assert status == HealthStatus.UNKNOWN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_plugin_health_metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetPluginHealthMetrics:
|
||||
def test_healthy_metrics(self):
|
||||
tracker = _make_health_tracker(_healthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
||||
assert isinstance(metrics, HealthMetrics)
|
||||
assert metrics.status == HealthStatus.HEALTHY
|
||||
assert metrics.success_rate == pytest.approx(1.0)
|
||||
assert metrics.error_rate == pytest.approx(0.0)
|
||||
|
||||
def test_degraded_metrics(self):
|
||||
tracker = _make_health_tracker(_degraded_summary())
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_b")
|
||||
assert metrics.status == HealthStatus.DEGRADED
|
||||
assert metrics.consecutive_failures == 3
|
||||
|
||||
def test_unhealthy_metrics(self):
|
||||
tracker = _make_health_tracker(_unhealthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_c")
|
||||
assert metrics.status == HealthStatus.UNHEALTHY
|
||||
assert metrics.circuit_breaker_state == "open"
|
||||
assert metrics.last_error is not None
|
||||
|
||||
def test_metrics_without_tracker(self):
|
||||
monitor = PluginHealthMonitor(health_tracker=None)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_d")
|
||||
assert metrics.status == HealthStatus.UNKNOWN
|
||||
assert metrics.plugin_id == "plugin_d"
|
||||
|
||||
def test_metrics_without_summary(self):
|
||||
tracker = _make_health_tracker(None)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_e")
|
||||
assert metrics.status == HealthStatus.UNKNOWN
|
||||
|
||||
def test_last_successful_update_parsed(self):
|
||||
summary = _healthy_summary()
|
||||
summary["last_success_time"] = "2024-06-01T12:00:00"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
||||
assert metrics.last_successful_update is not None
|
||||
assert isinstance(metrics.last_successful_update, datetime)
|
||||
|
||||
def test_invalid_last_success_time_handled(self):
|
||||
summary = _healthy_summary()
|
||||
summary["last_success_time"] = "not-a-date"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
# Should not raise
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
||||
assert metrics.last_successful_update is None
|
||||
|
||||
def test_total_successes_failures(self):
|
||||
tracker = _make_health_tracker(_degraded_summary())
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_b")
|
||||
assert metrics.total_failures == 6
|
||||
assert metrics.total_successes == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_all_plugin_health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetAllPluginHealth:
|
||||
def test_returns_empty_without_tracker(self):
|
||||
monitor = PluginHealthMonitor(health_tracker=None)
|
||||
result = monitor.get_all_plugin_health()
|
||||
assert result == {}
|
||||
|
||||
def test_returns_metrics_for_each_plugin(self):
|
||||
all_summaries = {
|
||||
"plugin_a": _healthy_summary(),
|
||||
"plugin_b": _degraded_summary(),
|
||||
}
|
||||
tracker = MagicMock()
|
||||
tracker.get_all_health_summaries.return_value = all_summaries
|
||||
tracker.get_health_summary.side_effect = lambda pid: all_summaries.get(pid)
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
result = monitor.get_all_plugin_health()
|
||||
assert "plugin_a" in result
|
||||
assert "plugin_b" in result
|
||||
assert isinstance(result["plugin_a"], HealthMetrics)
|
||||
|
||||
def test_returns_empty_when_no_summaries(self):
|
||||
tracker = _make_health_tracker(all_summaries={})
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
result = monitor.get_all_plugin_health()
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_recovery_suggestions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetRecoverySuggestions:
|
||||
def test_healthy_plugin_suggestion(self):
|
||||
tracker = _make_health_tracker(_healthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
suggestions = monitor._get_recovery_suggestions("p", _healthy_summary(), HealthStatus.HEALTHY)
|
||||
assert any("healthy" in s.lower() for s in suggestions)
|
||||
|
||||
def test_unhealthy_suggestions(self):
|
||||
tracker = _make_health_tracker(_unhealthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", _unhealthy_summary(), HealthStatus.UNHEALTHY)
|
||||
assert len(suggestions) > 0
|
||||
assert any("unhealthy" in s.lower() for s in suggestions)
|
||||
|
||||
def test_open_circuit_breaker_suggestion(self):
|
||||
summary = _unhealthy_summary()
|
||||
summary["circuit_state"] = "open"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
||||
assert any("circuit" in s.lower() for s in suggestions)
|
||||
|
||||
def test_timeout_error_suggestion(self):
|
||||
summary = _degraded_summary()
|
||||
summary["last_error"] = "connection timeout occurred"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.DEGRADED)
|
||||
assert any("timeout" in s.lower() for s in suggestions)
|
||||
|
||||
def test_import_error_suggestion(self):
|
||||
summary = _unhealthy_summary()
|
||||
summary["last_error"] = "ImportError: missing module"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
||||
assert any("dependencies" in s.lower() or "import" in s.lower() or "missing" in s.lower()
|
||||
for s in suggestions)
|
||||
|
||||
def test_permission_error_suggestion(self):
|
||||
summary = _unhealthy_summary()
|
||||
summary["last_error"] = "permission denied to access resource"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
||||
assert any("permission" in s.lower() for s in suggestions)
|
||||
|
||||
def test_degraded_suggestions_include_error_rate(self):
|
||||
tracker = _make_health_tracker(_degraded_summary())
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", _degraded_summary(), HealthStatus.DEGRADED)
|
||||
assert any("%" in s for s in suggestions)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start / stop monitoring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMonitorLifecycle:
|
||||
def test_start_monitoring(self, monitor):
|
||||
monitor.start_monitoring()
|
||||
try:
|
||||
assert monitor._monitor_thread is not None
|
||||
assert monitor._monitor_thread.is_alive()
|
||||
finally:
|
||||
monitor.stop_monitoring()
|
||||
|
||||
def test_stop_monitoring(self, monitor):
|
||||
monitor.start_monitoring()
|
||||
monitor.stop_monitoring()
|
||||
# Thread should no longer be alive
|
||||
assert not monitor._monitor_thread.is_alive()
|
||||
|
||||
def test_double_start_no_duplicate_threads(self, monitor):
|
||||
monitor.start_monitoring()
|
||||
try:
|
||||
thread1 = monitor._monitor_thread
|
||||
monitor.start_monitoring() # should be idempotent
|
||||
assert monitor._monitor_thread is thread1
|
||||
finally:
|
||||
monitor.stop_monitoring()
|
||||
|
||||
def test_register_health_check(self, monitor):
|
||||
callback = MagicMock()
|
||||
monitor.register_health_check(callback)
|
||||
assert callback in monitor._health_check_callbacks
|
||||
129
test/test_logo_downloader.py
Normal file
129
test/test_logo_downloader.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for src/logo_downloader.py
|
||||
|
||||
Focuses on the pure/static methods that don't require network calls:
|
||||
normalize_abbreviation, get_logo_filename_variations, get_logo_directory,
|
||||
ensure_logo_directory, and the download_missing_logo function path
|
||||
(with HTTP mocked).
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, Mock, MagicMock
|
||||
|
||||
from src.logo_downloader import LogoDownloader
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_abbreviation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizeAbbreviation:
|
||||
def test_basic_lowercase(self):
|
||||
result = LogoDownloader.normalize_abbreviation("lal")
|
||||
assert result == "LAL"
|
||||
|
||||
def test_uppercases(self):
|
||||
result = LogoDownloader.normalize_abbreviation("bos")
|
||||
assert result == "BOS"
|
||||
|
||||
def test_ampersand_replaced(self):
|
||||
result = LogoDownloader.normalize_abbreviation("TA&M")
|
||||
assert "&" not in result
|
||||
assert "AND" in result
|
||||
|
||||
def test_forward_slash_replaced(self):
|
||||
result = LogoDownloader.normalize_abbreviation("A/B")
|
||||
assert "/" not in result
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
result = LogoDownloader.normalize_abbreviation("")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_logo_filename_variations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetLogoFilenameVariations:
|
||||
def test_returns_list(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("LAL")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_includes_png(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("KC")
|
||||
filenames = " ".join(result)
|
||||
assert ".png" in filenames
|
||||
|
||||
def test_includes_original(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("LAL")
|
||||
assert any("LAL" in f for f in result)
|
||||
|
||||
def test_ampersand_variation(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("TA&M")
|
||||
# Should produce at least the normalized version
|
||||
assert len(result) > 0
|
||||
|
||||
def test_empty_string_no_crash(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("")
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_logo_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetLogoDirectory:
|
||||
def test_known_sport_returns_string(self):
|
||||
downloader = LogoDownloader()
|
||||
result = downloader.get_logo_directory("nfl")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_known_sport_nba(self):
|
||||
downloader = LogoDownloader()
|
||||
result = downloader.get_logo_directory("nba")
|
||||
assert "nba" in result.lower() or "sports" in result.lower()
|
||||
|
||||
def test_unknown_sport_returns_string(self):
|
||||
downloader = LogoDownloader()
|
||||
result = downloader.get_logo_directory("unknown_sport_xyz")
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure_logo_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEnsureLogoDirectory:
|
||||
def test_creates_writable_directory(self, tmp_path):
|
||||
downloader = LogoDownloader()
|
||||
test_dir = str(tmp_path / "logos" / "nfl")
|
||||
result = downloader.ensure_logo_directory(test_dir)
|
||||
assert result is True
|
||||
assert Path(test_dir).is_dir()
|
||||
|
||||
def test_existing_writable_directory(self, tmp_path):
|
||||
downloader = LogoDownloader()
|
||||
test_dir = str(tmp_path)
|
||||
result = downloader.ensure_logo_directory(test_dir)
|
||||
assert result is True
|
||||
|
||||
def test_returns_false_when_write_test_fails(self, tmp_path):
|
||||
"""Simulate a directory that exists but raises PermissionError on write."""
|
||||
downloader = LogoDownloader()
|
||||
test_dir = str(tmp_path / "logos")
|
||||
|
||||
import builtins
|
||||
original_open = builtins.open
|
||||
|
||||
def mock_open(path, *args, **kwargs):
|
||||
if ".write_test" in str(path):
|
||||
raise PermissionError("no write access")
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
with patch("builtins.open", side_effect=mock_open):
|
||||
result = downloader.ensure_logo_directory(test_dir)
|
||||
assert result is False
|
||||
317
test/test_scroll_helper.py
Normal file
317
test/test_scroll_helper.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Tests for src/common/scroll_helper.py
|
||||
|
||||
Covers ScrollHelper: create_scrolling_image, update_scroll_position,
|
||||
get_visible_portion, calculate_dynamic_duration, set_* methods,
|
||||
reset_scroll, clear_cache, get_scroll_info.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
|
||||
|
||||
DISPLAY_W = 64
|
||||
DISPLAY_H = 32
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def helper():
|
||||
return ScrollHelper(display_width=DISPLAY_W, display_height=DISPLAY_H)
|
||||
|
||||
|
||||
def _make_image(width: int = 64, height: int = 32, color=(255, 0, 0)) -> Image.Image:
|
||||
img = Image.new("RGB", (width, height), color)
|
||||
return img
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# __init__ / initial state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScrollHelperInit:
|
||||
def test_initial_scroll_position(self, helper):
|
||||
assert helper.scroll_position == 0.0
|
||||
|
||||
def test_initial_scroll_complete_false(self, helper):
|
||||
assert helper.scroll_complete is False
|
||||
|
||||
def test_display_dimensions(self, helper):
|
||||
assert helper.display_width == DISPLAY_W
|
||||
assert helper.display_height == DISPLAY_H
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_scrolling_image
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateScrollingImage:
|
||||
def test_empty_content_returns_blank_image(self, helper):
|
||||
result = helper.create_scrolling_image([])
|
||||
assert isinstance(result, Image.Image)
|
||||
assert helper.total_scroll_width == 0
|
||||
|
||||
def test_single_item_creates_image(self, helper):
|
||||
img = _make_image(width=100)
|
||||
result = helper.create_scrolling_image([img])
|
||||
assert isinstance(result, Image.Image)
|
||||
assert result.width > DISPLAY_W # includes leading gap
|
||||
|
||||
def test_multiple_items_wider_image(self, helper):
|
||||
items = [_make_image(width=50), _make_image(width=50)]
|
||||
result = helper.create_scrolling_image(items)
|
||||
# Should be wider than two items alone
|
||||
assert result.width > 100
|
||||
|
||||
def test_scroll_position_reset(self, helper):
|
||||
helper.scroll_position = 500.0
|
||||
helper.create_scrolling_image([_make_image()])
|
||||
assert helper.scroll_position == 0.0
|
||||
|
||||
def test_cached_array_set(self, helper):
|
||||
helper.create_scrolling_image([_make_image()])
|
||||
assert helper.cached_array is not None
|
||||
|
||||
def test_scroll_complete_reset(self, helper):
|
||||
helper.scroll_complete = True
|
||||
helper.create_scrolling_image([_make_image()])
|
||||
assert helper.scroll_complete is False
|
||||
|
||||
def test_total_scroll_width_matches_image(self, helper):
|
||||
img = _make_image(width=200)
|
||||
result = helper.create_scrolling_image([img])
|
||||
assert helper.total_scroll_width == result.width
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_scrolling_image
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSetScrollingImage:
|
||||
def test_sets_cached_image(self, helper):
|
||||
img = _make_image(width=200)
|
||||
helper.set_scrolling_image(img)
|
||||
assert helper.cached_image is img
|
||||
|
||||
def test_sets_cached_array(self, helper):
|
||||
img = _make_image(width=200)
|
||||
helper.set_scrolling_image(img)
|
||||
assert helper.cached_array is not None
|
||||
|
||||
def test_scroll_width_matches_image(self, helper):
|
||||
img = _make_image(width=300)
|
||||
helper.set_scrolling_image(img)
|
||||
assert helper.total_scroll_width == 300
|
||||
|
||||
def test_none_clears_cache(self, helper):
|
||||
helper.set_scrolling_image(_make_image())
|
||||
helper.set_scrolling_image(None)
|
||||
assert helper.cached_image is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_scroll_position (time-based mode)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateScrollPosition:
|
||||
def test_position_advances_over_time(self, helper):
|
||||
helper.create_scrolling_image([_make_image(width=200)])
|
||||
helper.scroll_speed = 100.0 # 100 px/s
|
||||
helper.last_update_time = time.time() - 0.1 # pretend 100ms elapsed
|
||||
initial = helper.scroll_position
|
||||
helper.update_scroll_position()
|
||||
assert helper.scroll_position > initial
|
||||
|
||||
def test_no_advance_without_image(self, helper):
|
||||
helper.update_scroll_position() # no image, should not crash
|
||||
assert helper.scroll_position == 0.0
|
||||
|
||||
def test_zero_width_content_stays_zero(self, helper):
|
||||
helper.create_scrolling_image([]) # empty → width 0
|
||||
helper.update_scroll_position()
|
||||
assert helper.scroll_position == 0.0
|
||||
|
||||
def test_scroll_complete_clamped(self, helper):
|
||||
helper.create_scrolling_image([_make_image(width=100)])
|
||||
# Force position past the end
|
||||
helper.scroll_position = helper.total_scroll_width + 50
|
||||
helper.total_distance_scrolled = helper.total_scroll_width + 50
|
||||
helper.update_scroll_position()
|
||||
assert helper.scroll_complete is True
|
||||
assert helper.scroll_position <= helper.total_scroll_width
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_visible_portion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetVisiblePortion:
|
||||
def test_returns_none_without_image(self, helper):
|
||||
assert helper.get_visible_portion() is None
|
||||
|
||||
def test_returns_image_sized_to_display(self, helper):
|
||||
helper.create_scrolling_image([_make_image(width=200)])
|
||||
visible = helper.get_visible_portion()
|
||||
assert visible is not None
|
||||
assert visible.width == DISPLAY_W
|
||||
assert visible.height == DISPLAY_H
|
||||
|
||||
def test_different_positions_give_different_images(self, helper):
|
||||
helper.create_scrolling_image([_make_image(width=300)])
|
||||
img1 = helper.get_visible_portion()
|
||||
helper.scroll_position = 50
|
||||
img2 = helper.get_visible_portion()
|
||||
# Images should differ (colour from scrolled content)
|
||||
# Just verify both are valid PIL images with correct size
|
||||
assert img1.width == img2.width == DISPLAY_W
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reset_scroll / clear_cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResetAndClear:
|
||||
def test_reset_restores_position(self, helper):
|
||||
helper.create_scrolling_image([_make_image(width=200)])
|
||||
helper.scroll_position = 100.0
|
||||
helper.reset_scroll()
|
||||
assert helper.scroll_position == 0.0
|
||||
|
||||
def test_reset_clears_complete_flag(self, helper):
|
||||
helper.scroll_complete = True
|
||||
helper.reset_scroll()
|
||||
assert helper.scroll_complete is False
|
||||
|
||||
def test_reset_alias(self, helper):
|
||||
helper.scroll_position = 50.0
|
||||
helper.reset()
|
||||
assert helper.scroll_position == 0.0
|
||||
|
||||
def test_clear_cache(self, helper):
|
||||
helper.create_scrolling_image([_make_image()])
|
||||
helper.clear_cache()
|
||||
assert helper.cached_image is None
|
||||
assert helper.cached_array is None
|
||||
assert helper.total_scroll_width == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_dynamic_duration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateDynamicDuration:
|
||||
def test_returns_min_when_disabled(self, helper):
|
||||
helper.dynamic_duration_enabled = False
|
||||
helper.min_duration = 30
|
||||
result = helper.calculate_dynamic_duration()
|
||||
assert result == 30
|
||||
|
||||
def test_returns_min_when_no_content(self, helper):
|
||||
helper.total_scroll_width = 0
|
||||
helper.min_duration = 30
|
||||
result = helper.calculate_dynamic_duration()
|
||||
assert result == 30
|
||||
|
||||
def test_respects_min_duration(self, helper):
|
||||
helper.create_scrolling_image([_make_image(width=50)])
|
||||
helper.min_duration = 60
|
||||
helper.max_duration = 300
|
||||
helper.scroll_speed = 500.0 # very fast → very short time
|
||||
result = helper.calculate_dynamic_duration()
|
||||
assert result >= 60
|
||||
|
||||
def test_respects_max_duration(self, helper):
|
||||
helper.create_scrolling_image([_make_image(width=5000)])
|
||||
helper.min_duration = 10
|
||||
helper.max_duration = 60
|
||||
helper.scroll_speed = 1.0 # very slow → very long time
|
||||
result = helper.calculate_dynamic_duration()
|
||||
assert result <= 60
|
||||
|
||||
def test_time_based_calculation(self, helper):
|
||||
helper.create_scrolling_image([_make_image(width=200)])
|
||||
helper.scroll_speed = 100.0
|
||||
helper.min_duration = 1
|
||||
helper.max_duration = 600
|
||||
helper.frame_based_scrolling = False
|
||||
result = helper.calculate_dynamic_duration()
|
||||
assert isinstance(result, int)
|
||||
assert result > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_* configuration methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSetMethods:
|
||||
def test_set_scroll_speed_time_based(self, helper):
|
||||
helper.frame_based_scrolling = False
|
||||
helper.set_scroll_speed(50.0)
|
||||
assert helper.scroll_speed == 50.0
|
||||
|
||||
def test_set_scroll_speed_clamped_low(self, helper):
|
||||
helper.frame_based_scrolling = False
|
||||
helper.set_scroll_speed(0.0)
|
||||
assert helper.scroll_speed >= 1.0
|
||||
|
||||
def test_set_scroll_speed_clamped_high(self, helper):
|
||||
helper.frame_based_scrolling = False
|
||||
helper.set_scroll_speed(10000.0)
|
||||
assert helper.scroll_speed <= 500.0
|
||||
|
||||
def test_set_scroll_delay(self, helper):
|
||||
helper.set_scroll_delay(0.05)
|
||||
assert helper.scroll_delay == 0.05
|
||||
|
||||
def test_set_scroll_delay_clamped(self, helper):
|
||||
helper.set_scroll_delay(0.0001)
|
||||
assert helper.scroll_delay >= 0.001
|
||||
|
||||
def test_set_target_fps(self, helper):
|
||||
helper.set_target_fps(60.0)
|
||||
assert helper.target_fps == 60.0
|
||||
|
||||
def test_set_target_fps_clamped(self, helper):
|
||||
helper.set_target_fps(1000.0)
|
||||
assert helper.target_fps <= 200.0
|
||||
|
||||
def test_set_sub_pixel_scrolling(self, helper):
|
||||
helper.set_sub_pixel_scrolling(True)
|
||||
assert helper.sub_pixel_scrolling is True
|
||||
helper.set_sub_pixel_scrolling(False)
|
||||
assert helper.sub_pixel_scrolling is False
|
||||
|
||||
def test_set_frame_based_scrolling(self, helper):
|
||||
helper.set_frame_based_scrolling(True)
|
||||
assert helper.frame_based_scrolling is True
|
||||
|
||||
def test_set_dynamic_duration_settings(self, helper):
|
||||
helper.set_dynamic_duration_settings(enabled=True, min_duration=20, max_duration=120, buffer=0.2)
|
||||
assert helper.dynamic_duration_enabled is True
|
||||
assert helper.min_duration == 20
|
||||
assert helper.max_duration == 120
|
||||
assert helper.duration_buffer == pytest.approx(0.2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_scroll_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetScrollInfo:
|
||||
def test_returns_dict(self, helper):
|
||||
info = helper.get_scroll_info()
|
||||
assert isinstance(info, dict)
|
||||
|
||||
def test_required_keys(self, helper):
|
||||
info = helper.get_scroll_info()
|
||||
for key in ("scroll_position", "total_distance_scrolled", "scroll_speed",
|
||||
"scroll_complete", "dynamic_duration"):
|
||||
assert key in info
|
||||
|
||||
def test_scroll_position_reflected(self, helper):
|
||||
helper.scroll_position = 42.0
|
||||
info = helper.get_scroll_info()
|
||||
assert info["scroll_position"] == 42.0
|
||||
329
test/test_utils.py
Normal file
329
test/test_utils.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Tests for src/common/utils.py
|
||||
|
||||
Covers all pure utility functions: normalize_team_abbreviation, format_time,
|
||||
format_date, get_timezone, validate_dimensions, parse_team_abbreviation,
|
||||
format_score, format_period, is_live_game, is_final_game, is_upcoming_game,
|
||||
sanitize_filename, truncate_text, parse_boolean.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
import pytz
|
||||
|
||||
from src.common.utils import (
|
||||
normalize_team_abbreviation,
|
||||
format_time,
|
||||
format_date,
|
||||
get_timezone,
|
||||
validate_dimensions,
|
||||
parse_team_abbreviation,
|
||||
format_score,
|
||||
format_period,
|
||||
is_live_game,
|
||||
is_final_game,
|
||||
is_upcoming_game,
|
||||
sanitize_filename,
|
||||
truncate_text,
|
||||
parse_boolean,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_team_abbreviation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizeTeamAbbreviation:
|
||||
def test_basic_uppercase(self):
|
||||
assert normalize_team_abbreviation("lal") == "LAL"
|
||||
|
||||
def test_strips_spaces(self):
|
||||
assert normalize_team_abbreviation(" KC ") == "KC"
|
||||
|
||||
def test_replaces_ampersand(self):
|
||||
assert normalize_team_abbreviation("TA&M") == "TAANDM"
|
||||
|
||||
def test_removes_internal_spaces(self):
|
||||
assert normalize_team_abbreviation("A B") == "AB"
|
||||
|
||||
def test_removes_hyphens(self):
|
||||
assert normalize_team_abbreviation("A-B") == "AB"
|
||||
|
||||
def test_empty_string_returns_empty(self):
|
||||
assert normalize_team_abbreviation("") == ""
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
assert normalize_team_abbreviation(None) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_time / format_date
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatTime:
|
||||
def _utc_dt(self, hour=20, minute=30):
|
||||
return datetime(2024, 1, 15, hour, minute, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_formats_utc_to_utc(self):
|
||||
dt = self._utc_dt(20, 30)
|
||||
result = format_time(dt, timezone_str="UTC")
|
||||
# 20:30 UTC → "8:30PM" (leading zero stripped)
|
||||
assert "8:30PM" in result or "8:30 PM" in result or result != ""
|
||||
|
||||
def test_naive_datetime_treated_as_utc(self):
|
||||
dt = datetime(2024, 1, 15, 12, 0, 0) # naive
|
||||
result = format_time(dt, timezone_str="UTC")
|
||||
assert result != ""
|
||||
|
||||
def test_invalid_timezone_returns_empty(self):
|
||||
dt = self._utc_dt()
|
||||
result = format_time(dt, timezone_str="Invalid/TZ")
|
||||
assert result == ""
|
||||
|
||||
def test_eastern_timezone(self):
|
||||
dt = self._utc_dt(20, 0) # 8 PM UTC = 3 PM ET
|
||||
result = format_time(dt, timezone_str="America/New_York")
|
||||
assert result != ""
|
||||
|
||||
|
||||
class TestFormatDate:
|
||||
def test_formats_date(self):
|
||||
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
|
||||
result = format_date(dt, timezone_str="UTC")
|
||||
assert "June" in result or "15" in result
|
||||
|
||||
def test_naive_datetime(self):
|
||||
dt = datetime(2024, 3, 10, 12, 0, 0)
|
||||
result = format_date(dt, timezone_str="UTC")
|
||||
assert result != ""
|
||||
|
||||
def test_invalid_timezone_returns_empty(self):
|
||||
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
|
||||
result = format_date(dt, timezone_str="BadZone/Here")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_timezone
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetTimezone:
|
||||
def test_valid_timezone(self):
|
||||
tz = get_timezone("America/New_York")
|
||||
assert tz is not None
|
||||
|
||||
def test_utc(self):
|
||||
tz = get_timezone("UTC")
|
||||
assert tz is pytz.utc or str(tz) == "UTC"
|
||||
|
||||
def test_invalid_returns_utc(self):
|
||||
tz = get_timezone("Not/ATimezone")
|
||||
assert tz is pytz.utc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_dimensions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidateDimensions:
|
||||
def test_valid(self):
|
||||
assert validate_dimensions(64, 32) is True
|
||||
|
||||
def test_zero_width(self):
|
||||
assert validate_dimensions(0, 32) is False
|
||||
|
||||
def test_zero_height(self):
|
||||
assert validate_dimensions(64, 0) is False
|
||||
|
||||
def test_negative(self):
|
||||
assert validate_dimensions(-1, 32) is False
|
||||
|
||||
def test_too_large(self):
|
||||
assert validate_dimensions(1001, 32) is False
|
||||
|
||||
def test_max_valid(self):
|
||||
assert validate_dimensions(1000, 1000) is True
|
||||
|
||||
def test_non_integer(self):
|
||||
assert validate_dimensions("64", 32) is False # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_team_abbreviation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseTeamAbbreviation:
|
||||
def test_empty_string(self):
|
||||
assert parse_team_abbreviation("") == ""
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
assert parse_team_abbreviation(None) == ""
|
||||
|
||||
def test_extracts_uppercase(self):
|
||||
result = parse_team_abbreviation("LAL")
|
||||
assert result == "LAL"
|
||||
|
||||
def test_fallback_first_three(self):
|
||||
# text without recognisable 2-4 char uppercase block
|
||||
result = parse_team_abbreviation("ab")
|
||||
assert len(result) <= 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_score
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatScore:
|
||||
def test_format_score(self):
|
||||
assert format_score(14, 7) == "7-14"
|
||||
|
||||
def test_format_score_strings(self):
|
||||
assert format_score("21", "14") == "14-21"
|
||||
|
||||
def test_zero_zero(self):
|
||||
assert format_score(0, 0) == "0-0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_period
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatPeriod:
|
||||
def test_basketball_q1(self):
|
||||
assert format_period(1, "basketball") == "Q1"
|
||||
|
||||
def test_basketball_q4(self):
|
||||
assert format_period(4, "basketball") == "Q4"
|
||||
|
||||
def test_basketball_ot1(self):
|
||||
assert format_period(5, "basketball") == "OT1"
|
||||
|
||||
def test_basketball_ot2(self):
|
||||
assert format_period(6, "basketball") == "OT2"
|
||||
|
||||
def test_football_q1(self):
|
||||
assert format_period(1, "football") == "Q1"
|
||||
|
||||
def test_football_ot(self):
|
||||
assert format_period(5, "football") == "OT1"
|
||||
|
||||
def test_hockey_p1(self):
|
||||
assert format_period(1, "hockey") == "P1"
|
||||
|
||||
def test_hockey_p3(self):
|
||||
assert format_period(3, "hockey") == "P3"
|
||||
|
||||
def test_hockey_ot(self):
|
||||
assert format_period(4, "hockey") == "OT1"
|
||||
|
||||
def test_baseball_inning(self):
|
||||
assert format_period(7, "baseball") == "INN 7"
|
||||
|
||||
def test_unknown_sport(self):
|
||||
result = format_period(2, "unknown")
|
||||
assert "2" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_live_game / is_final_game / is_upcoming_game
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGameStatusHelpers:
|
||||
def test_is_live_game_true(self):
|
||||
assert is_live_game("In Progress") is True
|
||||
assert is_live_game("halftime") is True
|
||||
assert is_live_game("overtime") is True
|
||||
|
||||
def test_is_live_game_false(self):
|
||||
assert is_live_game("Final") is False
|
||||
assert is_live_game("Scheduled") is False
|
||||
|
||||
def test_is_final_game_true(self):
|
||||
assert is_final_game("Final") is True
|
||||
assert is_final_game("COMPLETED") is True
|
||||
|
||||
def test_is_final_game_false(self):
|
||||
assert is_final_game("In Progress") is False
|
||||
|
||||
def test_is_upcoming_game_true(self):
|
||||
assert is_upcoming_game("Scheduled") is True
|
||||
assert is_upcoming_game("upcoming") is True
|
||||
|
||||
def test_is_upcoming_game_false(self):
|
||||
assert is_upcoming_game("Final") is False
|
||||
assert is_upcoming_game("In Progress") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sanitize_filename
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSanitizeFilename:
|
||||
def test_removes_invalid_chars(self):
|
||||
result = sanitize_filename('file<>:"/\\|?*.txt')
|
||||
assert "<" not in result
|
||||
assert ">" not in result
|
||||
assert ":" not in result
|
||||
|
||||
def test_collapses_underscores(self):
|
||||
result = sanitize_filename("file___name")
|
||||
assert "__" not in result
|
||||
|
||||
def test_strips_leading_trailing(self):
|
||||
result = sanitize_filename("_file_")
|
||||
assert not result.startswith("_")
|
||||
assert not result.endswith("_")
|
||||
|
||||
def test_normal_filename_unchanged(self):
|
||||
result = sanitize_filename("my_logo")
|
||||
assert result == "my_logo"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# truncate_text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTruncateText:
|
||||
def test_no_truncation_needed(self):
|
||||
assert truncate_text("hello", 10) == "hello"
|
||||
|
||||
def test_truncation_adds_suffix(self):
|
||||
result = truncate_text("hello world", 8)
|
||||
assert result.endswith("...")
|
||||
assert len(result) == 8
|
||||
|
||||
def test_exact_length(self):
|
||||
assert truncate_text("hello", 5) == "hello"
|
||||
|
||||
def test_custom_suffix(self):
|
||||
result = truncate_text("hello world", 8, suffix="~")
|
||||
assert result.endswith("~")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_boolean
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseBoolean:
|
||||
def test_true_bool(self):
|
||||
assert parse_boolean(True) is True
|
||||
|
||||
def test_false_bool(self):
|
||||
assert parse_boolean(False) is False
|
||||
|
||||
def test_int_1(self):
|
||||
assert parse_boolean(1) is True
|
||||
|
||||
def test_int_0(self):
|
||||
assert parse_boolean(0) is False
|
||||
|
||||
def test_string_true(self):
|
||||
for val in ("true", "True", "TRUE", "1", "yes", "on", "enabled"):
|
||||
assert parse_boolean(val) is True, f"Expected True for {val!r}"
|
||||
|
||||
def test_string_false(self):
|
||||
for val in ("false", "False", "0", "no", "off", "disabled"):
|
||||
assert parse_boolean(val) is False, f"Expected False for {val!r}"
|
||||
|
||||
def test_none_returns_false(self):
|
||||
assert parse_boolean(None) is False # type: ignore[arg-type]
|
||||
310
test/test_vegas_config.py
Normal file
310
test/test_vegas_config.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
Tests for src/vegas_mode/config.py
|
||||
|
||||
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
|
||||
is_plugin_included, get_ordered_plugins, validate, update.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVegasModeConfigDefaults:
|
||||
def test_default_disabled(self):
|
||||
cfg = VegasModeConfig()
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_default_scroll_speed(self):
|
||||
cfg = VegasModeConfig()
|
||||
assert cfg.scroll_speed == 50.0
|
||||
|
||||
def test_default_separator_width(self):
|
||||
cfg = VegasModeConfig()
|
||||
assert cfg.separator_width == 32
|
||||
|
||||
def test_default_target_fps(self):
|
||||
cfg = VegasModeConfig()
|
||||
assert cfg.target_fps == 125
|
||||
|
||||
def test_default_plugin_order_empty(self):
|
||||
cfg = VegasModeConfig()
|
||||
assert cfg.plugin_order == []
|
||||
|
||||
def test_default_excluded_plugins_empty(self):
|
||||
cfg = VegasModeConfig()
|
||||
assert len(cfg.excluded_plugins) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# from_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFromConfig:
|
||||
def _cfg(self, **kwargs) -> dict:
|
||||
return {"display": {"vegas_scroll": kwargs}}
|
||||
|
||||
def test_enabled_flag(self):
|
||||
cfg = VegasModeConfig.from_config(self._cfg(enabled=True))
|
||||
assert cfg.enabled is True
|
||||
|
||||
def test_scroll_speed(self):
|
||||
cfg = VegasModeConfig.from_config(self._cfg(scroll_speed=80.0))
|
||||
assert cfg.scroll_speed == 80.0
|
||||
|
||||
def test_separator_width(self):
|
||||
cfg = VegasModeConfig.from_config(self._cfg(separator_width=16))
|
||||
assert cfg.separator_width == 16
|
||||
|
||||
def test_plugin_order(self):
|
||||
cfg = VegasModeConfig.from_config(self._cfg(plugin_order=["a", "b", "c"]))
|
||||
assert cfg.plugin_order == ["a", "b", "c"]
|
||||
|
||||
def test_excluded_plugins(self):
|
||||
cfg = VegasModeConfig.from_config(self._cfg(excluded_plugins=["x", "y"]))
|
||||
assert "x" in cfg.excluded_plugins
|
||||
assert "y" in cfg.excluded_plugins
|
||||
|
||||
def test_target_fps(self):
|
||||
cfg = VegasModeConfig.from_config(self._cfg(target_fps=60))
|
||||
assert cfg.target_fps == 60
|
||||
|
||||
def test_buffer_ahead(self):
|
||||
cfg = VegasModeConfig.from_config(self._cfg(buffer_ahead=3))
|
||||
assert cfg.buffer_ahead == 3
|
||||
|
||||
def test_min_max_cycle_duration(self):
|
||||
cfg = VegasModeConfig.from_config(self._cfg(min_cycle_duration=30, max_cycle_duration=120))
|
||||
assert cfg.min_cycle_duration == 30
|
||||
assert cfg.max_cycle_duration == 120
|
||||
|
||||
def test_defaults_when_missing(self):
|
||||
cfg = VegasModeConfig.from_config({})
|
||||
assert cfg.enabled is False
|
||||
assert cfg.scroll_speed == 50.0
|
||||
|
||||
def test_frame_based_scrolling(self):
|
||||
cfg = VegasModeConfig.from_config(self._cfg(frame_based_scrolling=False))
|
||||
assert cfg.frame_based_scrolling is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# to_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestToDict:
|
||||
def test_roundtrip(self):
|
||||
original = VegasModeConfig(
|
||||
enabled=True,
|
||||
scroll_speed=75.0,
|
||||
separator_width=24,
|
||||
plugin_order=["a", "b"],
|
||||
excluded_plugins={"z"},
|
||||
target_fps=100,
|
||||
)
|
||||
d = original.to_dict()
|
||||
assert d["enabled"] is True
|
||||
assert d["scroll_speed"] == 75.0
|
||||
assert d["separator_width"] == 24
|
||||
assert d["plugin_order"] == ["a", "b"]
|
||||
assert "z" in d["excluded_plugins"]
|
||||
assert d["target_fps"] == 100
|
||||
|
||||
def test_excluded_plugins_is_list(self):
|
||||
cfg = VegasModeConfig(excluded_plugins={"x"})
|
||||
d = cfg.to_dict()
|
||||
assert isinstance(d["excluded_plugins"], list)
|
||||
|
||||
def test_all_keys_present(self):
|
||||
d = VegasModeConfig().to_dict()
|
||||
for key in ("enabled", "scroll_speed", "separator_width", "plugin_order",
|
||||
"excluded_plugins", "target_fps", "buffer_ahead",
|
||||
"frame_based_scrolling", "scroll_delay",
|
||||
"dynamic_duration_enabled", "min_cycle_duration", "max_cycle_duration"):
|
||||
assert key in d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_frame_interval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetFrameInterval:
|
||||
def test_125fps(self):
|
||||
cfg = VegasModeConfig(target_fps=125)
|
||||
assert abs(cfg.get_frame_interval() - 1.0 / 125) < 1e-9
|
||||
|
||||
def test_60fps(self):
|
||||
cfg = VegasModeConfig(target_fps=60)
|
||||
assert abs(cfg.get_frame_interval() - 1.0 / 60) < 1e-6
|
||||
|
||||
def test_zero_fps_guarded(self):
|
||||
cfg = VegasModeConfig(target_fps=0)
|
||||
# Should not raise ZeroDivisionError (max(1, fps) guard)
|
||||
result = cfg.get_frame_interval()
|
||||
assert result == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_plugin_included
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsPluginIncluded:
|
||||
def test_not_excluded_is_included(self):
|
||||
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
|
||||
assert cfg.is_plugin_included("good_plugin") is True
|
||||
|
||||
def test_excluded_plugin_not_included(self):
|
||||
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
|
||||
assert cfg.is_plugin_included("bad_plugin") is False
|
||||
|
||||
def test_empty_exclusions_all_included(self):
|
||||
cfg = VegasModeConfig()
|
||||
assert cfg.is_plugin_included("anything") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_ordered_plugins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetOrderedPlugins:
|
||||
def test_natural_order_when_no_order_configured(self):
|
||||
cfg = VegasModeConfig()
|
||||
available = ["a", "b", "c"]
|
||||
result = cfg.get_ordered_plugins(available)
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_explicit_order_followed(self):
|
||||
cfg = VegasModeConfig(plugin_order=["c", "a", "b"])
|
||||
available = ["a", "b", "c"]
|
||||
result = cfg.get_ordered_plugins(available)
|
||||
assert result == ["c", "a", "b"]
|
||||
|
||||
def test_unavailable_plugins_skipped(self):
|
||||
cfg = VegasModeConfig(plugin_order=["c", "x", "a"])
|
||||
available = ["a", "b", "c"]
|
||||
result = cfg.get_ordered_plugins(available)
|
||||
assert "x" not in result
|
||||
assert result[:2] == ["c", "a"]
|
||||
|
||||
def test_excluded_plugins_removed(self):
|
||||
cfg = VegasModeConfig(excluded_plugins={"b"})
|
||||
available = ["a", "b", "c"]
|
||||
result = cfg.get_ordered_plugins(available)
|
||||
assert "b" not in result
|
||||
|
||||
def test_unordered_available_appended(self):
|
||||
cfg = VegasModeConfig(plugin_order=["a"])
|
||||
available = ["a", "b", "c"]
|
||||
result = cfg.get_ordered_plugins(available)
|
||||
assert result[0] == "a"
|
||||
assert "b" in result
|
||||
assert "c" in result
|
||||
|
||||
def test_empty_available(self):
|
||||
cfg = VegasModeConfig(plugin_order=["a"])
|
||||
result = cfg.get_ordered_plugins([])
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidate:
|
||||
def test_valid_config_no_errors(self):
|
||||
cfg = VegasModeConfig()
|
||||
errors = cfg.validate()
|
||||
assert errors == []
|
||||
|
||||
def test_scroll_speed_too_low(self):
|
||||
cfg = VegasModeConfig(scroll_speed=0.5)
|
||||
errors = cfg.validate()
|
||||
assert any("scroll_speed" in e for e in errors)
|
||||
|
||||
def test_scroll_speed_too_high(self):
|
||||
cfg = VegasModeConfig(scroll_speed=300.0)
|
||||
errors = cfg.validate()
|
||||
assert any("scroll_speed" in e for e in errors)
|
||||
|
||||
def test_separator_width_negative(self):
|
||||
cfg = VegasModeConfig(separator_width=-1)
|
||||
errors = cfg.validate()
|
||||
assert any("separator_width" in e for e in errors)
|
||||
|
||||
def test_separator_width_too_large(self):
|
||||
cfg = VegasModeConfig(separator_width=200)
|
||||
errors = cfg.validate()
|
||||
assert any("separator_width" in e for e in errors)
|
||||
|
||||
def test_target_fps_too_low(self):
|
||||
cfg = VegasModeConfig(target_fps=10)
|
||||
errors = cfg.validate()
|
||||
assert any("target_fps" in e for e in errors)
|
||||
|
||||
def test_target_fps_too_high(self):
|
||||
cfg = VegasModeConfig(target_fps=300)
|
||||
errors = cfg.validate()
|
||||
assert any("target_fps" in e for e in errors)
|
||||
|
||||
def test_buffer_ahead_too_low(self):
|
||||
cfg = VegasModeConfig(buffer_ahead=0)
|
||||
errors = cfg.validate()
|
||||
assert any("buffer_ahead" in e for e in errors)
|
||||
|
||||
def test_buffer_ahead_too_high(self):
|
||||
cfg = VegasModeConfig(buffer_ahead=10)
|
||||
errors = cfg.validate()
|
||||
assert any("buffer_ahead" in e for e in errors)
|
||||
|
||||
def test_multiple_errors_returned(self):
|
||||
cfg = VegasModeConfig(scroll_speed=0.1, target_fps=5)
|
||||
errors = cfg.validate()
|
||||
assert len(errors) >= 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdate:
|
||||
def _wrap(self, **kwargs) -> dict:
|
||||
return {"display": {"vegas_scroll": kwargs}}
|
||||
|
||||
def test_update_enabled(self):
|
||||
cfg = VegasModeConfig(enabled=False)
|
||||
cfg.update(self._wrap(enabled=True))
|
||||
assert cfg.enabled is True
|
||||
|
||||
def test_update_scroll_speed(self):
|
||||
cfg = VegasModeConfig(scroll_speed=50.0)
|
||||
cfg.update(self._wrap(scroll_speed=90.0))
|
||||
assert cfg.scroll_speed == 90.0
|
||||
|
||||
def test_update_separator_width(self):
|
||||
cfg = VegasModeConfig(separator_width=32)
|
||||
cfg.update(self._wrap(separator_width=8))
|
||||
assert cfg.separator_width == 8
|
||||
|
||||
def test_update_plugin_order(self):
|
||||
cfg = VegasModeConfig(plugin_order=[])
|
||||
cfg.update(self._wrap(plugin_order=["x", "y"]))
|
||||
assert cfg.plugin_order == ["x", "y"]
|
||||
|
||||
def test_update_excluded_plugins(self):
|
||||
cfg = VegasModeConfig()
|
||||
cfg.update(self._wrap(excluded_plugins=["skip_me"]))
|
||||
assert "skip_me" in cfg.excluded_plugins
|
||||
|
||||
def test_update_ignores_missing_keys(self):
|
||||
cfg = VegasModeConfig(scroll_speed=50.0)
|
||||
cfg.update(self._wrap(target_fps=80)) # only fps, not speed
|
||||
assert cfg.scroll_speed == 50.0
|
||||
assert cfg.target_fps == 80
|
||||
|
||||
def test_empty_update_no_change(self):
|
||||
cfg = VegasModeConfig(scroll_speed=50.0)
|
||||
cfg.update({})
|
||||
assert cfg.scroll_speed == 50.0
|
||||
@@ -716,6 +716,41 @@ def _run_startup_reconciliation() -> None:
|
||||
"manual 'Reconcile' action to resolve.",
|
||||
len(result.inconsistencies_manual),
|
||||
)
|
||||
|
||||
# Write status file so the web UI can surface unresolved issues as a
|
||||
# banner without the user having to read journalctl. Mirrors the
|
||||
# hw_status pattern (/tmp/led_matrix_hw_status.json).
|
||||
import json as _json, tempfile as _tempfile, os as _os
|
||||
_recon_status = {
|
||||
"done": True,
|
||||
"successful": result.reconciliation_successful,
|
||||
"fixed_count": len(result.inconsistencies_fixed),
|
||||
"unresolved": [
|
||||
{
|
||||
"plugin_id": inc.plugin_id,
|
||||
"type": inc.inconsistency_type.value,
|
||||
"description": inc.description,
|
||||
}
|
||||
for inc in result.inconsistencies_manual
|
||||
],
|
||||
}
|
||||
_recon_path = _os.path.join(_tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
||||
_tmp = None
|
||||
try:
|
||||
if not _os.path.islink(_recon_path):
|
||||
_fd, _tmp = _tempfile.mkstemp(dir=_tempfile.gettempdir(), prefix=".led_recon_")
|
||||
with _os.fdopen(_fd, "w") as _f:
|
||||
_json.dump(_recon_status, _f)
|
||||
_os.replace(_tmp, _recon_path)
|
||||
_tmp = None # Rename succeeded; nothing to clean up
|
||||
except (OSError, ValueError, TypeError) as _e:
|
||||
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
|
||||
finally:
|
||||
if _tmp is not None and _os.path.exists(_tmp):
|
||||
try:
|
||||
_os.unlink(_tmp)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||
finally:
|
||||
|
||||
@@ -2,14 +2,17 @@ from flask import Blueprint, request, jsonify, Response
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import hashlib
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1384,6 +1387,59 @@ def get_system_version():
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0}
|
||||
_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load
|
||||
|
||||
@api_v3.route('/system/check-update', methods=['GET'])
|
||||
def check_for_update():
|
||||
"""Check whether a newer LEDMatrix commit is available on origin/main."""
|
||||
now = time.time()
|
||||
if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL:
|
||||
return jsonify(_update_check_cache['result'])
|
||||
|
||||
_safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0}
|
||||
try:
|
||||
cwd = str(PROJECT_ROOT)
|
||||
fetch_result = subprocess.run(
|
||||
['git', 'fetch', 'origin', 'main', '--quiet'],
|
||||
capture_output=True, timeout=10, cwd=cwd,
|
||||
)
|
||||
if fetch_result.returncode != 0:
|
||||
logger.warning("check-update: git fetch failed (rc=%d): %s",
|
||||
fetch_result.returncode,
|
||||
fetch_result.stderr.decode(errors='replace').strip())
|
||||
_update_check_cache['result'] = _safe
|
||||
_update_check_cache['ts'] = now
|
||||
return jsonify(_safe)
|
||||
local = subprocess.run(
|
||||
['git', 'rev-parse', 'HEAD'],
|
||||
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||
).stdout.strip()
|
||||
remote = subprocess.run(
|
||||
['git', 'rev-parse', 'origin/main'],
|
||||
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||
).stdout.strip()
|
||||
|
||||
if not local or not remote:
|
||||
return jsonify(_safe)
|
||||
|
||||
if local == remote:
|
||||
result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0}
|
||||
else:
|
||||
count_str = subprocess.run(
|
||||
['git', 'rev-list', 'HEAD..origin/main', '--count'],
|
||||
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||
).stdout.strip()
|
||||
count = int(count_str) if count_str.isdigit() else 0
|
||||
result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count}
|
||||
|
||||
_update_check_cache['result'] = result
|
||||
_update_check_cache['ts'] = now
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning("check-update failed: %s", e)
|
||||
return jsonify(_safe)
|
||||
|
||||
@api_v3.route('/system/action', methods=['POST'])
|
||||
def execute_system_action():
|
||||
"""Execute system actions (start/stop/reboot/etc)"""
|
||||
@@ -2433,6 +2489,28 @@ def reconcile_plugin_state():
|
||||
status_code=500
|
||||
)
|
||||
|
||||
@api_v3.route('/plugins/reconciliation-status', methods=['GET'])
|
||||
def get_reconciliation_status():
|
||||
"""Return the result of the last startup reconciliation from /tmp status file."""
|
||||
_recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
||||
try:
|
||||
st = os.lstat(_recon_path)
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
||||
if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
|
||||
logger.warning("[Reconciliation] Status file is not a regular file: %s", _recon_path)
|
||||
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
||||
try:
|
||||
with open(_recon_path) as _f:
|
||||
data = json.load(_f)
|
||||
return jsonify({'status': 'success', 'data': data})
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("[Reconciliation] Failed to parse status file: %s", _recon_path)
|
||||
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
||||
except PermissionError:
|
||||
logger.exception("[Reconciliation] Permission denied reading status file: %s", _recon_path)
|
||||
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
||||
|
||||
@api_v3.route('/plugins/config', methods=['GET'])
|
||||
def get_plugin_config():
|
||||
"""Get plugin configuration"""
|
||||
|
||||
@@ -843,6 +843,14 @@ async function updateFontPreview() {
|
||||
return;
|
||||
}
|
||||
|
||||
// BDF bitmap fonts cannot be rendered server-side — skip the API call
|
||||
if (family.toLowerCase().endsWith('.bdf')) {
|
||||
previewImage.style.display = 'none';
|
||||
loadingText.style.display = 'block';
|
||||
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
loadingText.textContent = 'Loading preview...';
|
||||
loadingText.style.display = 'block';
|
||||
|
||||
@@ -1,3 +1,66 @@
|
||||
<!-- Reconciliation warning banner: shown when startup reconciliation found stale plugin config entries -->
|
||||
<div id="reconciliation-banner" class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
|
||||
<div class="flex-shrink-0 mr-3 mt-0.5">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-yellow-800">Plugin Config Warning</p>
|
||||
<p class="text-sm text-yellow-700 mt-1" id="reconciliation-banner-text"></p>
|
||||
</div>
|
||||
<button type="button" onclick="window.dismissReconciliationBanner()" class="ml-4 flex-shrink-0 text-yellow-500 hover:text-yellow-700" aria-label="Dismiss">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
|
||||
var _recon_timer = null;
|
||||
|
||||
function checkReconciliation() {
|
||||
fetch('/api/v3/plugins/reconciliation-status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (resp) {
|
||||
var d = resp.data || {};
|
||||
if (!d.done) {
|
||||
// Reconciliation still running — poll again shortly
|
||||
_recon_timer = setTimeout(checkReconciliation, 2000);
|
||||
return;
|
||||
}
|
||||
_recon_timer = null;
|
||||
if (!d.unresolved || d.unresolved.length === 0) return;
|
||||
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
||||
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
||||
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
|
||||
document.getElementById('reconciliation-banner-text').textContent =
|
||||
'Stale plugin config entries found: ' + ids +
|
||||
'. Remove them from config.json or reinstall via the Plugin Store.';
|
||||
var banner = document.getElementById('reconciliation-banner');
|
||||
banner.dataset.dismissKey = key;
|
||||
banner.style.setProperty('display', 'flex', 'important');
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
checkReconciliation();
|
||||
|
||||
window.dismissReconciliationBanner = function () {
|
||||
var banner = document.getElementById('reconciliation-banner');
|
||||
banner.style.setProperty('display', 'none', 'important');
|
||||
if (_recon_timer !== null) {
|
||||
clearTimeout(_recon_timer);
|
||||
_recon_timer = null;
|
||||
}
|
||||
// Persist dismissal immediately so the banner won't reappear on reload
|
||||
// even if the background sync fetch below fails.
|
||||
var key = banner.dataset.dismissKey;
|
||||
if (key) {
|
||||
try { sessionStorage.setItem(DISMISS_KEY, key); } catch (e) {}
|
||||
}
|
||||
// Background sync only — do not rely on this for DISMISS_KEY or hiding.
|
||||
fetch('/api/v3/plugins/reconciliation-status').catch(function () {});
|
||||
};
|
||||
}());
|
||||
</script>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
||||
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
||||
{% set description = prop.description if prop.description else '' %}
|
||||
{% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %}
|
||||
{% set _pt = prop.get('type') %}
|
||||
{% set field_type = _pt if (_pt is string) else ((_pt | first) if (_pt and _pt is iterable and _pt is not string) else 'string') %}
|
||||
|
||||
{# Handle nested objects - check for widget first #}
|
||||
{% if field_type == 'object' %}
|
||||
|
||||
Reference in New Issue
Block a user