From 61e462c635bdafe7477980c694e7f66b16a10272 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:33:24 -0400 Subject: [PATCH] refactor: remove the skin system and the unused src/base_classes package (#615) * refactor: remove the skin system Skins never rendered with the current scoreboard plugins: the only hook was SportsCore._render_game in src/base_classes, which no plugin builds on, so the UI and store already treated them as unsupported. The owner decided on 2026-09-23 to remove them outright. Removed src/skin_system/ (runtime, base class, fixtures), skins/, scripts/validate_skin.py and their tests; the store's "type": "skin" installer, uninstaller and hide/refuse filters (the official registry lists no skins); SchemaManager.inject_skin_selector; and GET /api/v3/skins. Stored skin/skin_options config values are handled in the next commit. Co-Authored-By: Claude Opus 5.5 * fix(config): drop retired skin/skin_options keys instead of validating them A config.json written while the skin system existed can carry skin and skin_options in any plugin section, and most plugin schemas set additionalProperties: false. They are no longer core plugin properties; RETIRED_PLUGIN_KEYS in schema_manager lists them and drop_retired_plugin_keys removes them (unless the plugin's own schema declares the name) in prepare_plugin_config, which loading, hot reload, GET /plugins/config and both web saves already share, and in validate_config_against_schema for callers that validate a raw section. POST /plugins/config and /config/main also drop them from the stored section they merge into, so they leave config.json on the next save. Tests cover the load path (real PluginManager.load_plugin: no schema warning, not degraded), raw and prepared validation, validate_all_plugin_configs, and the JSON, form and /config/main saves. Co-Authored-By: Claude Opus 5.5 * refactor: remove the unused src/base_classes package No scoreboard plugin builds on src.base_classes: the nine monorepo scoreboards ship their own sports.py and share code through src/common (docs/SPORTS_UNIFICATION.md), and none of the third-party registry plugins imports it. The one import anywhere, baseball-scoreboard's rankings_manager.py, is a lazy import of ESPNDataSource in a class nothing instantiates. Removed the package and the eight test files that only tested it (test_api_extractors, test_data_sources, test_sports_base_characterization, test_sports_capabilities, test_sports_core_promotions, test_sports_logo_cache_bounded, test_sports_modes_promotions, test_sports_odds_fanout). test_common_is_hardware_free no longer lists src.base_classes as a forbidden import, and comments in sports_helpers.py and base_odds_manager.py stop pointing at it. Co-Authored-By: Claude Opus 5.5 * docs: drop the skin system and src/base_classes from the docs Deletes docs/SKIN_SYSTEM.md and docs/CREATING_SKINS.md and every link to them (docs/README.md, README.md, PLUGIN_DEVELOPMENT_GUIDE.md, the /skins section of REST_API_REFERENCE.md), the skin section of CLAUDE.md and the term in PRODUCT.md. SPORTS_UNIFICATION.md now says src/base_classes was removed and shared code lives in src/common, in the Layering section and the view-model-contract rule. Other docs stop pointing at the removed package. CHANGELOG records both removals under Unreleased. Co-Authored-By: Claude Opus 5.5 * fix(store): hide and refuse registry entries that aren't plugins The skin filters went with the skin system, but a custom registry can still list "type": "skin" entries, and installing one as a plugin would unpack it into the plugins directory. PluginStoreManager.is_plugin_entry() (a missing type means plugin) now hides non-plugin entries from the store and custom-registry listings, and install refuses them, in the route with a clear 400 and in _install_plugin_impl for any other caller. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .gitignore | 1 - CHANGELOG.md | 15 + CLAUDE.md | 11 - PRODUCT.md | 2 +- README.md | 9 - docs/CONFIG_REFERENCE.md | 2 +- docs/CREATING_SKINS.md | 254 ---- docs/FONT_MANAGER.md | 4 +- docs/PLUGIN_API_REFERENCE.md | 4 +- docs/PLUGIN_ARCHITECTURE_SPEC.md | 6 +- docs/PLUGIN_CONFIG_CORE_PROPERTIES.md | 15 +- docs/PLUGIN_DEVELOPMENT_GUIDE.md | 6 - docs/README.md | 2 - docs/REST_API_REFERENCE.md | 12 - docs/SKIN_SYSTEM.md | 206 --- docs/SPORTS_UNIFICATION.md | 48 +- scripts/validate_skin.py | 250 ---- skins/README.md | 28 - skins/example-classic-baseball/preview.png | Bin 5385 -> 0 bytes skins/example-classic-baseball/skin.json | 25 - skins/example-classic-baseball/skin.py | 131 -- src/base_classes/api_extractors.py | 371 ------ src/base_classes/baseball.py | 692 ---------- src/base_classes/basketball.py | 300 ----- src/base_classes/data_sources.py | 344 ----- src/base_classes/football.py | 387 ------ src/base_classes/hockey.py | 380 ------ src/base_classes/sports/__init__.py | 17 - .../sports/capabilities/__init__.py | 32 - .../sports/capabilities/celebrations.py | 418 ------ .../sports/capabilities/rotation.py | 246 ---- src/base_classes/sports/core.py | 1148 ----------------- src/base_classes/sports/modes.py | 1097 ---------------- src/base_odds_manager.py | 3 +- src/common/sports_helpers.py | 19 +- src/plugin_system/schema_manager.py | 103 +- src/plugin_system/store_manager.py | 179 +-- src/skin_system/__init__.py | 43 - src/skin_system/fixtures/baseball_live.json | 39 - src/skin_system/fixtures/baseball_recent.json | 39 - .../fixtures/baseball_upcoming.json | 39 - src/skin_system/fixtures/basketball_live.json | 28 - .../fixtures/basketball_recent.json | 28 - .../fixtures/basketball_upcoming.json | 28 - src/skin_system/fixtures/football_live.json | 36 - src/skin_system/fixtures/football_recent.json | 36 - .../fixtures/football_upcoming.json | 36 - src/skin_system/fixtures/hockey_live.json | 32 - src/skin_system/fixtures/hockey_recent.json | 32 - src/skin_system/fixtures/hockey_upcoming.json | 32 - src/skin_system/fixtures/placeholder_away.png | Bin 444 -> 0 bytes src/skin_system/fixtures/placeholder_home.png | Bin 446 -> 0 bytes src/skin_system/skin_base.py | 171 --- src/skin_system/skin_runtime.py | 352 ----- test/fixtures/api_v3_url_map.json | 9 - test/test_api_extractors.py | 342 ----- test/test_common_is_hardware_free.py | 11 +- test/test_data_sources.py | 307 ----- test/test_discovery_path_contract.py | 27 - test/test_retired_plugin_config_keys.py | 167 +++ test/test_skin_runtime_cache.py | 377 ------ test/test_skin_system.py | 571 -------- test/test_sports_base_characterization.py | 660 ---------- test/test_sports_capabilities.py | 1077 ---------------- test/test_sports_core_promotions.py | 613 --------- test/test_sports_logo_cache_bounded.py | 120 -- test/test_sports_modes_promotions.py | 585 --------- test/test_sports_odds_fanout.py | 136 -- test/test_store_non_plugin_entries.py | 79 ++ test/test_version_comparison_consistency.py | 26 +- .../test_plugin_config_json_saves.py | 54 +- web_interface/blueprints/api_v3/__init__.py | 5 +- web_interface/blueprints/api_v3/config.py | 8 +- web_interface/blueprints/api_v3/misc.py | 47 +- web_interface/blueprints/api_v3/plugins.py | 32 +- 75 files changed, 431 insertions(+), 12560 deletions(-) delete mode 100644 docs/CREATING_SKINS.md delete mode 100644 docs/SKIN_SYSTEM.md delete mode 100644 scripts/validate_skin.py delete mode 100644 skins/README.md delete mode 100644 skins/example-classic-baseball/preview.png delete mode 100644 skins/example-classic-baseball/skin.json delete mode 100644 skins/example-classic-baseball/skin.py delete mode 100644 src/base_classes/api_extractors.py delete mode 100644 src/base_classes/baseball.py delete mode 100644 src/base_classes/basketball.py delete mode 100644 src/base_classes/data_sources.py delete mode 100644 src/base_classes/football.py delete mode 100644 src/base_classes/hockey.py delete mode 100644 src/base_classes/sports/__init__.py delete mode 100644 src/base_classes/sports/capabilities/__init__.py delete mode 100644 src/base_classes/sports/capabilities/celebrations.py delete mode 100644 src/base_classes/sports/capabilities/rotation.py delete mode 100644 src/base_classes/sports/core.py delete mode 100644 src/base_classes/sports/modes.py delete mode 100644 src/skin_system/__init__.py delete mode 100644 src/skin_system/fixtures/baseball_live.json delete mode 100644 src/skin_system/fixtures/baseball_recent.json delete mode 100644 src/skin_system/fixtures/baseball_upcoming.json delete mode 100644 src/skin_system/fixtures/basketball_live.json delete mode 100644 src/skin_system/fixtures/basketball_recent.json delete mode 100644 src/skin_system/fixtures/basketball_upcoming.json delete mode 100644 src/skin_system/fixtures/football_live.json delete mode 100644 src/skin_system/fixtures/football_recent.json delete mode 100644 src/skin_system/fixtures/football_upcoming.json delete mode 100644 src/skin_system/fixtures/hockey_live.json delete mode 100644 src/skin_system/fixtures/hockey_recent.json delete mode 100644 src/skin_system/fixtures/hockey_upcoming.json delete mode 100644 src/skin_system/fixtures/placeholder_away.png delete mode 100644 src/skin_system/fixtures/placeholder_home.png delete mode 100644 src/skin_system/skin_base.py delete mode 100644 src/skin_system/skin_runtime.py delete mode 100644 test/test_api_extractors.py delete mode 100644 test/test_data_sources.py create mode 100644 test/test_retired_plugin_config_keys.py delete mode 100644 test/test_skin_runtime_cache.py delete mode 100644 test/test_skin_system.py delete mode 100644 test/test_sports_base_characterization.py delete mode 100644 test/test_sports_capabilities.py delete mode 100644 test/test_sports_core_promotions.py delete mode 100644 test/test_sports_logo_cache_bounded.py delete mode 100644 test/test_sports_modes_promotions.py delete mode 100644 test/test_sports_odds_fanout.py create mode 100644 test/test_store_non_plugin_entries.py diff --git a/.gitignore b/.gitignore index 3b59b144..fea80d19 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,6 @@ config/backups/ # Starlark apps runtime storage (installed .star files and cached renders) /starlark-apps/ -skin_renders/ # JS test deps (test/js) node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f9cbb9..0e07b552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,21 @@ floor on the release that ships them): - The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating errors and a Clear button. +### Removed + +- **The skin system.** Skins never rendered with the current scoreboard + plugins, so they are gone rather than "not supported yet": `src/skin_system/`, + `skins/`, `scripts/validate_skin.py`, `GET /api/v3/skins`, the store's + `"type": "skin"` handling and `docs/SKIN_SYSTEM.md` / `docs/CREATING_SKINS.md`. + A `skin` or `skin_options` key left in a plugin's saved config still loads + and saves without a validation error; it is ignored, and the next save of + that plugin's settings removes it (unless the plugin's own schema declares + the key). +- **`src/base_classes/`** (`SportsCore`, the sport and mode classes, + `CelebrationMixin`, the rotation strategies, `data_sources`, + `api_extractors`). No known plugin imports it. A plugin that does must use + `src.common` or its own copy of the code. + ## 3.5.0 New modules a plugin may import via `src.*` (floor on 3.5.0): diff --git a/CLAUDE.md b/CLAUDE.md index 08942c40..714ff7b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,17 +45,6 @@ - Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls - Third-party plugins can use their own repo URL with empty `plugin_path` -## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET -- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes` -- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` -- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section) -- Skins live in `skins//` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs -- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests -- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session) -- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins -- Validate skins headlessly: `python scripts/validate_skin.py --skin `; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md` -- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed - ## Common Pitfalls - paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat - BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()` diff --git a/PRODUCT.md b/PRODUCT.md index b0b2981b..23488754 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -44,7 +44,7 @@ Four strengths define LEDMatrix, and future work must protect all of them: - **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working. - **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls. - **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets. -- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, skin, on-demand, AP mode. +- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, on-demand, AP mode. - **Open decisions** (offered during init, not adopted as constraints): - Whether the UI must work fully offline, with no CDN fallbacks at runtime. - Whether a Node/CSS build step is acceptable for contributors. diff --git a/README.md b/README.md index 7b7b8b7f..7ae97dd0 100644 --- a/README.md +++ b/README.md @@ -460,15 +460,6 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl For plugin development, the `plugins/hello-world/` plugin in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository is a starter template. -### Visual Skins for Scoreboards - -**Not supported yet.** Skins are meant to restyle a sports scoreboard's -live/recent/upcoming screens without forking the plugin, but the current -scoreboard plugins don't render them: a selected skin has no effect. The web -UI doesn't offer skin install or selection for that reason. The skin system -and its docs stay in place for when scoreboards adopt it; see -[docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why. - **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility. diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 96f6b741..58eb7df7 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -104,7 +104,7 @@ logical image to multiple chained physical panels. |---|---|---|---| | `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) | | `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) | -| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | `src/base_classes/sports/core.py` | +| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config | | `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) | ## `display.vegas_scroll` — continuous scroll mode diff --git a/docs/CREATING_SKINS.md b/docs/CREATING_SKINS.md deleted file mode 100644 index d64bc6ba..00000000 --- a/docs/CREATING_SKINS.md +++ /dev/null @@ -1,254 +0,0 @@ -# Creating Skins - -> **Not supported yet: skins don't render with the current scoreboard -> plugins.** The only render hook is `SportsCore._render_game()` in -> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or -> third-party) builds on `src.base_classes`, so a skin you build here passes -> `validate_skin.py` but never appears on the matrix. The web UI and Plugin -> Store don't offer skins for that reason. Details: -> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below -> stays accurate for the skin API itself. - -A skin restyles a sports scoreboard (live / recent / upcoming) without -forking the plugin: the plugin keeps fetching data, scheduling, caching, and -doing vegas mode; your skin only draws. Architecture background: -[SKIN_SYSTEM.md](SKIN_SYSTEM.md). - -## Quick start - -```bash -cp -r skins/example-classic-baseball skins/my-skin -# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name -# edit skins/my-skin/skin.py -> rename the class, start restyling -python scripts/validate_skin.py --skin my-skin -``` - -The validator renders your skin against bundled fixture games at several -panel sizes with **no hardware, no network, no running service**, saves PNGs -(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate: -edit → validate → look at the PNGs. - -To select it, add to your plugin's section in `config/config.json` (this is -stored and validated, but has no visible effect until a scoreboard uses the -skin hook — see the note at the top): - -```json -"baseball-scoreboard": { - "skin": "my-skin", - "skin_options": { } -} -``` - -The web UI's **Visual Skin** dropdown is hidden while skins are unsupported. -`"skin"` also accepts a per-mode mapping: -`{"live": "my-skin", "recent": "built-in"}`. - -## The manifest (`skin.json`) - -```json -{ - "id": "my-skin", - "name": "My Skin", - "version": "1.0.0", - "author": "you", - "description": "What it looks like", - "skin_api_version": "1.0.0", - "targets": { - "sports": ["baseball"], - "sport_keys": ["mlb", "milb"], - "plugins": [] - }, - "entry_point": "skin.py", - "class_name": "MySkin", - "modes": ["live", "recent", "upcoming"], - "preview": "preview.png" -} -``` - -Field notes: `id` must equal the directory name; `skin_api_version`'s major -version must match the host's `SKIN_API_VERSION` or the skin is refused at -load; `targets` takes sport families (`sports`), exact sport keys -(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies. - -## The renderer (`skin.py`) - -```python -from src.skin_system.skin_base import ScoreboardSkin, SkinContext - -class MySkin(ScoreboardSkin): - def render_live(self, ctx: SkinContext, game: dict) -> bool: - score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" - fit = ctx.layout.fit_text(score, ctx.layout.bounds) - ctx.draw_fit(fit, ctx.layout.bounds) - return True # True = "I drew it"; False = use the built-in layout -``` - -Implement only the modes you care about — anything else falls back to the -plugin's built-in rendering. Return `False` to decline a specific game (e.g. -a layout that only makes sense while a game is live). - -### The rules (they keep your skin from breaking the display) - -1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never - reassign `ctx.canvas`, never touch the display or call any update method. -2. **No I/O in render paths.** No network, no file loads per frame — - `render_live` runs every display pass, and a slow render stalls the whole - matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and - `cache_key=` for images. -3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the - live/recent/upcoming modes each get their own instance. -4. **Always `.get()` optional keys.** Only the guaranteed keys below are - promised to exist. -5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/ - `ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run - at sizes you didn't test (64x32, 128x64, vegas cards). -6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides. - -A skin that raises 3 renders in a row is disabled until the service restarts -(the built-in layout takes over), so a bug is cosmetic — but check your logs. - -## SkinContext reference - -| Member | What it is | -|---|---| -| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) | -| `ctx.width`, `ctx.height` | Canvas size — the only size truth | -| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` | -| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) | -| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) | -| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` | -| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below | -| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) | -| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` | -| `ctx.options` | Your user's `skin_options` from config | -| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger | - -**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one -sanctioned exception. It goes through the host's logo cache — after the -first call per team it's a pure in-memory lookup. If a logo file is missing -on disk, the *first* call may download it, exactly like the built-in -renderer does for the same game (a skin is never worse than built-in here). -Always pass a stable `cache_key` when drawing it, never load image files -yourself in a render path, and always handle `None`. - -The default layout idiom — carve regions, then fit text into them: - -```python -from src.adaptive_layout import scoreboard_regions - -regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout) -ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}") -ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}") -fit = ctx.layout.fit_text("3-5", regions.score_area) -ctx.draw_fit(fit, regions.score_area) -``` - -`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/ -`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/ -ellipse/...` is always available for custom marks (see the bases diamond in -the example skin). - -## The game view model - -Guaranteed for every sport (view model v1.0 — renaming these breaks skins and -is treated as a breaking change upstream): - -| Key | Notes | -|---|---| -| `id` | Event id (string) | -| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` | -| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans | -| `game_date`, `game_time` | Pre-formatted local date/time strings | -| `start_time_utc` | UTC `datetime` | -| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) | -| `home_id`, `away_id` | Team ids | -| `home_score`, `away_score` | **Strings**, not ints | -| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) | -| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these | - -Sport extras (present for that sport, still `.get()` defensively): - -- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`, - `strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]` - booleans), `series_summary` (str) -- **football**: `period`, `period_text`, `clock`, `home_timeouts`, - `away_timeouts`, `down_distance_text`, `down_distance_text_long`, - `is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`), - `scoring_event` -- **basketball**: `period`, `period_text`, `clock` -- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`, - `home_shots`, `away_shots` - -Optional everywhere (only when the user enabled the feature): `odds` (dict), -`series_summary`, rankings-related fields. - -Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's -exactly what the validator feeds your skin. - -## Vegas mode - -You get vegas support for free: vegas captures the normal display output, -which is already your skin's rendering. Optionally implement -`render_vegas_card(ctx, game)` to return a purpose-built card at -`ctx.width x ctx.height` (sizes vary — never assume 128x32). - -## Building a skin with Claude Code - -Skins are ideal Claude Code projects: small, isolated, and verifiable with -one command. Paste this to start: - -> You are building a **display skin** for LEDMatrix — a visual overlay for a -> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels). -> First read `docs/CREATING_SKINS.md` and the reference skin in -> `skins/example-classic-baseball/`. -> -> Rules: -> - Create/modify files ONLY under `skins//`. Do NOT modify -> anything in `src/`, `scripts/`, the plugins, or any other skin. -> - Render only from the `game` dict and `ctx` helpers. No network calls, no -> per-frame file I/O, no new pip dependencies, no touching the display — -> draw onto `ctx.canvas` and return True. -> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works -> at any panel size; use `.get()` for every optional game key. -> - After every change run -> `python scripts/validate_skin.py --skin ` and LOOK at the -> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to -> read). Iterate until it passes and looks right at both 128x32 and 64x32. -> -> What I want it to look like: status go; colors; what shows during live vs upcoming vs final> - -Tips that keep Claude (and you) out of trouble: - -- One mode at a time: get `render_live` right before touching the others — - unimplemented modes automatically use the built-in look. -- Ask for edge-case renders: long team abbreviations, missing logos - (`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT. -- If the render looks cramped at 64x32, ask Claude to use - `ctx.layout.by_tier(...)` to drop elements on small panels rather than - shrinking everything. -- Never let it "fix" a problem by editing `src/` — if the skin can't do - something within its directory, that's a feature request, not a workaround. - -## Pre-publish checklist - -- [ ] `python scripts/validate_skin.py --skin --size 128x32 --size 64x32 --size 128x64` passes -- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping -- [ ] Handles a missing logo (`None`) without crashing — temporarily point a - fixture's logo path at a nonexistent file to test -- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow -- [ ] No render warning above the time budget -- [ ] `skin.json`: `id` matches the directory, `version` set, - `skin_api_version` matches the host, targets correct -- [ ] `preview.png` added (grab your favorite `_x4` render) -- [ ] Tested on real hardware if you have it — a Pi is much slower than your - dev machine - -Distribute by publishing the directory as a git repo (users -`git clone skins/`). Registry entries with `"type": "skin"` are -hidden and refused by the Plugin Store while skins are unsupported (see -[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution). - -**Trust note:** a skin is Python running inside the display service — the -same trust level as a plugin. Review code before installing skins from -others. diff --git a/docs/FONT_MANAGER.md b/docs/FONT_MANAGER.md index 3678e955..1acf32f1 100644 --- a/docs/FONT_MANAGER.md +++ b/docs/FONT_MANAGER.md @@ -393,7 +393,5 @@ self.font = self.font_manager.resolve_font( ## Example: Complete Manager Implementation For a working example of the font manager API in use, see -`src/font_manager.py` itself and the bundled scoreboard base classes -in `src/base_classes/` (e.g., `hockey.py`, `football.py`) which -register and resolve fonts via the patterns documented above. +`src/font_manager.py` itself. diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index eb1fa985..5aadaadc 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -430,9 +430,7 @@ self.display_manager.image.paste(icon, (5, 5), icon) self.display_manager.update_display() ``` -This is the same pattern the bundled scoreboard base classes -(`src/base_classes/baseball.py`, `basketball.py`, `football.py`, -`hockey.py`) use, so it's the canonical way to render arbitrary images. +This is the canonical way to render arbitrary images. ### Weather Icons diff --git a/docs/PLUGIN_ARCHITECTURE_SPEC.md b/docs/PLUGIN_ARCHITECTURE_SPEC.md index 8df78fd9..7a726978 100644 --- a/docs/PLUGIN_ARCHITECTURE_SPEC.md +++ b/docs/PLUGIN_ARCHITECTURE_SPEC.md @@ -12,9 +12,9 @@ > in `web_interface/app.py`. > - The default plugin location is `plugin-repos/` (configurable via > `plugin_system.plugins_directory`), not `./plugins/`. -> - Example imports use `src/plugin_system/base_classes/*_plugin.py`; -> the shipped base classes live in `src/base_classes/` (e.g. -> `src.base_classes.sports.SportsCore`, `src.base_classes.hockey.Hockey`). +> - Example imports use `src/plugin_system/base_classes/*_plugin.py`, +> which do not exist. The old `src/base_classes/` package has been +> removed; shared sports code lives in `src/common/`. > - The "Migration Strategy" and "Implementation Roadmap" sections > describe work that has now shipped. > diff --git a/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md b/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md index 753c8430..2939dd99 100644 --- a/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md +++ b/docs/PLUGIN_CONFIG_CORE_PROPERTIES.md @@ -25,16 +25,7 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`): - Description: Enable live priority takeover when plugin has live content - Used by DisplayController for priority scheduling -4. **`skin`** (string, object or null; no default) - - Description: Visual skin id, or a per-mode mapping like `{"live": "my-skin"}` - - Not an enum, so a stored value keeps validating after the skin is - uninstalled. Skins do not render with the current scoreboard plugins; - the key is kept so stored values keep loading and saving - -5. **`skin_options`** (object; no default) - - Description: Options passed through to the selected skin - -6. **`vegas_width_pct`**, **`vegas_overflow`**, **`vegas_max_width_screens`** +4. **`vegas_width_pct`**, **`vegas_overflow`**, **`vegas_max_width_screens`** (untyped; no default) - Description: Vegas mode tuning for this plugin — card width as a percentage of the panel, `"rotate"` or `"truncate"` on overflow, and the @@ -42,6 +33,10 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`): - Read by `src/vegas_mode/plugin_adapter.py` and `BasePlugin`, which validate the values themselves and ignore a bad one with a log line +`skin` and `skin_options` were core properties until the skin system was +removed. A plugin config saved with them still loads and saves; the keys are +dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`). + ## How Core Properties Work ### Schema Validation diff --git a/docs/PLUGIN_DEVELOPMENT_GUIDE.md b/docs/PLUGIN_DEVELOPMENT_GUIDE.md index 14b96c87..95857464 100644 --- a/docs/PLUGIN_DEVELOPMENT_GUIDE.md +++ b/docs/PLUGIN_DEVELOPMENT_GUIDE.md @@ -12,12 +12,6 @@ This guide explains how to set up a development workflow for plugins that are ma > scale. Existing plugins keep their classic rendering unless they adopt > those APIs; nothing migrates automatically. -> **Want a different look for an existing sports scoreboard?** Skins are -> meant for that, but they are **not supported yet**: the current scoreboard -> plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)). -> For now, change the look through the plugin's own display settings or its -> code. - ## Overview When developing plugins in separate repositories, you need a way to: diff --git a/docs/README.md b/docs/README.md index 4e5c1342..475cb92e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,8 +56,6 @@ Going deeper: - [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display, cache management, background services, permissions - [FONT_MANAGER.md](FONT_MANAGER.md) — font system -- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins) -- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat) ## Reference diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index f651a222..2c8f1e67 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -37,7 +37,6 @@ the entry below says so. - [Integrations](#integrations) - [Plugin-specific endpoints](#plugin-specific-endpoints) - [Starlark Apps](#starlark-apps) -- [Skins](#skins) > The API blueprint is the `api_v3` package in > `web_interface/blueprints/api_v3/` (one module per area: `config.py`, @@ -2084,17 +2083,6 @@ runs. --- -## Skins - -**GET** `/api/v3/skins` - -Installed scoreboard skins (optional `?plugin_id=` filter). Skins are not -supported by the current scoreboard plugins, so the response carries -`data.supported: false` and a `data.message`; clients must not offer these -as selectable. See [SKIN_SYSTEM.md](SKIN_SYSTEM.md). - ---- - ## Error Responses Errors use one of two shapes. Most endpoints answer: diff --git a/docs/SKIN_SYSTEM.md b/docs/SKIN_SYSTEM.md deleted file mode 100644 index 2ab85ad4..00000000 --- a/docs/SKIN_SYSTEM.md +++ /dev/null @@ -1,206 +0,0 @@ -# Skin System Architecture - -## Status: not supported yet - -**Skins don't render with the current scoreboard plugins.** The skin system -below works in isolation (it loads, validates and renders skins in -`scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a -running display calls it: - -- The only render hook is `SportsCore._render_game()` in - `src/base_classes/sports/core.py`. -- None of the current scoreboard plugins build on `src.base_classes`. The - official scoreboards in the `ledmatrix-plugins` monorepo, and the - third-party scoreboards in the plugin registry, carry their own sports and - rendering code (with the shared `src/common/sports_*` helpers) and never - reach `SportsCore._render_game()`. - -So a skin can be dropped into `skins/` and named in a plugin's config, but the -scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the -hook, core does not offer skins to users: - -- The plugin config page shows no **Visual Skin** dropdown. -- The Plugin Store hides registry entries with `"type": "skin"` and refuses - to install one (`POST /api/v3/plugins/install` answers 400 with the reason). -- `GET /api/v3/skins` still lists what is in `skins/`, with - `"supported": false` and a `message`. -- A config that already contains `"skin"` / `"skin_options"` still loads, - validates and saves unchanged; the value is simply unused. - -The rest of this document describes the design as built, for whoever wires a -scoreboard to it. - -Skins are user-installable **visual overlays** for the sports scoreboards. -A skin replaces only the *look* of a scoreboard — the host plugin keeps doing -data fetching, scheduling, caching, dedup, live-priority takeover, and vegas -mode. If you only want to **build** a skin, read -[CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system -works and why it is shaped this way. - -## Why skins instead of forks - -Before skins, changing a scoreboard's layout meant forking the whole plugin -(e.g. the community MLB scoreboard fork). The fork gets the new look but loses -everything the maintained plugin keeps earning: duration/scheduling behavior, -vegas mode support, caching and background-fetch improvements, bug fixes. It -also silently drifts: every upstream improvement now has to be re-ported by -hand. - -A skin inverts that trade. The plugin remains stock and keeps updating through -the store; the skin is ~100 lines of pure rendering code that receives the -plugin's already-fetched data each frame. Uninstalling the skin (or the skin -crashing) simply restores the built-in look. - -```text - (unchanged) (the skin seam) - ESPN API ──► update() ──► game view model ──► _render_game() ──► display - fetching (a dict) │ │ - caching │ └─ built-in - scheduling └─ skin.render_(ctx, game) - live priority draws onto ctx.canvas -``` - -## The render funnel - -A sports scoreboard built on the `src/base_classes/sports/` package -(`core.py`) renders through exactly one seam. No current scoreboard plugin is -built on it (see [Status](#status-not-supported-yet)), so for them this seam is -never reached: -`SportsCore._render_game(game, force_clear)`. - -1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`) - picks `self.current_game` and calls `_render_game`. -2. `_render_game` lazily loads the configured skin (once, on first render — - a broken skin can never block plugin startup). -3. If a skin is active, the host builds a `SkinContext` — a fresh black - canvas at the current display size plus layout/font/logo helpers — and - calls the skin's `render_live` / `render_recent` / `render_upcoming` - with a **copy** of the game dict. -4. If the skin returns `True`, the canvas is composited onto the display. - If it returns `False`, isn't implemented for that mode, or raises, the - built-in `_draw_scorebug_layout` runs instead. - -Key properties that fall out of this design: - -- **Per-mode fallback.** A skin that only implements `render_live` gets the - stock recent/upcoming screens for free. -- **Three strikes.** A skin that raises 3 times in a row is disabled for the - rest of the session (one loud error log per failure); the display never - goes dark. Restarting the service re-arms it. -- **Copies, not references.** Skins receive a shallow copy of the game dict, - so a buggy skin cannot corrupt the plugin's scheduling state. -- **Vegas mode works untouched.** Vegas capture falls back to grabbing the - regular `display()` output, which is already skin-rendered. Skins can - additionally implement `render_vegas_card` for purpose-built scroll cards, - and hosts can call `SportsCore.render_skin_card(game, size)` to use it. -- **Hot-loop caution.** `render_live` runs every display-loop pass during a - live game. The host logs a warning when a skin render exceeds 150 ms, and - `scripts/validate_skin.py` enforces a budget at development time — but - Python cannot forcibly time-out a stuck render, so a skin that blocks - (network I/O, giant image ops) stalls the display. This is why the rules - in CREATING_SKINS.md ban I/O in render paths. - -## The view model contract - -The `game` dict a skin receives is the plugin's already-extracted view model -(`SportsCore._extract_game_details_common` plus per-sport extras from -`src/base_classes/{baseball,basketball,football,hockey}.py`). - -- **Guaranteed keys (view model v1.0)** — always present for every sport: - `id`, `game_time`, `game_date`, `start_time_utc` (a UTC `datetime`), - `status_text`, `is_live`, `is_final`, `is_upcoming`, `is_halftime`, - `home_abbr`/`away_abbr`, `home_id`/`away_id`, `home_score`/`away_score` - (**strings**), `home_logo_path`/`away_logo_path`, `home_record`/`away_record`. -- **Sport extras** — documented per sport in CREATING_SKINS.md (e.g. baseball - adds `inning`, `inning_half`, `balls`, `strikes`, `outs`, `bases_occupied`). -- **Optional keys** (`odds`, rankings, `series_summary`, …) are present only - when the feature is enabled — skins must always use `.get()`. - -Versioning policy: additive changes bump the minor version -(`VIEW_MODEL_VERSION` in `src/skin_system/skin_base.py`, surfaced to skins as -`ctx.view_model_version`); renaming or removing a guaranteed key requires a -major bump plus a compat shim. `test/test_skin_system.py::TestViewModelContract` -fails CI if a guaranteed key disappears from the extractor. - -Separately, `SKIN_API_VERSION` versions the Python API (`ScoreboardSkin`, -`SkinContext`). The loader refuses a skin whose manifest declares a different -major version and falls back to the built-in renderer with a clear -"skin needs an update" log line. - -## Package layout and lifecycle - -```text -skins// - skin.json # manifest (required) - skin.py # ScoreboardSkin subclass (required) - preview.png # optional, shown by the web UI - assets/ # optional skin-local images - helpers.py ... # optional extra modules (namespaced per skin at import) -``` - -Skins live in the central `skins/` directory — deliberately **not** inside the -plugin's directory, because plugin reinstall/update deletes the whole plugin -directory and a skin must survive that. One skin can also target several -plugins (mlb + milb). - -Lifecycle: discovered lazily on first render → manifest validated → API major -version gated → module imported under a namespaced `sys.modules` key (two -skins can both ship a `helpers.py`, same scheme plugins use) → instantiated -with `(manifest, options)`. Every failure logs and falls back to built-in. - -Skins should be **stateless**: the live, recent, and upcoming mode classes -each hold their own skin instance, so derive everything from `(ctx, game)`. - -## Selection and configuration - -Inside the plugin's own config section in `config/config.json`: - -```json -"baseball-scoreboard": { - "skin": "retro-baseball", - "skin_options": { "accent_color": [255, 80, 0] } -} -``` - -`"skin"` is either one id for all modes or a per-mode mapping -(`{"live": "retro-baseball", "recent": "built-in"}`). Absent, empty, or -`"built-in"` means the stock renderer. Because this rides the plugin's config -section, it persists across plugin reinstalls like every other setting. - -`SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the -*served* schema for plugins with matching skins installed. While skins are -unsupported the plugin schema endpoint does not call it, so the dropdown is -not shown. Validation never sees the enum either way: the base schema allows -any `skin` value, so a config that references an uninstalled skin stays valid. -`GET /api/v3/skins` lists installed skins (optionally filtered by -`?plugin_id=`) and reports `"supported": false`. - -## Distribution - -- **Manual:** `git clone skins/` — that's the whole - install. No manifest bumps, no `update_registry.py`; skins are not monorepo - plugins. -- **Store (disabled while unsupported):** registry entries with - `"type": "skin"` are hidden from the store list and refused on install. - `PluginStoreManager._install_skin_from_info` is kept: once - `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such - entries install through the same `plugins.json` pipeline, land in `skins/`, - are validated against `skin.json` (including the API major version) instead - of `manifest.json`, and never install dependencies — skins are render-only - (stdlib + PIL + the provided context, no third-party packages in v1). - -## Trust model - -A skin is Python executing inside the display service — **exactly the same -trust level as a plugin**, even though "skin" sounds cosmetic. Only install -skins from sources you'd be willing to install a plugin from. - -## v2 directions (not in v1) - -- A generic `BasePlugin` opt-in (`render_with_skin()`) so non-sports plugins - (weather, music) can offer skinnable layouts; `skin_runtime` is already - sports-agnostic in anticipation. -- Store UI: preview gallery, one-click install from the skin browser. -- An update path for git-cloned skins (today: re-clone or store reinstall). -- Animation support in skins (today the API is one frame per render call; - stateful tricks work but are at-your-own-risk). diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index 1078f1c5..015c6e2d 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -7,9 +7,9 @@ becoming nine clients of a god class. Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`, `nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this -repo's `src/base_classes/sports.py`. They have drifted into three lineages, and -only 28 of the 66 methods appearing across them are present in all nine. One -logical fix (the UTC start-time bug) cost 75 files. +repo's former `src/base_classes/sports.py` (since removed). They have drifted +into three lineages, and only 28 of the 66 methods appearing across them are +present in all nine. One logical fix (the UTC start-time bug) cost 75 files. Merging everything into one base class would fix the duplication and create a worse problem: a single 2,500-line class that all nine plugins inherit, where any @@ -26,7 +26,7 @@ These are independent concerns. Conflating them is what produces god classes. |---|---| | Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) | | Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. | -| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. | +| Core changes never break a plugin's rendering | The **view-model contract**: the game dict each plugin's `_extract_game_details_common` builds is read by the shared `src/common` renderers, so its keys may be added, never renamed or removed. | | A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. | The core API is **additive-only**. A method the plugins call is never removed or @@ -67,16 +67,13 @@ This is the property the naive merge destroys, and it is enforced structurally: ## Layering -``` -src/base_classes/sports/ - __init__.py re-exports the public API (import path unchanged) - core.py SportsCore — fetch, cache, config, logos, fonts, odds, - view-model extraction, the skin seam - modes.py SportsUpcoming / SportsRecent / SportsLive - capabilities/ - celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins) - rotation.py RotationStrategy + registry +`src/base_classes/` has been removed: no scoreboard plugin built on it. B1 and +B2 below promoted code into it (`SportsCore`, the mode classes, +`CelebrationMixin`, the rotation strategies); the override points and +capabilities sections record that design, but none of it ships in core any +more. Shared sports code lives in `src/common`: +``` src/common/ sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration (content building stays in the plugins) @@ -85,13 +82,10 @@ src/common/ plugins' sports.py, and the _favorite_key seam ``` -`from src.base_classes.sports import SportsCore` keeps working — the package -`__init__` re-exports, so the conversion is invisible to every existing importer. - ### Converging on `src/common` -The scoreboards do not build on `src/base_classes`; their own `sports.py` copies -have moved past it. So shared code now lands in hardware-free `src/common` +The scoreboards never built on `src/base_classes` (now removed); their own +`sports.py` copies had moved past it. So shared code now lands in hardware-free `src/common` modules taken from the plugin copies, each a **new module** rather than growth on an existing one: a plugin that deletes a method copy and relies on an older module having gained it fails at runtime with an `AttributeError`, while a @@ -100,7 +94,7 @@ missing module fails at load, where the version checks can see it. listed below, for later phases); its parity test compares every body against the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and `test/test_common_is_hardware_free.py` keeps `src/common` free of -`rgbmatrix`, `src.base_classes` and `src.plugin_system`. How a plugin adopts a +`rgbmatrix`, `src.display_manager` and `src.plugin_system`. How a plugin adopts a module and drops its copy is documented in the plugins repo's `docs/plugin-development/08-shared-sports-code.md`. @@ -116,7 +110,6 @@ deprecation cycle. | `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` | | `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout | | `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op | -| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback | | `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `" SCORES!"` — only consulted when `CelebrationMixin` is present | | `win_phrase(team_abbr)` | Win-celebration wording | `" WINS!"` — mixin only | | `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["_abbr"]` | @@ -189,9 +182,9 @@ and a typo should cost the boost, not the scoreboard. When a plugin needs an ordering that core does not ship, it calls `register_rotation_strategy` to add its own — rather than core growing a branch for it. -`test_sports_capabilities.py` checks each strategy against a **verbatim -transcription** of the plugin code it replaces, over every live-game shape up to -four games. That differential is what B5 deletes the bundled copies on the +`test_sports_capabilities.py` (removed with `src/base_classes`) checked each +strategy against a **verbatim transcription** of the plugin code it replaces, +over every live-game shape up to four games. That differential is what B5 deletes the bundled copies on the strength of. ## Scroll display — where the promotion line falls @@ -507,9 +500,9 @@ What actually remains, smallest first: 3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`, `base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is the largest single duplication left: ~11,500 lines across eight plugins, with - ~36,500 more in the eight `sports.py`. Note that core already ships - `src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin - imports** — check whether it has drifted before treating it as the target. + ~36,500 more in the eight `sports.py`. The `src/base_classes/sports/` + package promoted in B1/B2 was never imported by a plugin and has been + removed, so the plugin copies are the only starting point. ## How to keep this project healthy @@ -542,6 +535,7 @@ Lessons this migration paid for, worth applying beyond it: - **A capability that is not opted into must not execute.** If you find yourself writing `if self._enabled` inside a base class, it belongs in a mixin. -- **Touch the view-model keys only additively.** Published skins depend on them. +- **Touch the view-model keys only additively.** The shared `src/common` + renderers read them. - **Every promotion lands with the characterization suite green**, and every pilot adoption lands with that plugin's harness and golden suites green. diff --git a/scripts/validate_skin.py b/scripts/validate_skin.py deleted file mode 100644 index 53cb6ebe..00000000 --- a/scripts/validate_skin.py +++ /dev/null @@ -1,250 +0,0 @@ -#!/usr/bin/env python3 -""" -Headless skin validator — render a skin against bundled fixture games at -multiple panel sizes without hardware, a network, or a running service. - - python scripts/validate_skin.py --skin my-skin - python scripts/validate_skin.py --skin my-skin --sport baseball \ - --size 128x32 --size 64x32 --output-dir /tmp/skin_renders - -For each (mode x size) it checks: the manifest loads and its API version -matches, the render raises no exception, the canvas isn't blank, and the -render finishes inside a time budget (warn — the live renderer runs every -display-loop pass, and a Pi is far slower than your dev machine). PNGs are -saved (native plus 4x nearest-neighbor previews) so you can eyeball the -result. Exit code is non-zero when any check fails. -""" - -import argparse -import json -import logging -import sys -import time -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(PROJECT_ROOT)) - -from PIL import Image, ImageDraw, ImageFont # noqa: E402 - -from src.common.font_layout import load_truetype # noqa: E402 - -FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures" -MODES = ("live", "recent", "upcoming") -SPORTS = ("baseball", "basketball", "football", "hockey") -RENDER_BUDGET_S = 0.100 - - -class FixtureHost: - """Stands in for a SportsCore instance: fonts, logger, logo loading, - outlined text — everything build_context needs, no network.""" - - def __init__(self, sport: str, skin_options: dict) -> None: - self.sport = sport - self.sport_key = sport - self.skin_options = skin_options - self.logger = logging.getLogger(f"validate_skin.{sport}") - self.fonts = self._load_fonts() - self._logo_cache = {} - self.display_manager = None # build_context is always given a size - - def _load_fonts(self) -> dict: - """Load the SportsCore font set (TTF, with PIL default fallback).""" - fonts = {} - try: - press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf") - small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf") - fonts['score'] = load_truetype(press, 10) - fonts['time'] = load_truetype(press, 8) - fonts['team'] = load_truetype(press, 8) - fonts['status'] = load_truetype(small, 6) - fonts['detail'] = load_truetype(small, 6) - fonts['rank'] = load_truetype(press, 10) - except IOError: - default = ImageFont.load_default() - for key in ('score', 'time', 'team', 'status', 'detail', 'rank'): - fonts[key] = default - return fonts - - def _load_and_resize_logo(self, team_id: str, team_abbrev: str, - logo_path, logo_url) -> "Image.Image | None": - """Load a fixture logo from disk (no downloads), cached per team.""" - if team_abbrev in self._logo_cache: - return self._logo_cache[team_abbrev] - path = Path(logo_path) - if not path.is_absolute(): - path = PROJECT_ROOT / path - if not path.exists(): - return None - logo = Image.open(path).convert('RGBA') - self._logo_cache[team_abbrev] = logo - return logo - - def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str, - position: tuple, font, - fill: tuple = (255, 255, 255), - outline_color: tuple = (0, 0, 0)) -> None: - """Classic outlined scorebug text, same as SportsCore's helper.""" - x, y = position - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), - (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - - -def load_fixture(sport: str, mode: str) -> dict: - with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f: - game = json.load(f) - # Real view models carry start_time_utc as a UTC datetime, not a string. - if isinstance(game.get("start_time_utc"), str): - from datetime import datetime - game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"]) - return game - - -def parse_size(value: str) -> "tuple[int, int]": - try: - w_text, h_text = value.lower().split("x") - w, h = int(w_text), int(h_text) - except ValueError as exc: - raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc - if w <= 0 or h <= 0: - raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}") - return w, h - - -def parse_options(value: str) -> dict: - try: - options = json.loads(value) - except json.JSONDecodeError as exc: - raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc - if not isinstance(options, dict): - raise argparse.ArgumentTypeError("options must be a JSON object") - return options - - -def display_path(path: Path) -> str: - """Repo-relative when inside the repo, absolute otherwise (--output-dir - may point anywhere, e.g. /tmp/skin_renders).""" - try: - return str(path.relative_to(PROJECT_ROOT)) - except ValueError: - return str(path) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)") - parser.add_argument("--sport", choices=SPORTS, - help="fixture sport (default: first sport the skin targets, else baseball)") - parser.add_argument("--size", action="append", type=parse_size, dest="sizes", - metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)") - parser.add_argument("--output-dir", type=Path, - default=PROJECT_ROOT / "skin_renders", - help="where rendered PNGs are written") - parser.add_argument("--options", type=parse_options, default={}, - help="skin_options JSON to pass the skin") - args = parser.parse_args() - sizes = args.sizes or [(128, 32), (64, 32)] - - logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") - - from src.skin_system import skin_runtime - from src.skin_system.skin_base import SKIN_API_VERSION - - skins = skin_runtime.discover_skins() - manifest = skins.get(args.skin) - if manifest is None: - print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}") - if skins: - print(f" installed skins: {', '.join(sorted(skins))}") - return 1 - - sport = args.sport - if sport is None: - declared = skin_runtime.skin_targets(manifest)[0] - sport = next((s for s in declared if s in SPORTS), "baseball") - - skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport, - options=args.options) - if skin is None: - print(f"FAIL: skin '{args.skin}' did not load " - f"(see log above; host API is {SKIN_API_VERSION})") - return 1 - - host = FixtureHost(sport, args.options) - args.output_dir.mkdir(parents=True, exist_ok=True) - failures = 0 - rendered = 0 - - for mode in MODES: - game = load_fixture(sport, mode) - render = getattr(skin, f"render_{mode}") - for width, height in sizes: - label = f"{mode}@{width}x{height}" - try: - # Warm-up render absorbs one-time font/image loads, second - # render is the one timed against the budget. - ctx = skin_runtime.build_context(host, game, size=(width, height)) - handled = render(ctx, dict(game)) - if handled: - ctx = skin_runtime.build_context(host, game, size=(width, height)) - started = time.monotonic() - handled = render(ctx, dict(game)) - elapsed = time.monotonic() - started - else: - elapsed = 0.0 - except Exception as e: - print(f"FAIL {label}: render raised {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - failures += 1 - continue - - if not handled: - print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)") - continue - - if ctx.canvas.size != (width, height): - print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it") - failures += 1 - continue - if ctx.canvas.convert("L").getbbox() is None: - print(f"FAIL {label}: canvas is blank — render returned True but drew nothing") - failures += 1 - continue - if elapsed > RENDER_BUDGET_S: - print(f"WARN {label}: render took {elapsed * 1000:.0f}ms " - f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)") - - out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png" - ctx.canvas.save(out) - preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST) - preview.save(out.with_name(out.stem + "_x4.png")) - print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}") - rendered += 1 - - # Vegas card, once per mode at the first size (optional API) - try: - width, height = sizes[0] - ctx = skin_runtime.build_context(host, game, size=(width, height)) - card = skin.render_vegas_card(ctx, dict(game)) - if card is not None: - out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png" - card.save(out) - print(f"ok {mode} vegas card -> {display_path(out)}") - except Exception as e: - print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}") - failures += 1 - - if rendered == 0 and failures == 0: - print(f"FAIL: skin '{args.skin}' rendered nothing — no render_ returned True") - return 1 - print(f"\n{'FAILED' if failures else 'PASSED'}: {rendered} renders, {failures} failures " - f"(PNGs in {args.output_dir})") - return 1 if failures else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skins/README.md b/skins/README.md deleted file mode 100644 index 78cb534e..00000000 --- a/skins/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# skins/ - -> **Not supported yet.** The current scoreboard plugins don't render skins, -> so a skin placed here and selected in config has no effect, and the web UI -> and Plugin Store don't offer them. See -> [docs/SKIN_SYSTEM.md](../docs/SKIN_SYSTEM.md#status-not-supported-yet). - -User-installable **visual skins** for the sports scoreboards. Each -subdirectory is one skin: - -```text -skins// - skin.json # manifest - skin.py # renderer (a ScoreboardSkin subclass) - preview.png # optional -``` - -- Install a skin: `git clone skins/`. The Plugin Store - refuses registry entries with `"type": "skin"` while skins don't render. -- Select it: set `"skin": ""` in the plugin's section of - `config/config.json`. The web UI no longer shows a Visual Skin dropdown. -- Build one: start from `example-classic-baseball/` and read - [docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with - `python scripts/validate_skin.py --skin `. - -Skins survive plugin reinstalls/updates (that's why they live here and not in -the plugin's directory). A skin is Python at the same trust level as a -plugin — review before installing. diff --git a/skins/example-classic-baseball/preview.png b/skins/example-classic-baseball/preview.png deleted file mode 100644 index 37ad2dad218228af08ddfdd548fada9648962677..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5385 zcmaJ_c|4SD+a61{Vr(H{648@vFCt=up|VGKlBK4kv1eb#h%6CPkCN=BP}v_#2$O_7 zVceyWEnAskFku#Eee3Q0ec$u`Uf=iEegAR)uKT=>>$r~dIL)(;q(UEw+nQ>U?gB3r|;q&~G{7y2;$(Nz484s;?FZuKz z`s3llBgRVTten0pDHEc~dU`I+ID*v7__(3;jPmWL;*+KPNV#SW2@9dK8b&>W>~iq{iDF5*Js@ww6-LJt%U=ES!_@23}B5VD&^Be0uhPQWJ{o z4Y=1`l9<^q1`R769F?TgTq5W6Sb<%wHxTJh!NDcpoQ}Fvh93E4CSLC~2GpjK^K&Mc z8;O9sRyA8V{Z&aDj&IK_NJ#nZ`TwYaiptPC;k(I%D`_O}wCec{iFZXTp9jJTBl}Im zD;aYvO-&5;w%qIYb1LDNs;g|6F6R$-ylnG`kYjNK#T3MmtnDzsz25s%)TL?J0}1CP z_`b(^^@Bd7UtPsbwgkY^4=GJ-e%5C6M^E~-DlL==T4WxFg!T(*5Za>H_ zfLBK#spWV>iM4~yQO3t7HZXTLx6BbN(lcVQ&I`ldvj+ni!y|0Kijp%;SI@rb?t6{R z)4P^QblxkIu^tp6>(y~1mzcA$2rD%Tv!ML-?L92o<}k*d?jQg$+(^2$Ij$#g&n&;B z$M>iW1z2%}Jz=-c6llk}Q@wyav6FslkAg7RAbbMLDe6kofpupr>%krQs!QK*mJ?|X zYlO-djhF%1$P8;{QzxQ=c&5#hVm<-s{@cmU^P3AP=Pe)pXUQ62-a1e!A1+FaMln2%l!Wm;{fp6cwVIBo{AL-y!I{<*(812@Ht@LGjd6_n# zET(e}ssN-IE76afI(qN?DEK^8)d z(hi{8;#Yp_XM^l^I7E_msh$lbwZj;w7_ZOjj{2HGJOh71^u1h8G*Vdfd>&gLHWWg= z$mC^RF(YDoodwryCMTOiDk?BlwI0eHVJogc4EeryEwzWrWBJ~EhqO$0gxKL8%+|wV zS5_h*_eq-CnP!{unhPI4*Kju8BxG~1C7(6dkjh%FQXO)%R=Wfe)zU8o@a18nAMVJ< zdu(WE9@B5x)&RnnSauO1_5CVcle?kbE;IV3lzWMCC$}KPgfyZE$hz0d6nnX<8f!R) zdo9ix;*#P{*CjDd@ulMGTd+)H1Dm$0mJ5{C6hWAeyrDzHkukNEr0@e^xdb`Bm=ijt zCb2sgdkLyzL+w&P{`5-iY=+jc(T=OJ?UQ~XK5=V=dE9xI>4^&Z)MOoXKrEPfpBQ#J zyIt#+yiIY#(qQ&(80W}=@DI$n&RVOvI|RM!R$Ms*!{=hFT?FUp*~z2ZB@5o{x%~HF z1M_L|fdVje%3h0B36B6~}Uq|`G@BG-$EPZ}!AU%WWid?Qd; z88uMNe*9`BjkrW^DJ}{eLPc-feUb)t_IN=NLNE$1zKu%j_2%XAy7E;Y%BBq+;3KX( zdm3+?G+mmWmjt}mc9D;bd8NX94PzuD0x&Qzz<3%4OS5|?nlP4qeVE0wt65ozwAcB% z81;||OtF}No}>iYf97tbqKTCac}6OGL%4qK;_Xn|ETY8L(jBeIF3$)UER~p+Ca}i$ zc7I#CPUC?lcI@d0`|Kr7$aEF%{lxnMg7>8|v-NJ(6;?z#by~M?@}*`7T2gVJs<)K* zot1VIF;j75J&ai-wb?29>fRn|^2NzdXejxJmwID-7p)y*G0v~ApP(tf>uyZUlAvPY zXKc*vStLRv*}jHKenuvfW^t(;$~!jm$3CXi7$-lY4W(8j+ROdtEr8kHl3shymziQY zN}7}Q)4XI|emY0I|0#dJ$Ux{;Jo!#eql~yR*IZ_G(D_0IE-?Vz&x`ZH7eHaXpVTO{ z6}@k7SVpX6I?4&fXf;4cxv~Cqia1Ifj>b0Fu{UivpVi_>>IjlG`n$}_*YU^Y>j)*- zns`t%%|H8)LNEP9h)i}s)3(4*{d*&vByMp$RqCh0WCD$;)F^rPT1oczEgVd6`YY$4 z!c9Q1K;2|H1#IQ$@lq=cb!zE-Ar0Ktg2ECDr(R}>-Iaex425?c3WSOT@LG2>UJyU@ zrc+}Ka|W+Ja9u^+6IAnlws_2I;2*E-P| z@W{WOnjC5uS`)f2EReK9GHu~D>p3N^MSQV++|B&@TOr#8*j~M>wdpoHdkaijuAW6r zVG%l1J9D3pHTI+a{)!HxHQx->P?8Kb+={+w1J zUl)0e76<_8E9hRM+kDfnVrnFt1CDcF1}%1rM%fJzn{wBuJ>{Ci@r}Q{{woUX%->LZVdITu zgai~iAFs>$$RuZR1Q3ut<<|G$IKI%OdG9^NIzIr(X*|N!c6=2wLyAkS+E8-gF;f9g zQkj+Hu@SpOr9j(|g|L1W8!v|RqX&*5_t>?h=omux|5&mUdv0y$#agr#`q&t=kIKI- z)!QZV^X%+t>AS#t1@t=+1tyl{=CEVB@NnJjzkqRa3!ZC!3wfr`bV1Iq!l-ql8NOKv z)!azB%Ghb1hQH{!t_sQ%MoynSRge=kI!^Y3esN9u^?AXrZb-UA`D$p`A~R}H12Tk+ z3!;tZUgz7zYGm+GE}NN+?cC{60+sCb>)rS~;`EK9@;TjgS|#VQkNkAK=%_ZAl8iee z{a0|RiX3wTucm}v+jn6dn*9I#KkO|?Wba-o-&hasmMQ3p7LC8ZV~4{^0JyKhtBCg> zJ`~2;8+6;{Z@rF9L-v}P&_JR34?dI8@Km^m5cBXoin4e8r2B~o_8|wR-V1YVDXSAd zW&wG5!_l{lN1X3S?zX`;;OfQ$?IEY44Q*ZNO2LfMh| z;ui0kg#T_+a^zl*1Wj2z8pmfUiEQ03BY43%hHCH9sYIkd&6yKhev=EA2 z`sEgXsb!x8-?M6Wb^qYtI^q{59cHpgG)?ZNR@QpL;4I%a9g)H~>O~Ww@uOw4Ts()3 z2??InG6tPwdiUt|G``V&+t?yXL94seLk~b-4U)-AF)pVLDXx=#$8Z*WtQMQXu`OQu zjBYO;)T5$%W>ubCudQ|6#D?|pKHdtbL+AWG>w#Vpq#L7^mGFR-6rW) znMuFJhHy~LY#vn7spl7RpRZ7ooLcfXxU_VDf ze|e=o=}Z_SkBYJ$Dpc?Arw0T3_wUCXl$UP?&ykun1@0m2IEgfH~t{ti4>X9ohW1{QF&D>pC77>=c;~0SRUG#tZ?Y|v;y5%uYJI49hdmk?^&A6S- zZy4?2Vhv;b^l^FiYJNCkJypBBzl-Nz*3I64(I_+UeV$=D{YdvQe$3$eqss}0!xyZ8 zrjcSPB!%b?Edc z5wg~kMWrg38{1xZt0P>HV9m)RI~`%&XGU#i9f=wiT>$A?ANK4RAUA?-VQ3t?nz*x9pAMaVLQ=Kcts}@OdJcF!8oIwo1XhtMCdJa|I%HByLSYyzVr+~lo<|JbBLcju@YXcWs$v`H!qXdRaf>@ z@IuFSmRCP8gM!8xeu6LAXDDsH#l#~IAI1gSr28B0R%Y9|V1u1MlIIMuPE}^lKaj>@ zJ}K-NmF$A*gU64*-TN#};le-%PyJwXTN-F;;H$Sk^aH*Usqsl+>PCZh*y7l8%zMH! zoG$OiJ_cJb-IcH$u4vBijjLoOdX>7mM|)GKd9pCb4m%oiurM;n>2f!|zegI?{EtS} zn>)aeuNGAP(s7qDd^t7tocc;Z)qZT8H)U+0-U|14OAqMk>iF`mdbh|_V)*Q>>8Zv_ ze*9kJSVl8(bMq$PqwRI9qs6^7QI{t2F_%||(XUzwOA? zWOE?0$oV&W09zSN$?V9)hBRUNw!LB<;wbX*w))yTdUO+*f8F+ShG5_}j>>Lr3YqOr zL@TfVOjWIT?n&giarJWc?;SgNL`Ho?mxI1QWPjOk!n0+F2t!hF0mSCaXDE&F#0$@z zzGcVmUWIw7M6#X;|G1OKFV>K<-Si&Vnef@R zENom0;3V?`U_<~c8pW*jmR;llG1%dib`qwD9$uhpZ4F~<7$iBvW59Ma&%Xb#LGVVj z73rqVEGqPjADKTLf=ZhF9qY~J1hbgl=pRI?a}-%F`D#-Q-JeY(L{<08MqilS?{%*# z2%k8i#+dTUc1inFs&F_I&FQbS8{1w;ezX_%G8zFjypX-$wEN8B1AkPWd#$vhaBs<1 zzN@==H!Rn3sEjPkerkGeoZEjeB%?$Zo<^)_Q1jK?wziy`E?N0@lH2#p^YB|E;mcjZqgkTJ<)NV`7U-|_>_ z=2J?VjI=!Izuq}0Bo7gtKmOs2Ig;>?%=8{q)XS?~Rvk1kcgS<{5>WF7M zDUrARMv8umn=v70^vlb7fcbS5|7X6IO-<;eJ>DmFNznX{p5Z11ksFHTkF}q(V}B0S z20xc}LCCVmZ+}jfhI){8V%E0Id;U8xpI72Dw`=K4KUSqgy3tUHVl1}gpk7R!=5`A+ g*dGP|uW{fGR4@)>Zp8X(|8vrL7Up18XW^UtPZ#o&y8r+H diff --git a/skins/example-classic-baseball/skin.json b/skins/example-classic-baseball/skin.json deleted file mode 100644 index ccec3071..00000000 --- a/skins/example-classic-baseball/skin.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "example-classic-baseball", - "name": "Example: Classic Baseball", - "version": "1.0.0", - "author": "LEDMatrix", - "description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.", - "skin_api_version": "1.0.0", - "targets": { - "sports": [ - "baseball" - ], - "sport_keys": [ - "mlb", - "milb" - ] - }, - "entry_point": "skin.py", - "class_name": "ClassicBaseballSkin", - "modes": [ - "live", - "recent", - "upcoming" - ], - "preview": "preview.png" -} diff --git a/skins/example-classic-baseball/skin.py b/skins/example-classic-baseball/skin.py deleted file mode 100644 index 6882a52c..00000000 --- a/skins/example-classic-baseball/skin.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Example: Classic Baseball — the reference skin. - -Shows the whole skin API surface on purpose: adaptive regions -(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit), -logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases -diamond), and per-user options (ctx.options). Everything is derived from -ctx and the game dict — a skin holds no state, does no I/O, and never -touches the display. - -Copy this directory to skins//, rename the class and the -manifest fields, and run: - - python scripts/validate_skin.py --skin -""" - -from src.adaptive_layout import LADDER_GRID, scoreboard_regions -from src.skin_system.skin_base import ScoreboardSkin, SkinContext - -DEFAULT_ACCENT = (255, 200, 0) - - -class ClassicBaseballSkin(ScoreboardSkin): - """Reference baseball skin: classic scorebug with bases/outs/count.""" - - def __init__(self, manifest: dict, options: dict): - super().__init__(manifest, options) - # Validate user options once at load time (fail fast, fall back - # gracefully) rather than surprising every render. - accent = self.options.get("accent_color", DEFAULT_ACCENT) - if (isinstance(accent, (list, tuple)) and len(accent) == 3 - and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)): - self._accent_color = tuple(accent) - else: - import logging - logging.getLogger(__name__).error( - "accent_color must be three 0-255 integers, got %r; using default", accent) - self._accent_color = DEFAULT_ACCENT - - # -- shared pieces ---------------------------------------------------- - - def _accent(self, ctx: SkinContext) -> tuple: - """Users can recolor the skin from config via skin_options.""" - return self._accent_color - - def _draw_card(self, ctx: SkinContext, game: dict, status: str, - center_lines: list, detail: str) -> None: - """The common card: logos left/right, status on top, the given - center content, detail along the bottom.""" - regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout) - - ctx.draw_image(ctx.load_logo("away"), regions.away_slot, - cache_key=f"logo:{game.get('away_abbr')}") - ctx.draw_image(ctx.load_logo("home"), regions.home_slot, - cache_key=f"logo:{game.get('home_abbr')}") - - if status: - fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID) - ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx)) - - if center_lines: - rows = regions.score_area.split_v(*[1] * len(center_lines)) - for line, row in zip(center_lines, rows): - if line: - fit = ctx.layout.fit_text(line, row, LADDER_GRID) - ctx.draw_fit(fit, row) - - if detail: - fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID) - ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160)) - - def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None: - """Raw-PIL escape hatch: a bases diamond + out dots in the bottom - band, sized from the layout scale so it works on any panel.""" - size = ctx.layout.px(3, minimum=2) # half-diagonal of one base - gap = ctx.layout.px(1) - cx = ctx.width // 2 - cy = ctx.height - (size * 2) - 1 - - bases = game.get("bases_occupied") or [False, False, False] - # (dx, dy) per base: first (right), second (top), third (left) - offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)] - for occupied, (dx, dy) in zip(bases, offsets): - x, y = cx + dx, cy + dy - diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)] - if occupied: - ctx.draw.polygon(diamond, fill=self._accent(ctx)) - else: - ctx.draw.polygon(diamond, outline=(110, 110, 110)) - - outs = min(int(game.get("outs") or 0), 3) - r = max(1, size - 1) - for i in range(3): - x = cx + (i - 1) * (2 * r + 2 * gap) - y = ctx.height - r - 1 - dot = [x - r, y - r, x + r, y + r] - if i < outs: - ctx.draw.ellipse(dot, fill=(255, 255, 255)) - else: - ctx.draw.ellipse(dot, outline=(110, 110, 110)) - - # -- the three modes -------------------------------------------------- - - def render_live(self, ctx: SkinContext, game: dict) -> bool: - half = "▲" if game.get("inning_half") == "top" else "▼" - inning = game.get("inning") or "" - status = f"{half}{inning}" if inning else game.get("status_text", "") - score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" - count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}" - - self._draw_card(ctx, game, status, [score], "") - self._draw_bases_and_outs(ctx, game) - - # Ball-strike count in the top-left corner, over the away logo. - fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID) - ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2), - color=(200, 200, 200)) - return True - - def render_recent(self, ctx: SkinContext, game: dict) -> bool: - score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" - self._draw_card(ctx, game, game.get("status_text", "Final"), - [score], game.get("series_summary", "")) - return True - - def render_upcoming(self, ctx: SkinContext, game: dict) -> bool: - matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}" - self._draw_card(ctx, game, game.get("game_date", ""), - [matchup, game.get("game_time", "")], - f"{game.get('away_record', '')} {game.get('home_record', '')}".strip()) - return True diff --git a/src/base_classes/api_extractors.py b/src/base_classes/api_extractors.py deleted file mode 100644 index 97155d86..00000000 --- a/src/base_classes/api_extractors.py +++ /dev/null @@ -1,371 +0,0 @@ -""" -Abstract API Data Extraction Layer - -This module provides a pluggable system for extracting game data from different -sports APIs. Each sport can have its own extractor that handles sport-specific -fields and data structures. -""" - -from abc import ABC, abstractmethod -from typing import Dict, Optional -import logging -from datetime import datetime -import pytz - -class APIDataExtractor(ABC): - """Abstract base class for API data extraction.""" - - def __init__(self, logger: logging.Logger): - self.logger = logger - - @abstractmethod - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract common game details from raw API data.""" - - @abstractmethod - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract sport-specific fields (downs, innings, periods, etc.).""" - - def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: - """Extract common game details that work across all sports.""" - if not game_event: - return None, None, None, None, None - - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - situation = competition.get("situation") - - # Parse game time - start_time_utc = None - try: - # Parse the datetime string - if game_date_str.endswith('Z'): - game_date_str = game_date_str.replace('Z', '+00:00') - dt = datetime.fromisoformat(game_date_str) - # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) - if dt.tzinfo is None: - # If naive, assume it's UTC - start_time_utc = dt.replace(tzinfo=pytz.UTC) - else: - # Convert to pytz.UTC for consistency - start_time_utc = dt.astimezone(pytz.UTC) - except ValueError: - self.logger.warning(f"Could not parse game date: {game_date_str}") - - # Extract teams - home_team = next((c for c in competitors if c.get("homeAway") == "home"), None) - away_team = next((c for c in competitors if c.get("homeAway") == "away"), None) - - if not home_team or not away_team: - self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") - return None, None, None, None, None - - return { - "game_event": game_event, - "competition": competition, - "status": status, - "situation": situation, - "start_time_utc": start_time_utc, - "home_team": home_team, - "away_team": away_team - }, home_team, away_team, status, situation - - except Exception as e: - self.logger.error(f"Error extracting common details: {e}") - return None, None, None, None, None - - -class ESPNFootballExtractor(APIDataExtractor): - """ESPN API extractor for football (NFL/NCAA).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract football game details from ESPN API.""" - common_data, home_team, away_team, status, situation = self._extract_common_details(game_event) - if not common_data: - return None - - try: - # Extract basic team info - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Extract sport-specific fields - sport_fields = self.get_sport_specific_fields(game_event) - - # Build game details - details = { - "id": game_event.get("id"), - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_team_name": home_team["team"].get("displayName", ""), - "away_team_name": away_team["team"].get("displayName", ""), - "status_text": status["type"].get("shortDetail", ""), - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - **sport_fields # Add sport-specific fields - } - - return details - - except Exception as e: - self.logger.error(f"Error extracting football game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract football-specific fields.""" - try: - competition = game_event["competitions"][0] - status = competition["status"] - situation = competition.get("situation", {}) - - sport_fields = { - "down": "", - "distance": "", - "possession": "", - "is_redzone": False, - "home_timeouts": 0, - "away_timeouts": 0, - "scoring_event": "" - } - - if situation and status["type"]["state"] == "in": - sport_fields.update({ - "down": situation.get("down", ""), - "distance": situation.get("distance", ""), - "possession": situation.get("possession", ""), - "is_redzone": situation.get("isRedZone", False), - "home_timeouts": situation.get("homeTimeouts", 0), - "away_timeouts": situation.get("awayTimeouts", 0) - }) - - # Detect scoring events - status_detail = status["type"].get("detail", "").lower() - if "touchdown" in status_detail or "field goal" in status_detail: - sport_fields["scoring_event"] = status_detail - - return sport_fields - - except Exception as e: - self.logger.error(f"Error extracting football-specific fields: {e}") - return {} - - -class ESPNBaseballExtractor(APIDataExtractor): - """ESPN API extractor for baseball (MLB).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract baseball game details from ESPN API.""" - common_data, home_team, away_team, status, situation = self._extract_common_details(game_event) - if not common_data: - return None - - try: - # Extract basic team info - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Extract sport-specific fields - sport_fields = self.get_sport_specific_fields(game_event) - - # Build game details - details = { - "id": game_event.get("id"), - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_team_name": home_team["team"].get("displayName", ""), - "away_team_name": away_team["team"].get("displayName", ""), - "status_text": status["type"].get("shortDetail", ""), - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - **sport_fields # Add sport-specific fields - } - - return details - - except Exception as e: - self.logger.error(f"Error extracting baseball game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract baseball-specific fields.""" - try: - competition = game_event["competitions"][0] - status = competition["status"] - situation = competition.get("situation", {}) - - sport_fields = { - "inning": "", - "outs": 0, - "bases": "", - "strikes": 0, - "balls": 0, - "pitcher": "", - "batter": "" - } - - if situation and status["type"]["state"] == "in": - sport_fields.update({ - "inning": situation.get("inning", ""), - "outs": situation.get("outs", 0), - "bases": situation.get("bases", ""), - "strikes": situation.get("strikes", 0), - "balls": situation.get("balls", 0), - "pitcher": situation.get("pitcher", ""), - "batter": situation.get("batter", "") - }) - - return sport_fields - - except Exception as e: - self.logger.error(f"Error extracting baseball-specific fields: {e}") - return {} - - -class ESPNHockeyExtractor(APIDataExtractor): - """ESPN API extractor for hockey (NHL/NCAA).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract hockey game details from ESPN API.""" - common_data, home_team, away_team, status, situation = self._extract_common_details(game_event) - if not common_data: - return None - - try: - # Extract basic team info - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Extract sport-specific fields - sport_fields = self.get_sport_specific_fields(game_event) - - # Build game details - details = { - "id": game_event.get("id"), - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_team_name": home_team["team"].get("displayName", ""), - "away_team_name": away_team["team"].get("displayName", ""), - "status_text": status["type"].get("shortDetail", ""), - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - **sport_fields # Add sport-specific fields - } - - return details - - except Exception as e: - self.logger.error(f"Error extracting hockey game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract hockey-specific fields.""" - try: - competition = game_event["competitions"][0] - status = competition["status"] - situation = competition.get("situation", {}) - - sport_fields = { - "period": "", - "period_text": "", - "power_play": False, - "penalties": "", - "shots_on_goal": {"home": 0, "away": 0} - } - - if situation and status["type"]["state"] == "in": - period = status.get("period", 0) - period_text = "" - if period == 1: - period_text = "P1" - elif period == 2: - period_text = "P2" - elif period == 3: - period_text = "P3" - elif period > 3: - period_text = f"OT{period-3}" - - sport_fields.update({ - "period": str(period), - "period_text": period_text, - "power_play": situation.get("isPowerPlay", False), - "penalties": situation.get("penalties", ""), - "shots_on_goal": { - "home": situation.get("homeShots", 0), - "away": situation.get("awayShots", 0) - } - }) - - return sport_fields - - except Exception as e: - self.logger.error(f"Error extracting hockey-specific fields: {e}") - return {} - - -class SoccerAPIExtractor(APIDataExtractor): - """Generic extractor for soccer APIs (different structure than ESPN).""" - - def extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract soccer game details from various soccer APIs.""" - # This would need to be adapted based on the specific soccer API being used - # For now, return a basic structure - try: - return { - "id": game_event.get("id"), - "home_abbr": game_event.get("home_team", {}).get("abbreviation", ""), - "away_abbr": game_event.get("away_team", {}).get("abbreviation", ""), - "home_score": str(game_event.get("home_score", "0")), - "away_score": str(game_event.get("away_score", "0")), - "home_team_name": game_event.get("home_team", {}).get("name", ""), - "away_team_name": game_event.get("away_team", {}).get("name", ""), - "status_text": game_event.get("status", ""), - "is_live": game_event.get("is_live", False), - "is_final": game_event.get("is_final", False), - "is_upcoming": game_event.get("is_upcoming", False), - **self.get_sport_specific_fields(game_event) - } - except Exception as e: - self.logger.error(f"Error extracting soccer game details: {e}") - return None - - def get_sport_specific_fields(self, game_event: Dict) -> Dict: - """Extract soccer-specific fields.""" - try: - return { - "half": game_event.get("half", ""), - "stoppage_time": game_event.get("stoppage_time", ""), - "cards": { - "home_yellow": game_event.get("home_yellow_cards", 0), - "away_yellow": game_event.get("away_yellow_cards", 0), - "home_red": game_event.get("home_red_cards", 0), - "away_red": game_event.get("away_red_cards", 0) - }, - "possession": { - "home": game_event.get("home_possession", 0), - "away": game_event.get("away_possession", 0) - } - } - except Exception as e: - self.logger.error(f"Error extracting soccer-specific fields: {e}") - return {} - - -# Factory function removed - sport classes now instantiate extractors directly diff --git a/src/base_classes/baseball.py b/src/base_classes/baseball.py deleted file mode 100644 index f36ca9db..00000000 --- a/src/base_classes/baseball.py +++ /dev/null @@ -1,692 +0,0 @@ -""" -Baseball Base Classes - -This module provides baseball-specific base classes that extend the core sports functionality -with baseball-specific logic for innings, outs, bases, strikes, balls, etc. -""" - -import logging -import time -from typing import Any, Dict, Optional - -from PIL import Image, ImageDraw, ImageFont - -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive, SportsRecent - - -class Baseball(SportsCore): - """Base class for baseball sports with common functionality.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - # Baseball-specific configuration - self.show_innings = self.mode_config.get("show_innings", True) - self.show_outs = self.mode_config.get("show_outs", True) - self.show_bases = self.mode_config.get("show_bases", True) - self.show_count = self.mode_config.get("show_count", True) - self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False) - self.show_series_summary = self.mode_config.get("show_series_summary", False) - self.data_source = ESPNDataSource(logger) - self.sport = "baseball" - - def _is_baseball_game_live(self, game: Dict) -> bool: - """Check if a baseball game is currently live.""" - try: - # Check if game is marked as live - is_live = game.get("is_live", False) - if is_live: - return True - - # Check inning to determine if game is active - inning = game.get("inning", "") - if inning and inning != "Final": - return True - - return False - - except Exception as e: - self.logger.error(f"Error checking if baseball game is live: {e}") - return False - - def _get_baseball_game_status(self, game: Dict) -> str: - """Get baseball-specific game status.""" - try: - status = game.get("status_text", "") - inning = game.get("inning", "") - - if self._is_baseball_game_live(game): - if inning: - return f"Live - {inning}" - else: - return "Live" - elif game.get("is_final", False): - return "Final" - elif game.get("is_upcoming", False): - return "Upcoming" - else: - return status - - except Exception as e: - self.logger.error(f"Error getting baseball game status: {e}") - return "" - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - details, home_team, away_team, status, situation = ( - self._extract_game_details_common(game_event) - ) - if details is None or home_team is None or away_team is None or status is None: - return - try: - # print(status["type"]["state"]) - # exit() - game_status = status["type"]["name"].lower() - status_state = status["type"]["state"].lower() - # Get team abbreviations - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] - - # Check if this is a favorite team game - is_favorite_game = ( - home_abbr in self.favorite_teams or away_abbr in self.favorite_teams - ) - - # Log all teams found for debugging - self.logger.debug( - f"Found game: {away_abbr} @ {home_abbr} (Status: {game_status}, State: {status_state})" - ) - - # Only log detailed information for favorite teams - if is_favorite_game: - # Use the validated competition-level `status` here too. MiLB - # events carry no event-level one, so this debug line raised a - # KeyError and dropped the very games it was meant to help - # diagnose -- and only for favourites, which is the worst way - # for it to fail. - self.logger.debug(f"Full status data: {status}") - self.logger.debug(f"Status type: {game_status}, State: {status_state}") - self.logger.debug(f"Status detail: {status['type'].get('detail', '')}") - self.logger.debug( - f"Status shortDetail: {status['type'].get('shortDetail', '')}" - ) - series = game_event["competitions"][0].get("series", None) - series_summary = "" - if series: - series_summary = series.get("summary", "") - # Get game state information - if status_state == "in": - # For live games, get detailed state - # Use the competition-level `status` already validated by - # _extract_game_details_common. Real ESPN events duplicate - # status at the event top level, but MiLB events (synthesized - # from the MLB Stats API into an ESPN-like shape) populate - # only the competition-level one, so the top-level lookup - # raised a bare KeyError and dropped the event. - inning = status.get( - "period", 1 - ) # Get inning from status period - - # Get inning information from status - status_detail = status["type"].get("detail", "").lower() - status_short = status["type"].get("shortDetail", "").lower() - - if is_favorite_game: - self.logger.debug( - f"Raw status detail: {status['type'].get('detail')}" - ) - self.logger.debug( - f"Raw status short: {status['type'].get('shortDetail')}" - ) - - # Determine inning half from status information - inning_half = "top" # Default - - # Handle end of inning: next inning is top - if "end" in status_detail or "end" in status_short: - inning_half = "top" - inning = ( - status.get("period", 1) + 1 - ) # Use period and increment for next inning - if is_favorite_game: - self.logger.debug( - f"Detected end of inning. Setting to Top {inning}" - ) - # Handle middle of inning: next is bottom of current inning - elif "mid" in status_detail or "mid" in status_short: - inning_half = "bottom" - if is_favorite_game: - self.logger.debug( - f"Detected middle of inning. Setting to Bottom {inning}" - ) - # Handle bottom of inning - elif ( - "bottom" in status_detail - or "bot" in status_detail - or "bottom" in status_short - or "bot" in status_short - ): - inning_half = "bottom" - if is_favorite_game: - self.logger.debug(f"Detected bottom of inning: {inning}") - # Handle top of inning - elif "top" in status_detail or "top" in status_short: - inning_half = "top" - if is_favorite_game: - self.logger.debug(f"Detected top of inning: {inning}") - - if is_favorite_game: - self.logger.debug(f"Status detail: {status_detail}") - self.logger.debug(f"Status short: {status_short}") - self.logger.debug(f"Determined inning: {inning_half} {inning}") - - # Get count and bases from situation - situation = game_event["competitions"][0].get("situation", {}) - - if is_favorite_game: - self.logger.debug(f"Full situation data: {situation}") - - # Get count from the correct location in the API response - count = situation.get("count", {}) - balls = count.get("balls", 0) - strikes = count.get("strikes", 0) - outs = situation.get("outs", 0) - - # Add detailed logging for favorite team games - if is_favorite_game: - self.logger.debug(f"Full situation data: {situation}") - self.logger.debug(f"Count object: {count}") - self.logger.debug( - f"Raw count values - balls: {balls}, strikes: {strikes}" - ) - self.logger.debug(f"Raw outs value: {outs}") - - # Try alternative locations for count data - if balls == 0 and strikes == 0: - # First try the summary field - if "summary" in situation: - try: - count_summary = situation["summary"] - balls, strikes = map(int, count_summary.split("-")) - if is_favorite_game: - self.logger.debug( - f"Using summary count: {count_summary}" - ) - except (ValueError, AttributeError): - if is_favorite_game: - self.logger.debug("Could not parse summary count") - else: - # Check if count is directly in situation - balls = situation.get("balls", 0) - strikes = situation.get("strikes", 0) - if is_favorite_game: - self.logger.debug( - f"Using direct situation count: balls={balls}, strikes={strikes}" - ) - self.logger.debug( - f"Full situation keys: {list(situation.keys())}" - ) - - if is_favorite_game: - self.logger.debug(f"Final count: balls={balls}, strikes={strikes}") - - # Get base runners - bases_occupied = [ - situation.get("onFirst", False), - situation.get("onSecond", False), - situation.get("onThird", False), - ] - - if is_favorite_game: - self.logger.debug(f"Bases occupied: {bases_occupied}") - else: - # Default values for non-live games - inning = 1 - inning_half = "top" - balls = 0 - strikes = 0 - outs = 0 - bases_occupied = [False, False, False] - - details.update( - { - "status": game_status, - "status_state": status_state, - "inning": inning, - "inning_half": inning_half, - "balls": balls, - "strikes": strikes, - "outs": outs, - "bases_occupied": bases_occupied, - "start_time": game_event["date"], - "series_summary": series_summary, - } - ) - - # Basic validation (can be expanded) - if not details["home_abbr"] or not details["away_abbr"]: - self.logger.warning( - f"Missing team abbreviation in event: {details['id']}" - ) - return None - - self.logger.debug( - f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" - ) - - return details - except Exception as e: - # Log the problematic event structure if possible - self.logger.error( - f"Error extracting game details: {e} from event: {game_event.get('id')}", - exc_info=True, - ) - return None - - def display_series_summary(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - if not self.show_series_summary: - return - - series_summary = game.get("series_summary", "") - bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time']) - height = bbox[3] - bbox[1] - shots_y = (self.display_height - height) // 2 - shots_width = draw_overlay.textlength(series_summary, font=self.fonts['time']) - shots_x = (self.display_width - shots_width) // 2 - self._draw_text_with_outline( - draw_overlay, series_summary, (shots_x, shots_y), self.fonts['time'] - ) - -class BaseballRecent(Baseball, SportsRecent): - """Base class for recent baseball games.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - self.display_series_summary(game, draw_overlay) - - -class BaseballLive(Baseball, SportsLive): - """Base class for live baseball games.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - # self.current_game["bases_occupied"] = [ - # random.choice([True, False]) for _ in range(3) - # ] - # self.current_game["balls"] = random.choice([1, 2, 3]) - # self.current_game["strikes"] = random.choice([1, 2]) - # self.current_game["outs"] = random.choice([1, 2]) - if self.current_game["inning_half"] == "top": - self.current_game["inning_half"] = "bottom" - else: - self.current_game["inning_half"] = "top" - self.current_game["inning"] += 1 - self.current_game["balls"] = (self.current_game["balls"] + 1) % 4 - self.current_game["strikes"] = (self.current_game["strikes"] + 1) % 3 - self.current_game["outs"] = (self.current_game["outs"] + 1) % 3 - self.current_game["bases_occupied"] = [ - not b for b in self.current_game["bases_occupied"] - ] - if self.current_game["inning"] % 2 == 0: - self.current_game["home_score"] = str( - int(self.current_game["home_score"]) + 1 - ) - else: - self.current_game["away_score"] = str( - int(self.current_game["away_score"]) + 1 - ) - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring - try: - main_img = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) - ) - overlay = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) - ) - draw_overlay = ImageDraw.Draw( - overlay - ) # Draw text elements on overlay first - - home_logo = self._load_and_resize_logo( - game["home_id"], - game["home_abbr"], - game["home_logo_path"], - game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game["away_id"], - game["away_abbr"], - game["away_logo_path"], - game.get("away_logo_url"), - ) - - if not home_logo or not away_logo: - self.logger.error( - f"Failed to load logos for live game: {game.get('id')}" - ) # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) - self._draw_text_with_outline( - draw_final, "Logo Error", (5, 5), self.fonts["status"] - ) - self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = ( - self.display_width - home_logo.width + 10 - ) # adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 # adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Live Game Specific Elements --- - - # Define default text color - text_color = (255, 255, 255) - - # Draw Inning (Top Center) - inning_half = game["inning_half"] - inning_num = game["inning"] - if game["is_final"]: - inning_text = "FINAL" - else: - inning_half_indicator = ( - "▲" if game["inning_half"].lower() == "top" else "▼" - ) - inning_num = game["inning"] - inning_text = f"{inning_half_indicator}{inning_num}" - - inning_bbox = draw_overlay.textbbox( - (0, 0), inning_text, font=self.display_manager.font - ) - inning_width = inning_bbox[2] - inning_bbox[0] - inning_x = (self.display_width - inning_width) // 2 - inning_y = 1 # Position near top center - # draw_overlay.text((inning_x, inning_y), inning_text, fill=(255, 255, 255), font=self.display_manager.font) - self._draw_text_with_outline( - draw_overlay, - inning_text, - (inning_x, inning_y), - self.display_manager.font, - ) - - # --- REVISED BASES AND OUTS DRAWING --- - bases_occupied = game["bases_occupied"] # [1st, 2nd, 3rd] - outs = game.get("outs", 0) - inning_half = game["inning_half"] - - # Define geometry - base_diamond_size = 7 - out_circle_diameter = 3 - out_vertical_spacing = 2 # Space between out circles - spacing_between_bases_outs = ( - 3 # Horizontal space between base cluster and out column - ) - base_vert_spacing = 1 # Internal vertical space in base cluster - base_horiz_spacing = 1 # Internal horizontal space in base cluster - - # Calculate cluster dimensions - base_cluster_height = ( - base_diamond_size + base_vert_spacing + base_diamond_size - ) - base_cluster_width = ( - base_diamond_size + base_horiz_spacing + base_diamond_size - ) - out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing - out_cluster_width = out_circle_diameter - - # Calculate overall start positions - overall_start_y = ( - inning_bbox[3] + 0 - ) # Start immediately below inning text (moved up 3 pixels) - - # Center the BASE cluster horizontally - bases_origin_x = (self.display_width - base_cluster_width) // 2 - - # Determine relative positions for outs based on inning half - if inning_half == "top": # Away batting, outs on left - outs_column_x = ( - bases_origin_x - spacing_between_bases_outs - out_cluster_width - ) - else: # Home batting, outs on right - outs_column_x = ( - bases_origin_x + base_cluster_width + spacing_between_bases_outs - ) - - # Calculate vertical alignment offset for outs column (center align with bases cluster) - outs_column_start_y = ( - overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) - ) - - # --- Draw Bases (Diamonds) --- - base_color_occupied = (255, 255, 255) - base_color_empty = (255, 255, 255) # Outline color - h_d = base_diamond_size // 2 - - # 2nd Base (Top center relative to bases_origin_x) - c2x = bases_origin_x + base_cluster_width // 2 - c2y = overall_start_y + h_d - poly2 = [ - (c2x, overall_start_y), - (c2x + h_d, c2y), - (c2x, c2y + h_d), - (c2x - h_d, c2y), - ] - if bases_occupied[1]: - draw_overlay.polygon(poly2, fill=base_color_occupied) - else: - draw_overlay.polygon(poly2, outline=base_color_empty) - - base_bottom_y = c2y + h_d # Bottom Y of 2nd base diamond - - # 3rd Base (Bottom left relative to bases_origin_x) - c3x = bases_origin_x + h_d - c3y = base_bottom_y + base_vert_spacing + h_d - poly3 = [ - (c3x, base_bottom_y + base_vert_spacing), - (c3x + h_d, c3y), - (c3x, c3y + h_d), - (c3x - h_d, c3y), - ] - if bases_occupied[2]: - draw_overlay.polygon(poly3, fill=base_color_occupied) - else: - draw_overlay.polygon(poly3, outline=base_color_empty) - - # 1st Base (Bottom right relative to bases_origin_x) - c1x = bases_origin_x + base_cluster_width - h_d - c1y = base_bottom_y + base_vert_spacing + h_d - poly1 = [ - (c1x, base_bottom_y + base_vert_spacing), - (c1x + h_d, c1y), - (c1x, c1y + h_d), - (c1x - h_d, c1y), - ] - if bases_occupied[0]: - draw_overlay.polygon(poly1, fill=base_color_occupied) - else: - draw_overlay.polygon(poly1, outline=base_color_empty) - - # --- Draw Outs (Vertical Circles) --- - circle_color_out = (255, 255, 255) - circle_color_empty_outline = (100, 100, 100) - - for i in range(3): - cx = outs_column_x - cy = outs_column_start_y + i * ( - out_circle_diameter + out_vertical_spacing - ) - coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter] - if i < outs: - draw_overlay.ellipse(coords, fill=circle_color_out) - else: - draw_overlay.ellipse(coords, outline=circle_color_empty_outline) - - # --- Draw Balls-Strikes Count (BDF Font) --- - balls = game.get("balls", 0) - strikes = game.get("strikes", 0) - - # Add debug logging for count with cooldown - current_time = time.time() - if ( - game["home_abbr"] in self.favorite_teams - or game["away_abbr"] in self.favorite_teams - ) and current_time - self.last_count_log_time >= self.count_log_interval: - self.logger.debug(f"Displaying count: {balls}-{strikes}") - self.logger.debug( - f"Raw count data: balls={game.get('balls')}, strikes={game.get('strikes')}" - ) - self.last_count_log_time = current_time - - count_text = f"{balls}-{strikes}" - bdf_font = self.display_manager.calendar_font - bdf_font.set_char_size(height=7 * 64) # Set 7px height - count_text_width = self.display_manager.get_text_width(count_text, bdf_font) - - # Position below the base/out cluster - cluster_bottom_y = ( - overall_start_y + base_cluster_height - ) # Find the bottom of the taller part (bases) - count_y = cluster_bottom_y + 2 # Start 2 pixels below cluster - - # Center horizontally within the BASE cluster width - count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2 - - # Ensure draw object is set and draw text - self.display_manager.draw = draw_overlay - # self.display_manager._draw_bdf_text(count_text, count_x, count_y, text_color, font=bdf_font) - # Use _draw_text_with_outline for count text - # self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color) - - # Draw Balls-Strikes Count with outline using BDF font - # Define outline color (consistent with _draw_text_with_outline default) - outline_color_for_bdf = (0, 0, 0) - - # Draw outline - for dx_offset, dy_offset in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - self.display_manager._draw_bdf_text( - count_text, - count_x + dx_offset, - count_y + dy_offset, - color=outline_color_for_bdf, - font=bdf_font, - ) - - # Draw main text - self.display_manager._draw_bdf_text( - count_text, count_x, count_y, color=text_color, font=bdf_font - ) - - # Draw Team:Score at the bottom (matching main branch format) - score_font = self.display_manager.font # Use PressStart2P - outline_color = (0, 0, 0) - score_text_color = ( - 255, - 255, - 255, - ) # Use a specific name for score text color - - # Helper function for outlined text - def draw_bottom_outlined_text(x, y, text): - self._draw_text_with_outline( - draw_overlay, - text, - (x, y), - score_font, - fill=score_text_color, - outline_color=outline_color, - ) - - away_abbr = game["away_abbr"] - home_abbr = game["home_abbr"] - away_score_str = str(game["away_score"]) - home_score_str = str(game["home_score"]) - - away_text = f"{away_abbr}:{away_score_str}" - home_text = f"{home_abbr}:{home_score_str}" - - # Calculate Y position (bottom edge) - # Get font height (approximate or precise) - try: - font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] - except AttributeError: - font_height = 8 # Fallback for default font - score_y = ( - self.display_height - font_height - 2 - ) # 2 pixels padding from bottom - - # Away Team:Score (Bottom Left) - away_score_x = 2 # 2 pixels padding from left - draw_bottom_outlined_text(away_score_x, score_y, away_text) - - # Home Team:Score (Bottom Right) - home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font) - home_text_width = home_text_bbox[2] - home_text_bbox[0] - home_score_x = ( - self.display_width - home_text_width - 2 - ) # 2 pixels padding from right - draw_bottom_outlined_text(home_score_x, score_y, home_text) - - # Draw gambling odds if available - if "odds" in game and game["odds"]: - self._draw_dynamic_odds( - draw_overlay, game["odds"], self.display_width, self.display_height - ) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert("RGB") # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error( - f"Error displaying live Baseball game: {e}", exc_info=True - ) # Changed log prefix diff --git a/src/base_classes/basketball.py b/src/base_classes/basketball.py deleted file mode 100644 index 72c4bf28..00000000 --- a/src/base_classes/basketball.py +++ /dev/null @@ -1,300 +0,0 @@ -import logging -from typing import Any, Dict, Optional - -from PIL import Image, ImageDraw, ImageFont - -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager - - -class Basketball(SportsCore): - """Base class for basketball sports with common functionality.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.data_source = ESPNDataSource(logger) - self.sport = "basketball" - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - # --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES --- - details, home_team, away_team, status, situation = ( - self._extract_game_details_common(game_event) - ) - if details is None or home_team is None or away_team is None or status is None: - return - try: - # Format period/quarter - period = status.get("period", 0) - period_text = "" - if status["type"]["state"] == "in": - if period == 0: - period_text = "Start" # Before kickoff - elif period >= 1 and period <= 4: - period_text = f"Q{period}" # OT starts after Q4 - elif period > 4: - period_text = f"OT{period - 4}" # OT starts after Q4 - elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state - period_text = "HALF" - elif status["type"]["state"] == "post": - if period > 4 : period_text = "Final/OT" - else: period_text = "Final" - elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming - - details.update({ - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - }) - - # Basic validation (can be expanded) - if not details["home_abbr"] or not details["away_abbr"]: - self.logger.warning( - f"Missing team abbreviation in event: {details['id']}" - ) - return None - - self.logger.debug( - f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" - ) - - return details - except Exception as e: - # Log the problematic event structure if possible - self.logger.error( - f"Error extracting game details: {e} from event: {game_event.get('id')}", - exc_info=True, - ) - return None - - -class BasketballLive(Basketball, SportsLive): - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - # For testing, we'll just update the clock to show it's working - minutes = int(self.current_game["clock"].split(":")[0]) - seconds = int(self.current_game["clock"].split(":")[1]) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - minutes = 19 - if self.current_game["period"] < 3: - self.current_game["period"] += 1 - else: - self.current_game["period"] = 1 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live Basketball game.""" # Updated docstring - try: - main_img = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) - ) - overlay = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) - ) - draw_overlay = ImageDraw.Draw( - overlay - ) # Draw text elements on overlay first - home_logo = self._load_and_resize_logo( - game["home_id"], - game["home_abbr"], - game["home_logo_path"], - game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game["away_id"], - game["away_abbr"], - game["away_logo_path"], - game.get("away_logo_url"), - ) - - if not home_logo or not away_logo: - self.logger.error( - f"Failed to load logos for live game: {game.get('id')}" - ) # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) - self._draw_text_with_outline( - draw_final, "Logo Error", (5, 5), self.fonts["status"] - ) - self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = ( - self.display_width - home_logo.width + 10 - ) # adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 # adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Draw Text Elements on Overlay --- - # Note: Rankings are now handled in the records/rankings section below - - # Period/Quarter and Clock (Top center) - period_clock_text = ( - f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - ) - - status_width = draw_overlay.textlength( - period_clock_text, font=self.fonts["time"] - ) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline( - draw_overlay, - period_clock_text, - (status_x, status_y), - self.fonts["time"], - ) - - # Scores (centered, slightly above bottom) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) - score_x = (self.display_width - score_width) // 2 - score_y = ( - self.display_height // 2 - ) - 3 # centered #from 14 # Position score higher - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] - ) - - # Draw odds if available - if "odds" in game and game["odds"]: - self._draw_dynamic_odds( - draw_overlay, game["odds"], self.display_width, self.display_height - ) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get("away_abbr", "") - home_abbr = game.get("home_abbr", "") - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - 1 - self.logger.debug( - f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" - ) - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = "" - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = "" - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get("away_record", "") - else: - away_text = "" - - if away_text: - away_record_x = 3 - self.logger.debug( - f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - away_text, - (away_record_x, record_y), - record_font, - ) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = "" - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = "" - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get("home_record", "") - else: - home_text = "" - - if home_text: - home_record_bbox = draw_overlay.textbbox( - (0, 0), home_text, font=record_font - ) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 3 - self.logger.debug( - f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - home_text, - (home_record_x, record_y), - record_font, - ) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert("RGB") # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error( - f"Error displaying live Hockey game: {e}", exc_info=True - ) # Changed log prefix diff --git a/src/base_classes/data_sources.py b/src/base_classes/data_sources.py deleted file mode 100644 index 80c6177a..00000000 --- a/src/base_classes/data_sources.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Pluggable Data Source Architecture - -This module provides abstract data sources that can be plugged into the sports system -to support different APIs and data providers. -""" - -from abc import ABC, abstractmethod -from typing import Dict, List -import requests -import logging -from datetime import datetime -from src.common.espn_dates import fetch_espn_scoreboard - -class DataSource(ABC): - """Abstract base class for data sources.""" - - def __init__(self, logger: logging.Logger): - self.logger = logger - self.session = requests.Session() - - # Configure retry strategy - from requests.adapters import HTTPAdapter - from urllib3.util.retry import Retry - - retry_strategy = Retry( - total=5, - backoff_factor=1, - status_forcelist=[429, 500, 502, 503, 504], - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("http://", adapter) - self.session.mount("https://", adapter) - - @abstractmethod - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games for a sport/league.""" - - @abstractmethod - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule for a sport/league within date range.""" - - @abstractmethod - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings for a sport/league.""" - - def get_headers(self) -> Dict[str, str]: - """Get headers for API requests. - - The agent carries the project URL deliberately. Around 2026-08-04 ESPN - began returning 403 for bare custom tokens like 'LEDMatrix/1.0' — and - for browser-style strings — while accepting an agent that identifies - the client and links to it. An Accept header alone does not rescue the - bare form when the request goes out through requests. - """ - return { - 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', - 'Accept': 'application/json' - } - - -class ESPNDataSource(DataSource): - """ESPN API data source.""" - - def __init__(self, logger: logging.Logger): - super().__init__(logger) - self.base_url = "https://site.api.espn.com/apis/site/v2/sports" - - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games from ESPN API.""" - try: - now = datetime.now() - formatted_date = now.strftime("%Y%m%d") - url = f"{self.base_url}/{sport}/{league}/scoreboard" - data = fetch_espn_scoreboard( - self.session, url, params={"dates": formatted_date, "limit": 1000}, - headers=self.get_headers(), timeout=15, logger=self.logger, - ) - events = data.get('events', []) - - # Filter for live games - live_events = [event for event in events - if event.get('competitions', [{}])[0].get('status', {}).get('type', {}).get('state') == 'in'] - - self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}") - return live_events - - except Exception as e: - self.logger.error(f"Error fetching live games from ESPN: {e}") - return [] - - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule from ESPN API.""" - try: - start_date, end_date = date_range - url = f"{self.base_url}/{sport}/{league}/scoreboard" - - params = { - 'dates': f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}", - "limit": 1000 - } - - data = fetch_espn_scoreboard( - self.session, url, params=params, - headers=self.get_headers(), timeout=15, logger=self.logger, - ) - events = data.get('events', []) - - self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}") - return events - - except Exception as e: - self.logger.error(f"Error fetching schedule from ESPN: {e}") - return [] - - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings, or the poll for leagues that have one. - - Order matters and used to be wrong. College leagues publish a poll at - /rankings and a records table at /standings; professional leagues have - only /standings. The old code tried /standings first and fell back to - /rankings only on a 404 -- but college /standings answers 200, so the - fallback never fired and college rankings came back empty forever. - Nothing failed; the AP rank badge simply never appeared, and anything - else keyed off rankings quietly did nothing. - - A 200 that lacks the key is treated as a miss, so a league answering - both endpoints still ends up with whichever one actually carries a poll. - """ - league_name = (league or "").lower() - wants_poll = "college" in league_name or "ncaa" in league_name - endpoints = ["rankings", "standings"] if wants_poll else ["standings", "rankings"] - - for endpoint in endpoints: - url = f"{self.base_url}/{sport}/{league}/{endpoint}" - # Only the request is guarded. Inspecting the payload happens - # below, outside the handler, so that a bug in this method cannot - # be mistaken for an endpoint that failed -- that mistake would - # silently drop rankings for a league that has them, which is the - # exact failure this function was written to fix. - try: - response = self.session.get( - url, headers=self.get_headers(), timeout=15 - ) - response.raise_for_status() - data = response.json() - except (requests.RequestException, ValueError) as e: - status = getattr(getattr(e, "response", None), "status_code", None) - # Only a 404 is routine -- it is how a league says "no poll - # here". Everything else is worth an error, and `status is - # None` covers the ones that matter most: ConnectionError, - # Timeout, a body that would not parse. Silencing those left a - # board that could not reach ESPN with one debug line, and the - # ranked filter running on an empty table. - if status != 404: - self.logger.error( - f"Error fetching {endpoint} from ESPN for " - f"{sport}/{league}: {e}" - ) - continue - - if not isinstance(data, dict): - # A list or a bare string is not something the callers can - # read. Treat it as a miss so the other endpoint still gets a - # turn, but say so -- this means ESPN changed shape. - self.logger.error( - f"Unexpected {endpoint} payload for {sport}/{league}: " - f"got {type(data).__name__}, expected an object" - ) - continue - if endpoint == "rankings" and not data.get("rankings"): - continue - self.logger.debug(f"Fetched {endpoint} for {sport}/{league}") - return data - self.logger.debug( - f"Standings/rankings not available for {sport}/{league} from ESPN API" - ) - return {} - - -class MLBAPIDataSource(DataSource): - """MLB API data source.""" - - def __init__(self, logger: logging.Logger): - super().__init__(logger) - self.base_url = "https://statsapi.mlb.com/api/v1" - - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games from MLB API.""" - try: - url = f"{self.base_url}/schedule" - params = { - 'sportId': 1, # MLB - 'date': datetime.now().strftime('%Y-%m-%d'), - 'hydrate': 'game,team,venue,weather' - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - games = data.get('dates', [{}])[0].get('games', []) - - # Filter for live games - live_games = [game for game in games - if game.get('status', {}).get('abstractGameState') == 'Live'] - - self.logger.debug(f"Fetched {len(live_games)} live games from MLB API") - return live_games - - except Exception as e: - self.logger.error(f"Error fetching live games from MLB API: {e}") - return [] - - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule from MLB API.""" - try: - start_date, end_date = date_range - url = f"{self.base_url}/schedule" - - params = { - 'sportId': 1, # MLB - 'startDate': start_date.strftime('%Y-%m-%d'), - 'endDate': end_date.strftime('%Y-%m-%d'), - 'hydrate': 'game,team,venue' - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - all_games = [] - for date_data in data.get('dates', []): - all_games.extend(date_data.get('games', [])) - - self.logger.debug(f"Fetched {len(all_games)} scheduled games from MLB API") - return all_games - - except Exception as e: - self.logger.error(f"Error fetching schedule from MLB API: {e}") - return [] - - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings from MLB API.""" - try: - url = f"{self.base_url}/standings" - params = { - 'leagueId': 103, # American League - 'season': datetime.now().year, - 'standingsType': 'regularSeason' - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - self.logger.debug("Fetched standings from MLB API") - return data - - except Exception as e: - self.logger.error(f"Error fetching standings from MLB API: {e}") - return {} - - -class SoccerAPIDataSource(DataSource): - """Soccer API data source (generic structure).""" - - def __init__(self, logger: logging.Logger, api_key: str = None): - super().__init__(logger) - self.api_key = api_key - self.base_url = "https://api.football-data.org/v4" # Example API - - def get_headers(self) -> Dict[str, str]: - """Get headers with API key for soccer API.""" - headers = super().get_headers() - if self.api_key: - headers['X-Auth-Token'] = self.api_key - return headers - - def fetch_live_games(self, sport: str, league: str) -> List[Dict]: - """Fetch live games from soccer API.""" - try: - # This would need to be adapted based on the specific soccer API - url = f"{self.base_url}/matches" - params = { - 'status': 'LIVE', - 'competition': league - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - matches = data.get('matches', []) - - self.logger.debug(f"Fetched {len(matches)} live games from soccer API") - return matches - - except Exception as e: - self.logger.error(f"Error fetching live games from soccer API: {e}") - return [] - - def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: - """Fetch schedule from soccer API.""" - try: - start_date, end_date = date_range - url = f"{self.base_url}/matches" - - params = { - 'competition': league, - 'dateFrom': start_date.strftime('%Y-%m-%d'), - 'dateTo': end_date.strftime('%Y-%m-%d') - } - - response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) - response.raise_for_status() - - data = response.json() - matches = data.get('matches', []) - - self.logger.debug(f"Fetched {len(matches)} scheduled games from soccer API") - return matches - - except Exception as e: - self.logger.error(f"Error fetching schedule from soccer API: {e}") - return [] - - def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings from soccer API.""" - try: - url = f"{self.base_url}/competitions/{league}/standings" - response = self.session.get(url, headers=self.get_headers(), timeout=15) - response.raise_for_status() - - data = response.json() - self.logger.debug("Fetched standings from soccer API") - return data - - except Exception as e: - self.logger.error(f"Error fetching standings from soccer API: {e}") - return {} - - -# Factory function removed - sport classes now instantiate data sources directly diff --git a/src/base_classes/football.py b/src/base_classes/football.py deleted file mode 100644 index 2974934f..00000000 --- a/src/base_classes/football.py +++ /dev/null @@ -1,387 +0,0 @@ -from typing import Dict, Any, Optional -from src.display_manager import DisplayManager -from src.cache_manager import CacheManager -import logging -from PIL import Image, ImageDraw, ImageFont -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive - -class Football(SportsCore): - """Base class for football sports with common functionality.""" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.data_source = ESPNDataSource(logger) - self.sport = "football" - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - details, home_team, away_team, status, situation = self._extract_game_details_common(game_event) - if details is None or home_team is None or away_team is None or status is None: - return - try: - competition = game_event["competitions"][0] - status = competition["status"] - - # --- Football Specific Details (Likely same for NFL/NCAAFB) --- - down_distance_text = "" - down_distance_text_long = "" - possession_indicator = None # Default to None - scoring_event = "" # Track scoring events - home_timeouts = 0 - away_timeouts = 0 - is_redzone = False - posession = None - - if situation and status["type"]["state"] == "in": - # down = situation.get("down") - down_distance_text = situation.get("shortDownDistanceText") - down_distance_text_long = situation.get("downDistanceText") - # distance = situation.get("distance") - - # Detect scoring events from status detail - status_detail = status["type"].get("detail", "").lower() - status_short = status["type"].get("shortDetail", "").lower() - is_redzone = situation.get("isRedZone") - posession = situation.get("possession") - - # Check for scoring events in status text - if any(keyword in status_detail for keyword in ["touchdown", "td"]): - scoring_event = "TOUCHDOWN" - elif any(keyword in status_detail for keyword in ["field goal", "fg"]): - scoring_event = "FIELD GOAL" - elif any(keyword in status_detail for keyword in ["extra point", "pat", "point after"]): - scoring_event = "PAT" - elif any(keyword in status_short for keyword in ["touchdown", "td"]): - scoring_event = "TOUCHDOWN" - elif any(keyword in status_short for keyword in ["field goal", "fg"]): - scoring_event = "FIELD GOAL" - elif any(keyword in status_short for keyword in ["extra point", "pat"]): - scoring_event = "PAT" - - # Determine possession based on team ID - possession_team_id = situation.get("possession") - if possession_team_id: - if possession_team_id == home_team.get("id"): - possession_indicator = "home" - elif possession_team_id == away_team.get("id"): - possession_indicator = "away" - - home_timeouts = situation.get("homeTimeouts", 3) # Default to 3 if not specified - away_timeouts = situation.get("awayTimeouts", 3) # Default to 3 if not specified - - - # Format period/quarter - period = status.get("period", 0) - period_text = "" - if status["type"]["state"] == "in": - if period == 0: - period_text = "Start" # Before kickoff - elif period >= 1 and period <= 4: - period_text = f"Q{period}" # OT starts after Q4 - elif period > 4: - period_text = f"OT{period - 4}" # OT starts after Q4 - elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state - period_text = "HALF" - elif status["type"]["state"] == "post": - if period > 4 : period_text = "Final/OT" - else: period_text = "Final" - elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming - - details.update({ - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - "home_timeouts": home_timeouts, - "away_timeouts": away_timeouts, - "down_distance_text": down_distance_text, # Added Down/Distance - "down_distance_text_long": down_distance_text_long, - "is_redzone": is_redzone, - "possession": posession, # ID of team with possession - "possession_indicator": possession_indicator, # Added for easy home/away check - "scoring_event": scoring_event, # Track scoring events (TOUCHDOWN, FIELD GOAL, PAT) - }) - - # Basic validation (can be expanded) - if not details['home_abbr'] or not details['away_abbr']: - self.logger.warning(f"Missing team abbreviation in event: {details['id']}") - return None - - self.logger.debug(f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}") - - return details - except Exception as e: - # Log the problematic event structure if possible - logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) - return None - -class FootballLive(Football, SportsLive): - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - try: - minutes, seconds = map(int, self.current_game["clock"].split(':')) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - # Simulate end of quarter/game - if self.current_game["period"] < 4: # Q4 is period 4 - self.current_game["period"] += 1 - # Update period_text based on new period - if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1" - elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2" - elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3" - elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4" - # Reset clock for next quarter (e.g., 15:00) - minutes, seconds = 15, 0 - else: - # Simulate game end - self.current_game["is_live"] = False - self.current_game["is_final"] = True - self.current_game["period_text"] = "Final" - minutes, seconds = 0, 0 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Simulate down change occasionally - if seconds % 15 == 0: - self.current_game["down_distance_text"] = f"{['1st','2nd','3rd','4th'][seconds % 4]} & {seconds % 10 + 1}" - self.current_game["status_text"] = f"{self.current_game['period_text']} {self.current_game['clock']}" - - # Display update handled by main loop or explicit call if needed immediately - # self.display(force_clear=True) # Only if immediate update is desired here - - except ValueError: - self.logger.warning("Test mode: Could not parse clock") # Changed log prefix - # No actual display call here, let main loop handle it - - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring - try: - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) # Draw text elements on overlay first - - home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) - away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for live game: {game.get('id')}") # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = self.display_width - home_logo.width + 10 #adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 #adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Draw Text Elements on Overlay --- - # Note: Rankings are now handled in the records/rankings section below - - # Scores (centered, slightly above bottom) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 #centered #from 14 # Position score higher - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) - - # Period/Quarter and Clock (Top center) - period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - if game.get("is_halftime"): \ - period_clock_text = "Halftime" # Override for halftime - elif game.get("is_period_break"): - period_clock_text = game.get("status_text", "Period Break") - - status_width = draw_overlay.textlength(period_clock_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time']) - - # Down & Distance or Scoring Event (Below Period/Clock) - scoring_event = game.get("scoring_event", "") - down_distance = game.get("down_distance_text", "") - if self.display_width > 128: - down_distance = game.get("down_distance_text_long", "") - - # Show scoring event if detected, otherwise show down & distance - if scoring_event and game.get("is_live"): - # Display scoring event with special formatting - event_width = draw_overlay.textlength(scoring_event, font=self.fonts['detail']) - event_x = (self.display_width - event_width) // 2 - event_y = (self.display_height) - 7 - - # Color coding for different scoring events - if scoring_event == "TOUCHDOWN": - event_color = (255, 215, 0) # Gold - elif scoring_event == "FIELD GOAL": - event_color = (0, 255, 0) # Green - elif scoring_event == "PAT": - event_color = (255, 165, 0) # Orange - else: - event_color = (255, 255, 255) # White - - self._draw_text_with_outline(draw_overlay, scoring_event, (event_x, event_y), self.fonts['detail'], fill=event_color) - elif down_distance and game.get("is_live"): # Only show if live and available - dd_width = draw_overlay.textlength(down_distance, font=self.fonts['detail']) - dd_x = (self.display_width - dd_width) // 2 - dd_y = (self.display_height)- 7 # Top of D&D text - down_color = (200, 200, 0) if not game.get("is_redzone", False) else (255,0,0) # Yellowish text - self._draw_text_with_outline(draw_overlay, down_distance, (dd_x, dd_y), self.fonts['detail'], fill=down_color) - - # Possession Indicator (small football icon) - possession = game.get("possession_indicator") - if possession: # Only draw if possession is known - ball_radius_x = 3 # Wider for football shape - ball_radius_y = 2 # Shorter for football shape - ball_color = (139, 69, 19) # Brown color for the football - lace_color = (255, 255, 255) # White for laces - - # Approximate height of the detail font (4x6 font at size 6 is roughly 6px tall) - detail_font_height_approx = 6 - ball_y_center = dd_y + (detail_font_height_approx // 2) # Center ball vertically with D&D text - - possession_ball_padding = 3 # Pixels between D&D text and ball - - if possession == "away": - # Position ball to the left of D&D text - ball_x_center = dd_x - possession_ball_padding - ball_radius_x - elif possession == "home": - # Position ball to the right of D&D text - ball_x_center = dd_x + dd_width + possession_ball_padding + ball_radius_x - else: - ball_x_center = 0 # Should not happen / no indicator - - if ball_x_center > 0: # Draw if position is valid - # Draw the football shape (ellipse) - draw_overlay.ellipse( - (ball_x_center - ball_radius_x, ball_y_center - ball_radius_y, # x0, y0 - ball_x_center + ball_radius_x, ball_y_center + ball_radius_y), # x1, y1 - fill=ball_color, outline=(0,0,0) - ) - # Draw a simple horizontal lace - draw_overlay.line( - (ball_x_center - 1, ball_y_center, ball_x_center + 1, ball_y_center), - fill=lace_color, width=1 - ) - - # Timeouts (Bottom corners) - 3 small bars per team - timeout_bar_width = 4 - timeout_bar_height = 2 - timeout_spacing = 1 - timeout_y = self.display_height - timeout_bar_height - 1 # Bottom edge - - # Away Timeouts (Bottom Left) - away_timeouts_remaining = game.get("away_timeouts", 0) - for i in range(3): - to_x = 2 + i * (timeout_bar_width + timeout_spacing) - color = (255, 255, 255) if i < away_timeouts_remaining else (80, 80, 80) # White if available, gray if used - draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0)) - - # Home Timeouts (Bottom Right) - home_timeouts_remaining = game.get("home_timeouts", 0) - for i in range(3): - to_x = self.display_width - 2 - timeout_bar_width - (2-i) * (timeout_bar_width + timeout_spacing) - color = (255, 255, 255) if i < home_timeouts_remaining else (80, 80, 80) # White if available, gray if used - draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0)) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get('away_abbr', '') - home_abbr = game.get('home_abbr', '') - - record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - 4 - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = '' - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = '' - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get('away_record', '') - else: - away_text = '' - - if away_text: - away_record_x = 3 - self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = '' - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = '' - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get('home_record', '') - else: - home_text = '' - - if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 3 - self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix diff --git a/src/base_classes/hockey.py b/src/base_classes/hockey.py deleted file mode 100644 index 419f09ec..00000000 --- a/src/base_classes/hockey.py +++ /dev/null @@ -1,380 +0,0 @@ -import logging -from typing import Any, Dict, Optional - -from PIL import Image, ImageDraw, ImageFont - -from src.base_classes.data_sources import ESPNDataSource -from src.base_classes.sports import SportsCore, SportsLive -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager - - -class Hockey(SportsCore): - """Base class for hockey sports with common functionality.""" - - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.data_source = ESPNDataSource(logger) - self.sport = "hockey" - self.show_shots_on_goal = self.mode_config.get("show_shots_on_goal", False) - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN NCAA FB API response.""" - # --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES --- - details, home_team, away_team, status, situation = ( - self._extract_game_details_common(game_event) - ) - if details is None or home_team is None or away_team is None or status is None: - return - try: - competition = game_event["competitions"][0] - status = competition["status"] - powerplay = False - penalties = "" - # A competitor may legitimately arrive without a "statistics" - # array (pre-game feeds, and some in-progress ones). Reading it - # unguarded raised KeyError inside the generator and dropped the - # WHOLE event, discarding valid scores and status. Default to an - # empty list so the saves/shots figures fall back to 0 instead. - home_stats = home_team.get("statistics", []) - away_stats = away_team.get("statistics", []) - home_team_saves = next( - ( - int(c["displayValue"]) - for c in home_stats - if c.get("name") == "saves" - ), - 0, - ) - home_team_saves_per = next( - ( - float(c["displayValue"]) - for c in home_stats - if c.get("name") == "savePct" - ), - 0.0, - ) - away_team_saves = next( - ( - int(c["displayValue"]) - for c in away_stats - if c.get("name") == "saves" - ), - 0, - ) - away_team_saves_per = next( - ( - float(c["displayValue"]) - for c in away_stats - if c.get("name") == "savePct" - ), - 0.0, - ) - - home_shots = 0 - away_shots = 0 - if home_team_saves_per > 0: - away_shots = round(home_team_saves / home_team_saves_per) - if away_team_saves_per > 0: - home_shots = round(away_team_saves / away_team_saves_per) - if situation and status["type"]["state"] == "in": - # Detect scoring events from status detail - # status_detail = status["type"].get("detail", "") - powerplay = situation.get("isPowerPlay", False) - penalties = situation.get("penalties", "") - - # Format period/quarter - period = status.get("period", 0) - period_text = "" - if status["type"]["state"] == "in": - if period == 0: - period_text = "Start" # Before kickoff - elif period >= 1 and period <= 3: - period_text = f"P{period}" # OT starts after Q4 - elif period > 3: - period_text = f"OT{period - 3}" # OT starts after Q4 - elif status["type"]["state"] == "post": - if period > 3: - period_text = "Final/OT" - else: - period_text = "Final" - elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming - - details.update( - { - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - "power_play": powerplay, - "penalties": penalties, - "home_shots": home_shots, - "away_shots": away_shots, - } - ) - - # Basic validation (can be expanded) - if not details["home_abbr"] or not details["away_abbr"]: - self.logger.warning( - f"Missing team abbreviation in event: {details['id']}" - ) - return None - - self.logger.debug( - f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" - ) - - return details - except Exception as e: - # Log the problematic event structure if possible - self.logger.error( - f"Error extracting game details: {e} from event: {game_event.get('id')}", - exc_info=True, - ) - return None - - -class HockeyLive(Hockey, SportsLive): - def __init__( - self, - config: Dict[str, Any], - display_manager: DisplayManager, - cache_manager: CacheManager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - - def _test_mode_update(self): - if self.current_game and self.current_game["is_live"]: - # For testing, we'll just update the clock to show it's working - minutes = int(self.current_game["clock"].split(":")[0]) - seconds = int(self.current_game["clock"].split(":")[1]) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - minutes = 19 - if self.current_game["period"] < 3: - self.current_game["period"] += 1 - else: - self.current_game["period"] = 1 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring - try: - main_img = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) - ) - overlay = Image.new( - "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) - ) - draw_overlay = ImageDraw.Draw( - overlay - ) # Draw text elements on overlay first - home_logo = self._load_and_resize_logo( - game["home_id"], - game["home_abbr"], - game["home_logo_path"], - game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game["away_id"], - game["away_abbr"], - game["away_logo_path"], - game.get("away_logo_url"), - ) - - if not home_logo or not away_logo: - self.logger.error( - f"Failed to load logos for live game: {game.get('id')}" - ) # Changed log prefix - # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) - self._draw_text_with_outline( - draw_final, "Logo Error", (5, 5), self.fonts["status"] - ) - self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly more inward than NHL perhaps) - home_x = ( - self.display_width - home_logo.width + 10 - ) # adjusted from 18 # Adjust position as needed - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 # adjusted from 18 # Adjust position as needed - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # --- Draw Text Elements on Overlay --- - # Note: Rankings are now handled in the records/rankings section below - - # Period/Quarter and Clock (Top center) - period_clock_text = ( - f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - ) - if game.get("is_period_break"): - period_clock_text = game.get("status_text", "Period Break") - - status_width = draw_overlay.textlength( - period_clock_text, font=self.fonts["time"] - ) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline( - draw_overlay, - period_clock_text, - (status_x, status_y), - self.fonts["time"], - ) - - # Scores (centered, slightly above bottom) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) - score_x = (self.display_width - score_width) // 2 - score_y = ( - self.display_height // 2 - ) - 3 # centered #from 14 # Position score higher - self._draw_text_with_outline( - draw_overlay, score_text, (score_x, score_y), self.fonts["score"] - ) - - # Shots on Goal - if self.show_shots_on_goal: - shots_font = self.fonts.get('detail', ImageFont.load_default()) - home_shots = str(game.get("home_shots", "0")) - away_shots = str(game.get("away_shots", "0")) - shots_text = f"{away_shots} SHOTS {home_shots}" - shots_bbox = draw_overlay.textbbox((0, 0), shots_text, font=shots_font) - shots_height = shots_bbox[3] - shots_bbox[1] - shots_y = self.display_height - shots_height - 1 - shots_width = draw_overlay.textlength(shots_text, font=shots_font) - shots_x = (self.display_width - shots_width) // 2 - self._draw_text_with_outline( - draw_overlay, shots_text, (shots_x, shots_y), shots_font - ) - - # Draw odds if available - if "odds" in game and game["odds"]: - self._draw_dynamic_odds( - draw_overlay, game["odds"], self.display_width, self.display_height - ) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get("away_abbr", "") - home_abbr = game.get("home_abbr", "") - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - 1 - self.logger.debug( - f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" - ) - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = "" - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = "" - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get("away_record", "") - else: - away_text = "" - - if away_text: - away_record_x = 3 - self.logger.debug( - f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - away_text, - (away_record_x, record_y), - record_font, - ) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = "" - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = "" - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get("home_record", "") - else: - home_text = "" - - if home_text: - home_record_bbox = draw_overlay.textbbox( - (0, 0), home_text, font=record_font - ) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 3 - self.logger.debug( - f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" - ) - self._draw_text_with_outline( - draw_overlay, - home_text, - (home_record_x, record_y), - record_font, - ) - - # Composite the text overlay onto the main image - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert("RGB") # Convert for display - - # Display the final image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here for live - - except Exception as e: - self.logger.error( - f"Error displaying live Hockey game: {e}", exc_info=True - ) # Changed log prefix diff --git a/src/base_classes/sports/__init__.py b/src/base_classes/sports/__init__.py deleted file mode 100644 index a6def39f..00000000 --- a/src/base_classes/sports/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Sports scoreboard base classes. - -Formerly the single module ``src/base_classes/sports.py``; now a package so -capabilities can be composed instead of accumulating in one class. See -docs/SPORTS_UNIFICATION.md for the architecture. The import path is -unchanged: ``from src.base_classes.sports import SportsCore`` still works. -""" - -from .core import SportsCore -from .modes import SportsLive, SportsRecent, SportsUpcoming - -__all__ = [ - "SportsCore", - "SportsUpcoming", - "SportsRecent", - "SportsLive", -] diff --git a/src/base_classes/sports/capabilities/__init__.py b/src/base_classes/sports/capabilities/__init__.py deleted file mode 100644 index e084ad7c..00000000 --- a/src/base_classes/sports/capabilities/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Opt-in capabilities for the sports scoreboards. - -Each module here is a feature that only *some* sports want. They are composed -by inheritance (mixins) or selected by name (strategies) — never enabled by an -``if self._enabled:`` branch inside the base classes. - -The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not -inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not -in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in. - -See ``docs/SPORTS_UNIFICATION.md`` for the full rationale. -""" - -from .celebrations import CelebrationMixin -from .rotation import ( - RotationStrategy, - SimpleRotation, - SmoothWeightedRotation, - WeightedCycleRotation, - get_rotation_strategy, - register_rotation_strategy, -) - -__all__ = [ - "CelebrationMixin", - "RotationStrategy", - "SimpleRotation", - "SmoothWeightedRotation", - "WeightedCycleRotation", - "get_rotation_strategy", - "register_rotation_strategy", -] diff --git a/src/base_classes/sports/capabilities/celebrations.py b/src/base_classes/sports/capabilities/celebrations.py deleted file mode 100644 index 49d371cd..00000000 --- a/src/base_classes/sports/capabilities/celebrations.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Score / win celebration takeover — an opt-in capability. - -Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other -five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the -five that do not opt in have none of this code in their MRO: a bug here cannot -reach hockey, and hockey's config never grows keys it ignores. - -Usage — mix in *before* the mode class so its ``display`` runs first:: - - class SoccerLive(CelebrationMixin, SportsLive): - def score_phrase(self, points, team_abbr): - return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!")) - -The two lineages spelled this differently (``_check_for_goal`` / -``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` / -``celebrate_opponent_scores`` in football) but the bodies were identical apart -from three things, each of which is a seam here rather than a branch: - -* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN" - from the points delta and soccer uses to say "GOOOOAAALLL"; -* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football - where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where - two quick goals are two real events; -* **team identity** — matching goes through ``_favorite_key``, so nrl can match - on team id (its abbreviations are ambiguous) without core knowing why. - -The config keys are read under both spellings, so a plugin adopting the mixin -keeps working with the ``*_goals`` keys already in its published schema. -""" - -from __future__ import annotations - -import re -import time -from typing import Any, Dict, List, Optional - -from PIL import Image, ImageDraw - - -class CelebrationMixin: - """Full-screen takeover when a tracked team scores or wins.""" - - #: Collapse increments that land while a celebration is already on screen - #: into that one celebration. True for sports where a single scoring play - #: arrives as more than one score update (football: touchdown +6, then the - #: extra point +1). False where consecutive increments are distinct events — - #: suppressing there would swallow a real goal. - COALESCE_SCORING_SEQUENCE = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - mode_config = getattr(self, "mode_config", {}) or {} - self.celebration_enabled = mode_config.get("celebration_enabled", True) - # Coerced and floored at init: this value is compared numerically on the - # display path, where a string from a hand-edited config would raise - # TypeError outside any try block, and a zero or negative value would - # arm a celebration that can never render. - raw_duration = mode_config.get("celebration_duration", 8) - try: - self.celebration_duration = max(1.0, float(raw_duration)) - except (TypeError, ValueError): - self.logger.warning( - "[Celebrations] Unusable celebration_duration %r; using 8s. " - "Set a positive number of seconds.", - raw_duration, - ) - self.celebration_duration = 8.0 - # Both spellings: the soccer lineage ships `celebrate_opponent_goals`, - # football ships `celebrate_opponent_scores`. Whichever the plugin's - # schema declares is the one its users have set. - self.celebrate_opponent_scores = mode_config.get( - "celebrate_opponent_scores", - mode_config.get("celebrate_opponent_goals", False), - ) - # Per-game score baselines: {game_id: {"away": int, "home": int}} - self._score_baselines: Dict[str, Dict[str, int]] = {} - # The active celebration (a game *snapshot*, so a win survives the game - # leaving live_games) or None. See _start_celebration for the shape. - self.active_celebration: Optional[Dict[str, Any]] = None - - # ------------------------------------------------------------------ - # Override points - # ------------------------------------------------------------------ - - def score_phrase(self, points: int, team_abbr: str) -> str: - """The wording for a score celebration. - - ``points`` is the score delta that triggered it, which sports with - variable-value scores use to name the play. The default is deliberately - sport-neutral; every celebrating plugin overrides it. - """ - return f"{team_abbr} SCORES!" - - def win_phrase(self, team_abbr: str) -> str: - """The wording for a win celebration.""" - return f"{team_abbr} WINS!" - - def _is_favorite(self, key: Optional[str]) -> bool: - """Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite.""" - return bool(self.favorite_teams) and key in self.favorite_teams - - # ------------------------------------------------------------------ - # Detection - # ------------------------------------------------------------------ - - @staticmethod - def _score_to_int(score) -> Optional[int]: - """Coerce an ESPN score value (str / int / dict) to an int, or None.""" - try: - if score is None: - return None - if isinstance(score, str): - s = score.strip() - if not s: - return None - try: - return int(float(s)) - except ValueError: - numbers = re.findall(r"\d+", s) - return int(numbers[0]) if numbers else None - if isinstance(score, dict): - return int(float(score.get("value", score.get("displayValue", 0)))) - return int(float(score)) - except (ValueError, TypeError): - return None - - def _should_celebrate_for(self, game: Dict, side: str) -> bool: - """Whether a score by ``side`` in ``game`` should trigger a celebration.""" - if self._is_favorite(self._favorite_key(game, side)): - return True - if not self.favorite_teams: - # No favorites configured: the user opted to show this game, so - # celebrate any score in it. - return True - # Favorites exist but this team isn't one -> it's the opponent. - return self.celebrate_opponent_scores - - def prune_score_baselines(self, live_games: List[Dict]) -> None: - """Drop baselines for games no longer live. - - Only :meth:`_check_for_win` removes entries, and it only fires for games - seen to go final. A game that vanishes from the live list any other way - — postponed, dropped by the feed, or simply still live when the board - restarts — leaves its baseline behind forever, so on a board that runs - all season the dict grows without bound. - - Call this from ``update()`` with the current live set, alongside the - equivalent pruning in :meth:`SmoothWeightedRotation.next_game`. - """ - live_ids = {g.get("id") for g in live_games} - self._score_baselines = { - gid: baseline - for gid, baseline in self._score_baselines.items() - if gid in live_ids - } - - def has_active_celebration(self) -> bool: - """True while a celebration is within its display window.""" - celebration = self.active_celebration - return bool(celebration) and ( - time.time() - celebration["started_at"] < self.celebration_duration - ) - - def _check_for_score(self, game: Dict) -> None: - """Compare a live game's score against its baseline and arm a - celebration when a celebratable team's score increases.""" - if not self.celebration_enabled: - return - game_id = game.get("id") - if not game_id: - return - away = self._score_to_int(game.get("away_score")) - home = self._score_to_int(game.get("home_score")) - if away is None or home is None: - return - - baseline = self._score_baselines.get(game_id) - # Always refresh the baseline: a first sighting must never celebrate (a - # game already in progress at boot would false-fire), and a decrement - # (VAR, a correction) just re-bases silently. - self._score_baselines[game_id] = {"away": away, "home": home} - if baseline is None: - return - - away_delta = away - baseline["away"] - home_delta = home - baseline["home"] - if away_delta <= 0 and home_delta <= 0: - return - - # One takeover per scoring sequence, where the sport has such a thing. - # The baseline is already advanced above, so nothing re-fires later. - if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration(): - return - - scored_side = None - points = 0 - if away_delta > 0 and self._should_celebrate_for(game, "away"): - scored_side, points = "away", away_delta - if scored_side is None and home_delta > 0 and self._should_celebrate_for( - game, "home" - ): - scored_side, points = "home", home_delta - if scored_side is None: - return - - self._start_celebration( - game, - "score", - scored_side=scored_side, - team_abbr=game.get(f"{scored_side}_abbr", ""), - away_score=away, - home_score=home, - points=points, - ) - - def _check_for_win(self, game: Dict) -> None: - """When a game we were tracking live goes final, arm a win celebration - if a favorite won. Fires at most once per game.""" - if not self.celebration_enabled: - return - game_id = game.get("id") - if not game_id: - return - # Only celebrate wins for games we actually watched go live: one seen - # for the first time already-final (the board started after full time) - # has no baseline and must not fire. - if game_id not in self._score_baselines: - return - # Consume the baseline so this can only fire once. - self._score_baselines.pop(game_id, None) - - away = self._score_to_int(game.get("away_score")) - home = self._score_to_int(game.get("home_score")) - if away is None or home is None: - return - - if away > home: - winner_side = "away" - elif home > away: - winner_side = "home" - else: - return # draw -> no win celebration - - # Wins are gated strictly on favorites: every game ends, so the - # "no favorites -> celebrate all" score fallback would be far too noisy. - if not self._is_favorite(self._favorite_key(game, winner_side)): - return - - self._start_celebration( - game, - "win", - scored_side=winner_side, - team_abbr=game.get(f"{winner_side}_abbr", ""), - away_score=away, - home_score=home, - ) - - def _start_celebration( - self, - game: Dict, - kind: str, - scored_side: str, - team_abbr: str, - away_score: int, - home_score: int, - points: int = 0, - ) -> None: - """Arm a celebration. ``scored_side`` ('away'/'home') is the side whose - score digit gets highlighted.""" - phrase = ( - self.win_phrase(team_abbr) - if kind == "win" - else self.score_phrase(points, team_abbr) - ) - - self.active_celebration = { - "kind": kind, - "game": dict(game), # snapshot: survives the game leaving live_games - "scored_side": scored_side, - "team_abbr": team_abbr, - "away_score": away_score, - "home_score": home_score, - "started_at": time.time(), - "phrase": phrase, - } - # Pin focus to the involved game so the post-celebration scorebug - # resumes on it. - self.current_game = dict(game) - self.logger.info( - f"[Celebrations] {kind} armed: {phrase} " - f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]" - ) - - # ------------------------------------------------------------------ - # Rendering - # ------------------------------------------------------------------ - - def _fit_font(self, draw, text: str, max_width: int, fonts: List): - """The first font whose rendered ``text`` fits ``max_width``, falling - back to the last (smallest) font.""" - for font in fonts: - if draw.textlength(text, font=font) <= max_width - 2: - return font - return fonts[-1] - - def _draw_celebration_layout( - self, celebration: Dict, force_clear: bool = False - ) -> None: - """Render the full-screen score/win takeover.""" - if force_clear: - self.display_manager.clear() - - display_width = ( - self.display_manager.matrix.width - if hasattr(self.display_manager, "matrix") and self.display_manager.matrix - else self.display_width - ) - display_height = ( - self.display_manager.matrix.height - if hasattr(self.display_manager, "matrix") and self.display_manager.matrix - else self.display_height - ) - - elapsed = time.time() - celebration["started_at"] - game = celebration["game"] - - # Background: a brief color flash for the first ~1.2s, then black. - bg = (0, 0, 0, 255) - if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0: - bg = (12, 12, 48, 255) - main_img = Image.new("RGBA", (display_width, display_height), bg) - overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0)) - draw = ImageDraw.Draw(overlay) - - # Logos at the edges (best-effort: a logo failure must not blank the - # celebration). - try: - center_y = display_height // 2 - home_logo = self._load_and_resize_logo( - game.get("home_id"), game.get("home_abbr"), - game.get("home_logo_path"), game.get("home_logo_url"), - ) - away_logo = self._load_and_resize_logo( - game.get("away_id"), game.get("away_abbr"), - game.get("away_logo_path"), game.get("away_logo_url"), - ) - if home_logo: - main_img.paste( - home_logo, - (display_width - home_logo.width + 2, center_y - home_logo.height // 2), - home_logo, - ) - if away_logo: - main_img.paste( - away_logo, (-2, center_y - away_logo.height // 2), away_logo - ) - except Exception as e: - self.logger.debug(f"[Celebrations] Logo load failed: {e}") - - # Phrase across the top, shrunk to fit the panel width. - phrase = celebration["phrase"] - phrase_font = self._fit_font( - draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]] - ) - phrase_width = draw.textlength(phrase, font=phrase_font) - self._draw_text_with_outline( - draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font - ) - - # Score centered low, with the scoring/winning side's digit pulsing in a - # highlight color so the change reads at a glance. - away_text = str(celebration["away_score"]) - home_text = str(celebration["home_score"]) - score_font = self.fonts["score"] - segments = [ - (away_text, celebration["scored_side"] == "away"), - ("-", False), - (home_text, celebration["scored_side"] == "home"), - ] - total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments) - highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0) - x = (display_width - total_width) // 2 - y = display_height - 14 - for seg, is_highlight in segments: - color = highlight if is_highlight else (255, 255, 255) - self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color) - x += draw.textlength(seg, font=score_font) - - main_img = Image.alpha_composite(main_img, overlay).convert("RGB") - self.display_manager.image = main_img - self.display_manager.update_display() - - def display(self, force_clear: bool = False) -> bool: - """Render an active celebration as a full-screen takeover; otherwise - defer to the normal live scorebug.""" - if not self.is_enabled: - return False - celebration = self.active_celebration - if celebration: - if self.has_active_celebration(): - try: - self._draw_celebration_layout(celebration, force_clear) - return True - except Exception as e: - self.logger.error( - f"[Celebrations] Error drawing celebration: {e}", exc_info=True - ) - # Disarm rather than retry: the same render would fail on - # every frame for the rest of the window, logging a - # traceback each time and leaving the scorebug off screen. - self.active_celebration = None - self.last_game_switch = time.time() - else: - self.active_celebration = None - # Reset the dwell so the scorebug resumes on the scoring/winning - # game for a full duration before rotation can move on. - self.last_game_switch = time.time() - return super().display(force_clear) diff --git a/src/base_classes/sports/capabilities/rotation.py b/src/base_classes/sports/capabilities/rotation.py deleted file mode 100644 index 4135ca9e..00000000 --- a/src/base_classes/sports/capabilities/rotation.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Live-rotation strategies — which live game to show next. - -The nine plugin copies grew three spellings of this, and the survey behind -``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted -Round-Robin algorithm in two shapes: - -* an **incremental picker** that holds weight state across calls and answers - "what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and -* a **precomputed cycle** that returns a full list of game ids up front - (football / baseball / basketball's ``_build_weighted_schedule`` and hockey's - ``_build_rotation_schedule``, which differ only in loop shape). - -They agree *within* a cycle — SWRR is deterministic — and differ only at cycle -boundaries, where the incremental form has no seam and the precomputed form -restarts. That is a real behavioral difference, so core ships both rather than -declaring a winner, and a plugin picks one by name: - - self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight) - -Core never learns which sport is asking. A plugin with a genuinely novel -ordering registers its own strategy instead of core growing a branch:: - - register_rotation_strategy("my-order", MyRotation) -""" - -from __future__ import annotations - -from typing import Callable, Dict, List, Optional, Type - - -def _game_id(game: Dict) -> Optional[str]: - """The rotation key for a game, or None if it has no usable id.""" - return game.get("id") - - -class RotationStrategy: - """Base class for live-rotation ordering. - - Subclasses implement :meth:`schedule`; :meth:`next_game` has a working - default derived from it. Strategies whose natural shape is incremental - override :meth:`next_game` instead and derive :meth:`schedule`. - - :param weight_for: callable mapping a game dict to a positive integer - weight — how many turns it gets per turn of a weight-1 game. Supplied by - the host so the *favorites* policy stays with the plugin and this module - stays free of any notion of what a favorite is. Defaults to equal - weights, which makes every strategy a plain round robin. - """ - - #: Name this strategy is registered under. Set by :func:`register_rotation_strategy`. - name: str = "" - - #: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each - #: step scans every game, so an unbounded weight — a misread config field, - #: say — would spin the display thread for an unbounded time. On a Pi that - #: stalls rendering outright, so the bound is clamped like the floor is. - MAX_WEIGHT = 16 - - def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None): - self._weight_for = weight_for or (lambda game: 1) - - def weights(self, games: List[Dict]) -> Dict[str, int]: - """``{game_id: weight}`` for games that have an id, in ``games`` order. - - A weight below 1 is clamped up: a zero or negative weight would starve - a game out of the rotation entirely, which no caller means to express - and which would make ``total_weight`` collapse. It is clamped down at - :attr:`MAX_WEIGHT` for the reason documented there. - """ - weights: Dict[str, int] = {} - for game in games: - gid = _game_id(game) - if gid is None: - continue - try: - weight = int(self._weight_for(game)) - except (TypeError, ValueError): - weight = 1 - weights[gid] = min(self.MAX_WEIGHT, max(1, weight)) - return weights - - def schedule(self, games: List[Dict]) -> List[str]: - """Game ids in display order for one cycle. Ids may repeat.""" - raise NotImplementedError - - def next_game(self, games: List[Dict]) -> Optional[Dict]: - """The next game to display, or None when there is nothing to show.""" - order = self.schedule(games) - if not order: - return None - by_id = {gid: g for g in games if (gid := _game_id(g)) is not None} - return by_id.get(order[0]) - - def reset(self) -> None: - """Drop any accumulated state. Stateless strategies need do nothing.""" - - -class SimpleRotation(RotationStrategy): - """Plain round robin: every live game once per cycle, weights ignored. - - The fallback for a plugin that wants strictly even rotation regardless of - favorites. - """ - - def schedule(self, games: List[Dict]) -> List[str]: - return [gid for g in games if (gid := _game_id(g)) is not None] - - -class WeightedCycleRotation(RotationStrategy): - """Precomputed SWRR cycle — the football / baseball / basketball / hockey shape. - - Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly - rather than clumped, highest weight scheduled first. When no game carries a - boost the cycle degenerates to a single pass in ``games`` order, which is - exactly the plain round robin it replaced. - """ - - def schedule(self, games: List[Dict]) -> List[str]: - weights = self.weights(games) - if not weights: - return [] - total_weight = sum(weights.values()) - if total_weight <= len(weights): - # No boost in effect — plain order, one pass. (Also the guard that - # keeps the loop below from being O(total_weight) for nothing.) - return list(weights) - - current = {gid: 0 for gid in weights} - order: List[str] = [] - for _ in range(total_weight): - for gid, weight in weights.items(): - current[gid] += weight - picked = max(current, key=lambda gid: current[gid]) - current[picked] -= total_weight - order.append(picked) - return order - - -class SmoothWeightedRotation(RotationStrategy): - """Incremental SWRR — the afl / nrl / soccer shape. - - Weight state persists across calls, so there is no fixed-length cycle and - therefore no clustering seam at a cycle boundary. A game seen for the first - time starts at weight 0 and receives its full weight on the next call, so a - favorite's game that has just gone live naturally wins the first pick after - it appears — "queued first on refresh" without a special-cased branch. - - State for games no longer live is dropped on each call, so a long-running - board does not accumulate entries for finished games. - """ - - def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None): - super().__init__(weight_for) - self._current: Dict[str, int] = {} - - def reset(self) -> None: - self._current = {} - - def next_game(self, games: List[Dict]) -> Optional[Dict]: - if not games: - return None - weights = self.weights(games) - if not weights: - return None - - # Keep state only for games still live. - self._current = { - gid: value for gid, value in self._current.items() if gid in weights - } - for gid, weight in weights.items(): - self._current[gid] = self._current.get(gid, 0) + weight - - total_weight = sum(weights.values()) - # Iterate in `games` order so ties break toward the feed's ordering, - # which is what the plugin copies did and what makes the no-boost case - # identical to a plain round robin. - ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights] - best = max(ids_in_order, key=lambda gid: self._current[gid]) - self._current[best] -= total_weight - return next(g for g in games if _game_id(g) == best) - - def schedule(self, games: List[Dict]) -> List[str]: - """One cycle's worth of picks, without disturbing live state. - - Derived by running the picker forward on a copy, so the returned order - is exactly what repeated :meth:`next_game` calls would produce from the - current state — callers can use it to preview or log the rotation - without perturbing it. - """ - weights = self.weights(games) - if not weights: - return [] - # type(self), not this class: a subclass that overrides next_game must - # be previewed through its own ordering, or the returned order is not - # the one repeated next_game calls would produce — which is exactly - # what this method promises. - preview = type(self)(self._weight_for) - preview._current = dict(self._current) - order: List[str] = [] - for _ in range(sum(weights.values())): - picked = preview.next_game(games) - if picked is None: - break - order.append(_game_id(picked)) - return order - - -_REGISTRY: Dict[str, Type[RotationStrategy]] = {} - - -def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None: - """Register a rotation strategy under ``name``. - - When a plugin needs an ordering that core does not ship, it registers its - own here instead of core growing a sport-specific branch. Re-registering a - name replaces it, so a plugin may also override a built-in for itself. - """ - if not name: - raise ValueError("rotation strategy name must be a non-empty string") - # Fail at registration, not at the first schedule() call several frames - # later, where the cause is no longer on the stack. - if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)): - raise TypeError( - f"rotation strategy {name!r} must be a RotationStrategy subclass, " - f"got {factory!r}" - ) - factory.name = name - _REGISTRY[name] = factory - - -def get_rotation_strategy( - name: str, weight_for: Optional[Callable[[Dict], int]] = None -) -> RotationStrategy: - """Build the strategy registered under ``name``. - - Falls back to ``"simple"`` for an unknown name rather than raising: the name - arrives from user config, and a typo should cost the boost, not the - scoreboard. - """ - factory = _REGISTRY.get(name) or _REGISTRY["simple"] - return factory(weight_for=weight_for) - - -register_rotation_strategy("simple", SimpleRotation) -register_rotation_strategy("weighted", WeightedCycleRotation) -register_rotation_strategy("swrr", SmoothWeightedRotation) diff --git a/src/base_classes/sports/core.py b/src/base_classes/sports/core.py deleted file mode 100644 index c4969386..00000000 --- a/src/base_classes/sports/core.py +++ /dev/null @@ -1,1148 +0,0 @@ -"""SportsCore — the shared fetch/cache/config/render base for the sports -scoreboards. Split out of the former ``src/base_classes/sports.py``; see -docs/SPORTS_UNIFICATION.md for the layering. -""" - -import logging -import os -import tempfile -import time -from abc import ABC, abstractmethod -from collections import OrderedDict -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import pytz -from src.common.espn_dates import fetch_espn_scoreboard -import requests -from PIL import Image, ImageDraw, ImageFont -from src.common.font_layout import load_truetype -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -from src.background_data_service import get_background_service - -# Import new architecture components (individual classes will import what they need) -from src.base_classes.api_extractors import APIDataExtractor -from src.base_classes.data_sources import DataSource -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager -from src.dynamic_team_resolver import DynamicTeamResolver -from src.logo_downloader import LogoDownloader, download_missing_logo -try: - from src.base_odds_manager import BaseOddsManager as OddsManager -except ImportError: - OddsManager = None - -# The core install root, resolved from this module's own location so nothing -# here depends on the process working directory. -# -# The index is the number of directories between this file and the root: -# src/base_classes/sports/core.py -> sports -> base_classes -> src -> root. -# It is defined ONCE, here, because hand-counting it at each use site is -# exactly what broke when this module moved from src/base_classes/sports.py -# into the package (the old depth of 2 silently started resolving to src/). -# If this module ever moves again, this is the single line to update. -_INSTALL_ROOT = Path(__file__).resolve().parents[3] - -# Shared element-style resolver. Core always ships src.element_style, so the -# guard is never taken here — it is kept because this module's methods are -# back-copied verbatim into the plugins' bundled `sports.py`, where older -# cores genuinely lack the module (the plugins fall back to the classic -# inline config read below). -try: - from src.element_style import ElementStyleResolver, defaults_from_schema_file - STYLE_AVAILABLE = True -except ImportError: # pragma: no cover - core always ships element_style - STYLE_AVAILABLE = False - - -# --- Font catalog delegation ------------------------------------------------- -# The plugin copies each carried their own alias table mapping font *family* -# names ("press_start") to filenames. That table is a duplicate of the core -# FontManager's `common_fonts` catalog, so resolve through the catalog instead -# and let the two never drift apart. Same lazy module-level shape as -# skin_runtime._get_font_manager / base_plugin._fallback_font_manager: a -# SportsCore host has no plugin_manager to borrow a FontManager from. - -_shared_font_catalog: Optional[Any] = None - - -def _font_catalog() -> Any: - """Shared read-only FontManager used purely as a font-name catalog.""" - global _shared_font_catalog - if _shared_font_catalog is None: - from src.font_manager import FontManager - _shared_font_catalog = FontManager({}) - return _shared_font_catalog - - -def _resolve_font_family_alias(font_name: str) -> str: - """Resolve a font family alias to its filename, leaving filenames as-is. - - A config `font` value may be either a family name from the FontManager - catalog ("press_start", "four_by_six", "five_by_seven") or a literal - filename; the former resolve here, the latter pass through unchanged. - """ - try: - catalogued = _font_catalog().common_fonts.get(font_name) - except Exception: - return font_name - return os.path.basename(catalogued) if catalogued else font_name - - -def _read_bdf_native_size(bdf_path: str) -> Optional[int]: - """A BDF file's one true pixel size, delegated to FontManager. - - Deliberately NOT reimplemented here: the plugin copies grew a variant - that scans the whole file and returns a partially collected value when - parsing raises, where FontManager's stops at the first STARTCHAR and - returns None on error. - """ - try: - from src.font_manager import FontManager - return FontManager._read_bdf_native_size(bdf_path) - except Exception: - return None - - -class SportsCore(ABC): - # Which ScoreboardSkin render method this class's display path maps to. - # SportsLive inherits the default; SportsUpcoming/SportsRecent override. - SKIN_MODE = "live" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - self.logger = logger - self.config = config - self.cache_manager = cache_manager - self.config_manager = self.cache_manager.config_manager - if OddsManager: - try: - self.odds_manager = OddsManager( - self.cache_manager, self.config_manager) - except Exception as e: - self.logger.warning(f"Failed to initialize OddsManager: {e}") - self.odds_manager = None - else: - self.odds_manager = None - self.logger.warning("OddsManager not available - odds functionality disabled") - self.display_manager = display_manager - self.display_width = self.display_manager.matrix.width - self.display_height = self.display_manager.matrix.height - - self.sport_key = sport_key - self.sport = None - self.league = None - - # Initialize new architecture components (will be overridden by sport-specific classes) - self.sport_config = None - self.api_extractor: APIDataExtractor - self.data_source: DataSource - self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key - self.is_enabled: bool = self.mode_config.get("enabled", False) - self.show_odds: bool = self.mode_config.get("show_odds", False) - # Use LogoDownloader to get the correct default logo directory for this sport - default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key)) - self.logo_dir = self._initialize_logo_dir(default_logo_dir) - self.update_interval: int = self.mode_config.get( - "update_interval_seconds", 60) - self.show_records: bool = self.mode_config.get('show_records', False) - self.show_ranking: bool = self.mode_config.get('show_ranking', False) - # Number of games to show (instead of time-based windows) - self.recent_games_to_show: int = self.mode_config.get( - "recent_games_to_show", 5) # Show last 5 games - self.upcoming_games_to_show: int = self.mode_config.get( - "upcoming_games_to_show", 10) # Show next 10 games - self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False) - self.show_all_live: bool = self.mode_config.get("show_all_live", False) - - self.session = requests.Session() - retry_strategy = Retry( - total=5, # increased number of retries - backoff_factor=1, # increased backoff factor - # added 429 to retry list - status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["GET", "HEAD", "OPTIONS"] - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("https://", adapter) - self.session.mount("http://", adapter) - - # LRU-bounded: entries are decoded RGBA thumbnails, not file bytes. - # Each is up to display_width*1.5 x display_height*1.5 -- about 36KB on - # a 256x64 panel, more for wide wordmarks. The key is a team - # abbreviation and assets/sports/ncaa_logos alone ships 307 of them, so - # an unbounded dict here held the whole league: ~11-18MB per manager - # instance, and a league runs three (live/recent/upcoming) each with - # its own cache. That is real money on a 1GB Pi. - self._logo_cache: "OrderedDict[str, Image.Image]" = OrderedDict() - - # Font caches for _load_custom_font_from_element_config: per-frame - # callers (font-ladder walks) resolve the same (name, size) over and - # over, and the BDF strike size means re-reading a file header. - # Both are released in cleanup(). - self._font_cache: Dict[Tuple[str, int], Any] = {} - self._bdf_native_size_cache: Dict[str, Optional[int]] = {} - - # Set up headers - self.headers = { - 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', - 'Accept': 'application/json', - 'Accept-Language': 'en-US,en;q=0.9', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive' - } - self.last_update = 0 - self.current_game = None - # Cooldown clocks for _should_log(), one per warning type. Initialized - # here rather than lazily: _should_log() reads them unguarded, so - # whichever warning fires first would otherwise raise AttributeError - # instead of logging. `_last_warning_time` is the single-clock field - # the plugin copies expose; kept for subclasses that read it. - self._warning_cooldowns: Dict[str, float] = {} - self._last_warning_time = 0 - self.fonts = self._load_fonts() - - # Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a - # skin id applied to all modes, or a per-mode mapping like - # {"live": "retro", "recent": "built-in"}. Loaded lazily on first - # render so a broken skin can never block startup. - self._skin_config = self.mode_config.get("skin") - self.skin_options = self.mode_config.get("skin_options", {}) or {} - self._skin = None - self._skin_load_attempted = False - self._skin_failures = 0 - self._skin_slow_renders = 0 - - # Initialize dynamic team resolver and resolve favorite teams - self.dynamic_resolver = DynamicTeamResolver() - raw_favorite_teams = self.mode_config.get("favorite_teams", []) - self.favorite_teams = self.dynamic_resolver.resolve_teams(raw_favorite_teams, sport_key) - - # Log dynamic team resolution - if raw_favorite_teams != self.favorite_teams: - self.logger.info(f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}") - else: - self.logger.info(f"Favorite teams: {self.favorite_teams}") - - self.logger.setLevel(logging.INFO) - - # Initialize team rankings cache - self._team_rankings_cache = {} - self._rankings_cache_timestamp = 0 - self._rankings_cache_duration = 3600 # Cache rankings for 1 hour - - # Initialize background data service with optimized settings - # Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries - self.background_service = get_background_service(self.cache_manager, max_workers=1) - self.background_fetch_requests = {} # Track background fetch requests - self.background_enabled = True - self.logger.info("Background service enabled with 1 worker (memory optimized)") - - def _initialize_logo_dir(self, configured_path: Path) -> Path: - """Resolve and ensure a writable logo directory, falling back when necessary.""" - downloader = LogoDownloader() - resolved_configured = self._resolve_project_path(configured_path) - candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured) - - for candidate in candidates: - candidate_path = self._resolve_project_path(candidate) - if downloader.ensure_logo_directory(str(candidate_path)): - if candidate_path != resolved_configured: - self.logger.warning( - "Configured logo directory '%s' is not writable; using fallback '%s'", - resolved_configured, - candidate_path, - ) - return candidate_path - - self.logger.error( - "Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)", - resolved_configured, - ) - return resolved_configured - - def _resolve_project_path(self, path: Path) -> Path: - """Convert relative paths to absolute ones rooted at the project directory.""" - if path.is_absolute(): - return path - return (_INSTALL_ROOT / path).resolve() - - def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]: - """Return fallback directories to try when the configured directory is not writable.""" - fallbacks: List[Path] = [] - - env_override = os.environ.get("LEDMATRIX_LOGO_DIR") - if env_override: - env_path = Path(env_override) - if not env_path.is_absolute(): - env_path = self._resolve_project_path(env_path) - fallbacks.append(env_path / self.sport_key) - - cache_dir = getattr(self.cache_manager, "cache_dir", None) - if cache_dir: - fallbacks.append(Path(cache_dir) / "logos" / self.sport_key) - - try: - fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key) - except RuntimeError as e: - self.logger.debug("Could not resolve home directory (expected for service users): %s", e) - - fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key) - - unique_fallbacks: List[Path] = [] - seen = set() - for candidate in fallbacks: - if candidate == configured_dir: - continue - if candidate not in seen: - unique_fallbacks.append(candidate) - seen.add(candidate) - - return unique_fallbacks - - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts['status']) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True) - - - def _resolve_skin_id(self) -> Optional[str]: - """The skin id configured for this instance's mode, or None for the - built-in renderer. Accepts a plain id (all modes) or a per-mode - mapping ({"live": "retro-baseball", "recent": "built-in"}).""" - skin_id = self._skin_config - if isinstance(skin_id, dict): - skin_id = skin_id.get(self.SKIN_MODE) - if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in": - return None - return skin_id - - def _get_skin(self): - """Lazily load the configured skin once. Returns None (built-in - renderer) when no skin is configured or loading failed.""" - if not self._skin_load_attempted: - self._skin_load_attempted = True - skin_id = self._resolve_skin_id() - if skin_id: - try: - from src.skin_system import skin_runtime - self._skin = skin_runtime.load_skin( - skin_id, sport=self.sport, sport_key=self.sport_key, - options=self.skin_options) - except Exception as e: - self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True) - self._skin = None - return self._skin - - def _render_game(self, game: Dict, force_clear: bool = False) -> None: - """Render one game: try the configured skin first, fall back to the - built-in _draw_scorebug_layout. A skin that raises 3 times in a row - is disabled for the rest of the session.""" - skin = self._get_skin() - if skin is not None and self._skin_failures < 3: - try: - from src.skin_system import skin_runtime - ctx = skin_runtime.build_context(self, game) - render = getattr(skin, f"render_{self.SKIN_MODE}") - started = time.monotonic() - handled = render(ctx, dict(game)) - elapsed = time.monotonic() - started - if elapsed > 0.15 and self._skin_slow_renders < 5: - self._skin_slow_renders += 1 - self.logger.warning( - f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to " - f"render {self.SKIN_MODE} — slow renders stall the whole display loop") - if handled: - self._skin_failures = 0 - self.display_manager.image.paste(ctx.canvas, (0, 0)) - self.display_manager.update_display() - return - except Exception: - self._skin_failures += 1 - outcome = ("disabling skin for this session" if self._skin_failures >= 3 - else "falling back to built-in renderer") - self.logger.error( - f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} " - f"({self._skin_failures}/3); {outcome}", exc_info=True) - self._draw_scorebug_layout(game, force_clear) - - def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]: - """Render one game as a standalone card via the configured skin — - for vegas mode and previews. Tries render_vegas_card at the given - size, then the mode renderer on a card-sized canvas. Returns None - when no skin is active or the skin declined, so callers can use - their default rendering.""" - skin = self._get_skin() - if skin is None or self._skin_failures >= 3: - return None - try: - from src.skin_system import skin_runtime - ctx = skin_runtime.build_context(self, game, size=size) - card = skin.render_vegas_card(ctx, dict(game)) - if card is not None: - # A successful render clears accumulated strikes, mirroring - # _render_game — transient failures must not add up across - # the session and disable a working skin. - self._skin_failures = 0 - return card - ctx = skin_runtime.build_context(self, game, size=size) - render = getattr(skin, f"render_{self.SKIN_MODE}") - if render(ctx, dict(game)): - self._skin_failures = 0 - return ctx.canvas - except Exception: - # Card failures count toward the same 3-strike session disable - # as display failures — a skin broken for vegas shouldn't get - # to throw on every scroll tick forever. - self._skin_failures += 1 - self.logger.error( - f"Skin '{self._resolve_skin_id()}' card render failed " - f"({self._skin_failures}/3)", exc_info=True) - return None - - def display(self, force_clear: bool = False) -> bool: - """Common display method for all NCAA FB managers""" # Updated docstring - if not self.is_enabled: # Check if module is enabled - return False - - if not self.current_game: - # Clear display if force_clear is True, even when there's no content - # This prevents black screens when switching to modes with no content - if force_clear: - try: - self.display_manager.clear() - self.display_manager.update_display() - except Exception as e: - self.logger.debug(f"Error clearing display when no content: {e}") - - current_time = time.time() - if not hasattr(self, '_last_warning_time'): - self._last_warning_time = 0 - if current_time - getattr(self, '_last_warning_time', 0) > 300: - self.logger.warning(f"No game data available to display in {self.__class__.__name__}") - setattr(self, '_last_warning_time', current_time) - return False - - try: - self._render_game(self.current_game, force_clear) - # display_manager.update_display() should be called within subclass draw methods - # or after calling display() in the main loop. Let's keep it out of the base display. - return True - except Exception as e: - self.logger.error(f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True) - return False - - - def _load_fonts(self) -> Dict[str, Any]: - """Load fonts used by the scoreboard. - - Paths go through :meth:`_resolve_font_path` so the bundled fonts are - found regardless of the process working directory — a bare - ``"assets/fonts/..."`` silently degraded every scoreboard to the PIL - default font whenever the process started elsewhere (the plugin safety - harness on CI being the case that surfaced it). - """ - fonts: Dict[str, Any] = {} - press_start = self._resolve_font_path("PressStart2P-Regular.ttf") - four_by_six = self._resolve_font_path("4x6-font.ttf") - try: - fonts['score'] = load_truetype(press_start, 10) - fonts['time'] = load_truetype(press_start, 8) - fonts['team'] = load_truetype(press_start, 8) - fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status - fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font - fonts['rank'] = load_truetype(press_start, 10) - self.logger.info("Successfully loaded fonts") - except OSError: - # Name the directory we searched: the usual cause is an install - # whose assets/fonts is missing, and the bare message sent people - # hunting for a font-format problem instead. - self.logger.warning( - "Fonts not found under %s, using default PIL font.", - self._font_root(), - ) - fonts['score'] = ImageFont.load_default() - fonts['time'] = ImageFont.load_default() - fonts['team'] = ImageFont.load_default() - fonts['status'] = ImageFont.load_default() - fonts['detail'] = ImageFont.load_default() - fonts['rank'] = ImageFont.load_default() - return fonts - - def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: - """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" - home_team_odds = odds.get('home_team_odds', {}) - away_team_odds = odds.get('away_team_odds', {}) - home_spread = home_team_odds.get('spread_odds') - away_spread = away_team_odds.get('spread_odds') - - # Get top-level spread as fallback - top_level_spread = odds.get('spread') - - # If we have a top-level spread and the individual spreads are None or 0, use the top-level - if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: - home_spread = top_level_spread - if away_spread is None: - away_spread = -top_level_spread - - # Determine which team is favored (has negative spread) - home_favored = home_spread is not None and home_spread < 0 - away_favored = away_spread is not None and away_spread < 0 - - # Only show the negative spread (favored team) - favored_spread = None - favored_side = None - - if home_favored: - favored_spread = home_spread - favored_side = 'home' - self.logger.debug(f"Home team favored with spread: {favored_spread}") - elif away_favored: - favored_spread = away_spread - favored_side = 'away' - self.logger.debug(f"Away team favored with spread: {favored_spread}") - else: - self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}") - - # Show the negative spread on the appropriate side - if favored_spread is not None: - spread_text = str(favored_spread) - font = self.fonts['detail'] # Use detail font for odds - - if favored_side == 'home': - # Home team is favored, show spread on right side - spread_width = draw.textlength(spread_text, font=font) - spread_x = width - spread_width # Top right - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing home spread '{spread_text}' on right side") - else: - # Away team is favored, show spread on left side - spread_x = 0 # Top left - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing away spread '{spread_text}' on left side") - - # Show over/under on the opposite side of the favored team - over_under = odds.get('over_under') - if over_under is not None: - ou_text = f"O/U: {over_under}" - font = self.fonts['detail'] # Use detail font for odds - ou_width = draw.textlength(ou_text, font=font) - - if favored_side == 'home': - # Home team is favored, show O/U on left side (opposite of spread) - ou_x = 0 # Top left - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)") - elif favored_side == 'away': - # Away team is favored, show O/U on right side (opposite of spread) - ou_x = width - ou_width # Top right - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)") - else: - # No clear favorite, show O/U in center - ou_x = (width - ou_width) // 2 - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)") - - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) - - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): - """Draw text with a black outline for better readability.""" - x, y = position - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - - #: Decoded logos to keep. A scroll of "other games" shows on the order of - #: 20 games (40 teams), so this holds a full cycle without thrashing while - #: capping the cache well below a 307-team league. - _LOGO_CACHE_MAX = 64 - - def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: - """Load and resize a team logo, with caching and automatic download if missing.""" - self.logger.debug(f"Logo path: {logo_path}") - if team_abbrev in self._logo_cache: - self.logger.debug(f"Using cached logo for {team_abbrev}") - self._logo_cache.move_to_end(team_abbrev) - return self._logo_cache[team_abbrev] - - try: - # Try different filename variations first (for cases like TA&M vs TAANDM) - actual_logo_path = None - filename_variations = LogoDownloader.get_logo_filename_variations(team_abbrev) - - for filename in filename_variations: - test_path = logo_path.parent / filename - if test_path.exists(): - actual_logo_path = test_path - self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") - break - - # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): - self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") - - # Try to download the logo from ESPN API (this will create placeholder if download fails) - download_missing_logo(self.sport_key, team_id, team_abbrev, logo_path, logo_url) - actual_logo_path = logo_path - - # Use the original path if no alternative was found - if not actual_logo_path: - actual_logo_path = logo_path - - # Only try to open the logo if the file exists - if os.path.exists(actual_logo_path): - logo = Image.open(actual_logo_path) - else: - self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt") - return None - if logo.mode != 'RGBA': - logo = logo.convert('RGBA') - - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - self._logo_cache[team_abbrev] = logo - while len(self._logo_cache) > self._LOGO_CACHE_MAX: - self._logo_cache.popitem(last=False) - return logo - - except Exception as e: - self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True) - return None - - def _fetch_odds(self, game: Dict) -> None: - """Fetch odds for a specific game using the new architecture.""" - try: - if not self.show_odds: - return - - if not self.odds_manager: - return - - # Determine update interval based on game state - is_live = game.get('is_live', False) - update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \ - else self.mode_config.get("odds_update_interval", 3600) - - # Fetch odds using OddsManager - odds_data = self.odds_manager.get_odds( - sport=self.sport, - league=self.league, - event_id=game['id'], - update_interval_seconds=update_interval, - ) - - if odds_data: - game['odds'] = odds_data - self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}") - else: - self.logger.debug(f"No odds data returned for game {game['id']}") - - except Exception as e: - self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") - - def _get_timezone(self): - try: - timezone_str = self.config.get('timezone', 'UTC') - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - return pytz.utc - - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Whether a warning of this kind is outside its cooldown window. - - Cooldowns are tracked **per ``warning_type``**. They previously shared - one timestamp, so the parameter was accepted and ignored: an API-error - warning would silence an unrelated cache warning for the next minute, - and whichever fired first won. Nothing in core called this, so no - behavior regressed with the fix — but every caller has always been - entitled to assume its own warning type has its own clock. - """ - current_time = time.time() - if current_time - self._warning_cooldowns.get(warning_type, 0) > cooldown: - self._warning_cooldowns[warning_type] = current_time - # Kept in step for subclasses that read it directly. - self._last_warning_time = current_time - return True - return False - - def _fetch_team_rankings(self) -> Dict[str, int]: - """Fetch team rankings using the new architecture components.""" - current_time = time.time() - - # Check if we have cached rankings that are still valid - if (self._team_rankings_cache and - current_time - self._rankings_cache_timestamp < self._rankings_cache_duration): - return self._team_rankings_cache - - try: - data = self.data_source.fetch_standings(self.sport, self.league) - - rankings = {} - rankings_data = data.get('rankings', []) - - if rankings_data: - # Use the first ranking (usually AP Top 25) - first_ranking = rankings_data[0] - teams = first_ranking.get('ranks', []) - - for team_data in teams: - team_info = team_data.get('team', {}) - team_abbr = team_info.get('abbreviation', '') - current_rank = team_data.get('current', 0) - - if team_abbr and current_rank > 0: - rankings[team_abbr] = current_rank - - # Cache the results - self._team_rankings_cache = rankings - self._rankings_cache_timestamp = current_time - - self.logger.debug(f"Fetched rankings for {len(rankings)} teams") - return rankings - - except Exception as e: - self.logger.error(f"Error fetching team rankings: {e}") - return {} - - def _extract_game_details_common(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: - if not game_event: - return None, None, None, None, None - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - situation = competition.get("situation") - start_time_utc = None - try: - # Parse the datetime string - if game_date_str.endswith('Z'): - game_date_str = game_date_str.replace('Z', '+00:00') - dt = datetime.fromisoformat(game_date_str) - # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) - if dt.tzinfo is None: - # If naive, assume it's UTC - start_time_utc = dt.replace(tzinfo=pytz.UTC) - else: - # Convert to pytz.UTC for consistency - start_time_utc = dt.astimezone(pytz.UTC) - except ValueError: - logging.warning(f"Could not parse game date: {game_date_str}") - - home_team = next((c for c in competitors if c.get("homeAway") == "home"), None) - away_team = next((c for c in competitors if c.get("homeAway") == "away"), None) - - if not home_team or not away_team: - self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") - return None, None, None, None, None - - try: - home_abbr = home_team["team"]["abbreviation"] - except KeyError: - home_abbr = home_team["team"]["name"][:3] - try: - away_abbr = away_team["team"]["abbreviation"] - except KeyError: - away_abbr = away_team["team"]["name"][:3] - - # Check if this is a favorite team game BEFORE doing expensive logging - is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams) - - # Only log debug info for favorite team games - if is_favorite_game: - self.logger.debug(f"Processing favorite team game: {game_event.get('id')}") - self.logger.debug(f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}") - - game_time, game_date = "", "" - if start_time_utc: - local_time = start_time_utc.astimezone(self._get_timezone()) - game_time = local_time.strftime("%I:%M%p").lstrip('0') - - # Check date format from config - use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) - if use_short_date_format: - game_date = local_time.strftime("%-m/%-d") - else: - game_date = self.display_manager.format_date_with_ordinal(local_time) - - - home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' - away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' - - # Don't show "0-0" records - set to blank instead - if home_record in {"0-0", "0-0-0"}: - home_record = '' - if away_record in {"0-0", "0-0-0"}: - away_record = '' - - details = { - "id": game_event.get("id"), - "game_time": game_time, - "game_date": game_date, - "start_time_utc": start_time_utc, - "status_text": status["type"]["shortDetail"], # e.g., "Final", "7:30 PM", "Q1 12:34" - "is_live": status["type"]["state"] == "in", - "is_final": status["type"]["state"] == "post", - "is_upcoming": (status["type"]["state"] == "pre" or - status["type"]["name"].lower() in ['scheduled', 'pre-game', 'status_scheduled']), - "is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check - "is_period_break": status["type"]["name"] == "STATUS_END_PERIOD", # Added Period Break check - "home_abbr": home_abbr, - "home_id": home_team["id"], - "home_score": home_team.get("score", "0"), - "home_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"), - "home_logo_url": home_team["team"].get("logo"), - "home_record": home_record, - "away_record": away_record, - "away_abbr": away_abbr, - "away_id": away_team["id"], - "away_score": away_team.get("score", "0"), - "away_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), - "away_logo_url": away_team["team"].get("logo"), - "is_within_window": True, # Whether game is within display window - - } - return details, home_team, away_team, status, situation - except Exception as e: - # Log the problematic event structure if possible - logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) - return None, None, None, None, None - - @abstractmethod - def _extract_game_details(self, game_event: dict) -> dict | None: - details, _, _, _, _ = self._extract_game_details_common(game_event) - return details - - @abstractmethod - def _fetch_data(self) -> Optional[Dict]: - pass - - def _fetch_todays_games(self) -> Optional[Dict]: - """Fetch only today's games for live updates (not entire season).""" - try: - tz = pytz.timezone("America/New_York") # Use full name (not "EST") for DST support - now = datetime.now(tz) - yesterday = now - timedelta(days=1) - formatted_date = now.strftime("%Y%m%d") - formatted_date_yesterday = yesterday.strftime("%Y%m%d") - # Fetch todays games only - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - data = fetch_espn_scoreboard( - self.session, url, - params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, - headers=self.headers, timeout=10, logger=self.logger, - ) - events = data.get('events', []) - - self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}") - return {'events': events} - except requests.exceptions.RequestException as e: - self.logger.error(f"API error fetching todays games for {self.sport} - {self.league}: {e}") - return None - - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now + timedelta(weeks=-2) - end_date = now + timedelta(weeks=1) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - data = fetch_espn_scoreboard( - self.session, url, params={"dates": date_str, "limit": 1000}, - headers=self.headers, timeout=10, logger=self.logger, - ) - immediate_events = data.get('events', []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {'events': immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning(f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}") - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - - # ------------------------------------------------------------------ - # Promoted from the plugin copies (see docs/SPORTS_UNIFICATION.md). - # Everything below is present in all nine bundled `sports.py` copies; - # the canonical form of each is documented on the method. - # ------------------------------------------------------------------ - - def _favorite_key(self, game: Dict, side: str) -> Optional[str]: - """Override point: which view-model field identifies a team when - matching against ``favorite_teams``. - - ``side`` is ``"home"`` or ``"away"``. The default is the team - abbreviation — what eight of the nine scoreboards match on, and what - users type into their favorites list. - - NRL overrides this to the team **id**, because NRL abbreviations are - not unique: "NEW" is both Newcastle Knights and New Zealand Warriors, - "CAN" both Canberra Raiders and Canterbury Bulldogs. Matching those by - abbreviation selects the wrong club. It is a seam rather than a branch - precisely so core never has to learn the string "nrl":: - - def _favorite_key(self, game, side): - return str(game.get(f"{side}_id")) - - An override that stringifies should note that a missing id becomes the - literal ``"None"``, which would spuriously match a favorites list - containing that string. The default returns ``None``, which never - matches. - """ - return game.get(f"{side}_abbr") - - def _config_schema_path(self) -> Optional[str]: - """Override point: the plugin's ``config_schema.json``, used as the - reference for style-resolver defaults. - - None (the default) means no schema is available — layout offsets are - then read with the classic inline config lookup, which is exactly what - a plugin that never shipped resolver support does today. A plugin - opting into :mod:`src.element_style` returns its own schema path:: - - def _config_schema_path(self): - return os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'config_schema.json') - - It must not be derived from this module's ``__file__``: after - promotion that resolves inside ``src/base_classes/sports/``, where no - ``config_schema.json`` exists. - """ - return None - - def _font_root(self) -> str: - """Override point: the directory ``assets/fonts`` resolves against. - - Defaults to the core install root, derived from this module's own - location — the same strategy as ``FontManager._resolve_asset_path``. - A plugin bundling its own fonts overrides this to return its plugin - directory. Never a cwd-relative path: fonts must load no matter where - the process was started from (e.g. the plugin safety harness on CI). - """ - return str(_INSTALL_ROOT) - - def _resolve_font_path(self, font_name: str) -> str: - """Locate a font file by filename, independently of the process cwd. - - Tries ``assets/fonts/`` relative to the cwd first (preserving - behavior for a process started from an install root), then under - :meth:`_font_root`. Returns the cwd-relative path unchanged when the - file is nowhere to be found, so callers log the familiar path. - """ - relative = os.path.join('assets', 'fonts', font_name) - if os.path.exists(relative): - return relative - candidate = os.path.join(self._font_root(), relative) - if os.path.exists(candidate): - return candidate - return relative - - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: - """ - Get layout offset for a specific element and axis. - - Args: - element: Element name (e.g., 'home_logo', 'score', 'status_text') - axis: 'x_offset' or 'y_offset' (or 'away_x_offset', 'home_x_offset' for records) - default: Default value if not configured (default: 0) - - Returns: - Offset value from config or default (always returns int) - """ - schema_path = self._config_schema_path() if STYLE_AVAILABLE else None - if schema_path: - # Shared resolver (rebuilt if the config dict was swapped out, - # matching the classic path's read-config-on-every-call semantics). - # Note it is stricter than the classic read below: a boolean offset - # degrades to the default instead of counting as 1/0, which is the - # more correct reading of a pixel offset. - resolver = getattr(self, '_style_resolver_cached', None) - if resolver is None or resolver._config is not self.config: - resolver = ElementStyleResolver( - self.config, defaults_from_schema_file(schema_path)) - self._style_resolver_cached = resolver - return resolver.offset_value(element, axis, default) - try: - layout_config = self.config.get('customization', {}).get('layout', {}) - element_config = layout_config.get(element, {}) - offset_value = element_config.get(axis, default) - - # Ensure we return an integer (handle float/string from config) - if isinstance(offset_value, (int, float)): - return int(offset_value) - elif isinstance(offset_value, str): - # Try to convert string to int - try: - return int(float(offset_value)) - except (ValueError, TypeError): - self.logger.warning( - f"Invalid layout offset value for {element}.{axis}: '{offset_value}', using default {default}" - ) - return default - else: - return default - except Exception as e: - # Gracefully handle any config access errors - self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") - return default - - def _load_custom_font_from_element_config( - self, - element_config: Dict[str, Any], - default_size: int = 8, - default_font: Optional[str] = None, - ) -> ImageFont.FreeTypeFont: - """ - Load a custom font from an element configuration dictionary. - - Args: - element_config: Configuration dict for a single element containing 'font' and 'font_size' keys - default_size: Default font size if not specified in config - default_font: Default font filename when not specified in config (e.g. '4x6-font.ttf' for odds) - - Returns: - PIL ImageFont object - """ - base_default = default_font or "PressStart2P-Regular.ttf" - font_name = element_config.get('font', base_default) - font_size = int(element_config.get('font_size', default_size)) # Ensure integer for PIL - - # Resolve family aliases (e.g. "press_start") to real filenames, then - # locate the file against _font_root() rather than the cwd. - resolved_name = _resolve_font_family_alias(font_name) - font_path = self._resolve_font_path(resolved_name) - - # Memoized: per-frame callers (font-ladder walks) resolve the same - # (name, size) repeatedly -- return the previously loaded face. - cache_key = (resolved_name, font_size) - cached_font = self._font_cache.get(cache_key) - if cached_font is not None: - return cached_font - - # Try to load the font - try: - if os.path.exists(font_path): - # Try loading as TTF first (works for both TTF and some BDF files with PIL) - if font_path.lower().endswith('.ttf'): - font = load_truetype(font_path, font_size) - self.logger.debug(f"Loaded font: {font_name} at size {font_size}") - self._font_cache[cache_key] = font - return font - elif font_path.lower().endswith('.bdf'): - # BDF fonts are fixed-size bitmaps, not scalable outlines -- - # FreeType only accepts the exact pixel size baked into the - # file (its "strike") and raises "invalid pixel size" for - # anything else. Try the requested size first (in case it - # happens to match), then fall back to the file's real - # native size, so a BDF font can still be selected via - # font_size-driven configs without the caller needing to - # know its exact strike size. - # - # This retry is the OLDER lineage's behavior and it is the - # correct one: the newer copies call truetype() on a BDF at - # any size (which simply fails) or refuse BDF outright. - try: - font = load_truetype(font_path, font_size) - self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") - self._font_cache[cache_key] = font - return font - except OSError: - if font_path in self._bdf_native_size_cache: - native_size = self._bdf_native_size_cache[font_path] - else: - native_size = _read_bdf_native_size(font_path) - self._bdf_native_size_cache[font_path] = native_size - if native_size and native_size != font_size: - try: - font = load_truetype(font_path, native_size) - self.logger.debug( - f"Loaded BDF font: {font_name} at its native size {native_size} " - f"(requested {font_size} isn't a valid strike for this file)" - ) - self._font_cache[cache_key] = font - return font - except Exception as retry_exc: - self.logger.debug( - f"BDF font {font_name} also failed to load at native " - f"size {native_size}: {retry_exc}" - ) - self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default") - # Fall through to default - else: - self.logger.warning(f"Unknown font file type: {font_name}, using default") - else: - self.logger.warning(f"Font file not found: {font_path}, using default") - except Exception as e: - self.logger.error(f"Error loading font {font_name}: {e}, using default") - - # Fall back to default font. Cached under the requested (name, size) - # key too, so a misconfigured or missing font pays the disk cost once - # instead of on every frame of a font-ladder walk. - default_font_path = self._resolve_font_path( - _resolve_font_family_alias(base_default)) - try: - if os.path.exists(default_font_path): - font = load_truetype(default_font_path, font_size) - else: - self.logger.warning("Default font not found, using PIL default") - font = ImageFont.load_default() - except Exception as e: - self.logger.error(f"Error loading default font: {e}") - font = ImageFont.load_default() - self._font_cache[cache_key] = font - return font - - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - # Font caches hold PIL faces; without this they are an unbounded - # per-instance leak across enable/disable cycles. - if hasattr(self, '_font_cache'): - self._font_cache.clear() - if hasattr(self, '_bdf_native_size_cache'): - self._bdf_native_size_cache.clear() - - # NOTE: self.background_service is deliberately NOT shut down here. - # get_background_service() returns a PROCESS-WIDE singleton shared by - # every scoreboard; shutting it down from one unloading plugin would - # stop background fetching for all the others. Whoever owns the - # process owns its lifecycle. Do not "fix" this. - - self.logger.info(f"{self.__class__.__name__} cleanup completed") diff --git a/src/base_classes/sports/modes.py b/src/base_classes/sports/modes.py deleted file mode 100644 index 8f6e3cce..00000000 --- a/src/base_classes/sports/modes.py +++ /dev/null @@ -1,1097 +0,0 @@ -"""The three display modes layered on SportsCore: SportsUpcoming, -SportsRecent and SportsLive. Split out of the former -``src/base_classes/sports.py``; see docs/SPORTS_UNIFICATION.md. -""" - -import logging -import time -from abc import abstractmethod -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List - -from PIL import Image, ImageDraw, ImageFont - -from src.cache_manager import CacheManager -from src.display_manager import DisplayManager - -from .core import SportsCore - - -class SportsUpcoming(SportsCore): - SKIN_MODE = "upcoming" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.upcoming_games = [] # Store all fetched upcoming games initially - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get("upcoming_update_interval", 3600) # Check for recent games every hour - self.last_log_time = 0 - self.log_interval = 300 - self.last_warning_time = 0 - self.warning_cooldown = 300 - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each upcoming game for 15 seconds - - def _select_games_for_display( - self, processed_games: List[Dict], favorite_teams: List[str] - ) -> List[Dict]: - """ - Single-pass game selection with proper deduplication and counting. - - When a game involves two favorite teams, it counts toward BOTH teams' limits. - This prevents unexpected game counts from the multi-pass algorithm. - - Team identity goes through the ``_favorite_key`` override point rather - than reading ``home_abbr``/``away_abbr`` directly, because abbreviations - are not unique in every league (NRL matches on team ID instead). - """ - sorted_games = sorted( - processed_games, - key=lambda g: g.get("start_time_utc") - or datetime.max.replace(tzinfo=timezone.utc), - ) - - if not favorite_teams: - return sorted_games - - selected_games = [] - selected_ids = set() - team_counts = {team: 0 for team in favorite_teams} - - for game in sorted_games: - game_id = game.get("id") - if game_id in selected_ids: - continue - - home = self._favorite_key(game, "home") - away = self._favorite_key(game, "away") - - home_fav = home in favorite_teams - away_fav = away in favorite_teams - - if not home_fav and not away_fav: - continue - - home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show - away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show - - if home_needs or away_needs: - selected_games.append(game) - selected_ids.add(game_id) - if home_fav: - team_counts[home] += 1 - if away_fav: - team_counts[away] += 1 - - self.logger.debug( - f"Selected game {away}@{home}: team_counts={team_counts}" - ) - - if all(c >= self.upcoming_games_to_show for c in team_counts.values()): - self.logger.debug("All favorite teams satisfied, stopping selection") - break - - self.logger.info( - f"Selected {len(selected_games)} games for {len(favorite_teams)} " - f"favorite teams: {team_counts}" - ) - return selected_games - - def update(self): - """Update upcoming games data.""" - if not self.is_enabled: return - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - self.last_update = current_time - - # Fetch rankings if enabled - if self.show_ranking: - self._fetch_team_rankings() - - try: - data = self._fetch_data() # Uses shared cache - if not data or 'events' not in data: - self.logger.warning("No events found in shared data.") # Changed log prefix - if not self.games_list: self.current_game = None - return - - events = data['events'] - # self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix - - processed_games = [] - favorite_games_found = 0 - all_upcoming_games = 0 # Count all upcoming games regardless of favorites - - for event in events: - game = self._extract_game_details(event) - # Count all upcoming games for debugging - if game and game['is_upcoming']: - all_upcoming_games += 1 - - # Filter criteria: must be upcoming ('pre' state) - if game and game['is_upcoming']: - # Only fetch odds for games that will be displayed - if self.show_favorite_teams_only: - if not self.favorite_teams: - continue - if game['home_abbr'] not in self.favorite_teams and game['away_abbr'] not in self.favorite_teams: - continue - processed_games.append(game) - # Count favorite team games for logging - if (game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams): - favorite_games_found += 1 - # Odds are NOT fetched here -- see after selection below. - - # Enhanced logging for debugging - self.logger.info(f"Found {all_upcoming_games} total upcoming games in data") - self.logger.info(f"Found {len(processed_games)} upcoming games after filtering") - - if processed_games: - for game in processed_games[:3]: # Show first 3 - self.logger.info(f" {game['away_abbr']}@{game['home_abbr']} - {game['start_time_utc']}") - - if self.favorite_teams and all_upcoming_games > 0: - self.logger.info(f"Favorite teams: {self.favorite_teams}") - self.logger.info(f"Found {favorite_games_found} favorite team upcoming games") - - # Filter for favorite teams only if the config is set - if self.show_favorite_teams_only: - # Select N games per favorite team (where N = upcoming_games_to_show) - # Example: upcoming_games_to_show=2 with 3 favorite teams = up to 6 games total - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - if team_specific_games := [game for game in processed_games if game['home_abbr'] == team or game['away_abbr'] == team]: - # Sort by game time and take the earliest N games - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - # Take up to upcoming_games_to_show games for this team - team_games.extend(team_specific_games[:self.upcoming_games_to_show]) - - # Sort the final list by game time (earliest first) - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - # Remove duplicates (in case a game involves multiple favorite teams) - seen_ids = set() - unique_team_games = [] - for game in team_games: - if game['id'] not in seen_ids: - seen_ids.add(game['id']) - unique_team_games.append(game) - team_games = unique_team_games - else: - team_games = processed_games # Show all upcoming if no favorites - # Sort by game time, earliest first - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - # Limit to the specified number of upcoming games - team_games = team_games[:self.upcoming_games_to_show] - - # Odds are fetched here, for the games that survived selection, - # rather than in the loop that collects them. That loop walks every - # upcoming game in the schedule window, and for a college league - # the window is enormous -- a live rig logged 946 upcoming games in - # one cycle and displayed 1 of them. The comment up there claimed - # odds were fetched "only for games that will be displayed", but - # the only narrowing it applied was show_favorite_teams_only, which - # is not the default; in the usual case nothing narrowed it at all - # and every game cost a separate ESPN request on a Pi that is also - # driving the panel. - if self.show_odds: - for game in team_games: - self._fetch_odds(game) - - # Log changes or periodically - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(team_games) != len(self.games_list) or - any(g1['id'] != g2.get('id') for g1, g2 in zip(self.games_list, team_games)) or - (not self.games_list and team_games) - ) - - # Check if the list of games to display has changed - new_game_ids = {g['id'] for g in team_games} - current_game_ids = {g['id'] for g in self.games_list} - - if new_game_ids != current_game_ids: - self.logger.info(f"Found {len(team_games)} upcoming games within window for display.") # Changed log prefix - self.games_list = team_games - if not self.current_game or not self.games_list or self.current_game['id'] not in new_game_ids: - self.current_game_index = 0 - self.current_game = self.games_list[0] if self.games_list else None - self.last_game_switch = current_time - else: - try: - self.current_game_index = next(i for i, g in enumerate(self.games_list) if g['id'] == self.current_game['id']) - self.current_game = self.games_list[self.current_game_index] - except StopIteration: - self.current_game_index = 0 - self.current_game = self.games_list[0] - self.last_game_switch = current_time - - elif self.games_list: - self.current_game = self.games_list[self.current_game_index] # Update data - - if not self.games_list: - self.logger.info("No relevant upcoming games found to display.") # Changed log prefix - self.current_game = None - - if should_log and not self.games_list: - # Log favorite teams only if no games are found and logging is needed - self.logger.debug(f"Favorite teams: {self.favorite_teams}") # Changed log prefix - self.logger.debug(f"Total upcoming games before filtering: {len(processed_games)}") # Changed log prefix - self.last_log_time = current_time - elif should_log: - self.last_log_time = current_time - - except Exception as e: - self.logger.error(f"Error updating upcoming games: {e}", exc_info=True) # Changed log prefix - # self.current_game = None # Decide if clear on error - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the layout for an upcoming NCAA FB game.""" # Updated docstring - try: - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) - away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for game: {game.get('id')}") # Changed log prefix - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positions - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw Text Elements on Overlay - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") - - # Note: Rankings are now handled in the records/rankings section below - - # "Next Game" at the top (use smaller status font) - status_font = self.fonts['status'] - if self.display_width > 128: - status_font = self.fonts['time'] - status_text = "Next Game" - status_width = draw_overlay.textlength(status_text, font=status_font) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Changed from 2 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), status_font) - - # Date text (centered, below "Next Game") - date_width = draw_overlay.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - # Adjust Y position to stack date and time nicely - date_y = center_y - 7 # Raise date slightly - self._draw_text_with_outline(draw_overlay, game_date, (date_x, date_y), self.fonts['time']) - - # Time text (centered, below Date) - time_width = draw_overlay.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = date_y + 9 # Place time below date - self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get('away_abbr', '') - home_abbr = game.get('home_abbr', '') - - record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = '' - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = '' - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get('away_record', '') - else: - away_text = '' - - if away_text: - away_record_x = 0 - self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = '' - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = '' - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get('home_record', '') - else: - home_text = '' - - if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here - - except Exception as e: - self.logger.error(f"Error displaying upcoming game: {e}", exc_info=True) # Changed log prefix - - def display(self, force_clear=False) -> bool: - """Display upcoming games, handling switching.""" - if not self.is_enabled: return False - - if not self.games_list: - if self.current_game: self.current_game = None # Clear state if list empty - current_time = time.time() - # Log warning periodically if no games found - if current_time - self.last_warning_time > self.warning_cooldown: - self.logger.info("No upcoming games found for favorite teams to display.") # Changed log prefix - self.last_warning_time = current_time - return False # Skip display update - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.games_list) - self.current_game = self.games_list[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force redraw on switch - - # Log team switching with sport prefix - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - sport_prefix = self.sport_key.upper() if hasattr(self, 'sport_key') else 'SPORT' - self.logger.info(f"[{sport_prefix} Upcoming] Showing {away_abbr} vs {home_abbr}") - else: - self.logger.debug(f"Switched to game index {self.current_game_index}") - - if self.current_game: - self._render_game(self.current_game, force_clear) - return True - # update_display() is called within _draw_scorebug_layout for upcoming - return False - - except Exception as e: - self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix - return False - - -class SportsRecent(SportsCore): - SKIN_MODE = "recent" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.recent_games = [] # Store all fetched recent games initially - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get("recent_update_interval", 3600) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each recent game for 15 seconds - # Tracks when each game was first seen with an expired clock, keyed by - # game id. Promoted alongside the zero-clock helpers below; without it - # the first _get_zero_clock_duration() call raises AttributeError. - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - # -- Zero-clock tracking ------------------------------------------------ - # Byte-identical in all nine plugin copies. Note that afl/nrl/soccer define - # these but never call them — their clocks count up, so 0:00 means kickoff - # rather than expiry (see CLOCK_COUNTS_DOWN on SportsLive). That makes the - # pair a future `CountdownClockMixin` candidate so it stops appearing in the - # MRO of sports that cannot use it — B2 work, not now. - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] - - def _select_recent_games_for_display( - self, processed_games: List[Dict], favorite_teams: List[str] - ) -> List[Dict]: - """ - Single-pass game selection for recent games with proper deduplication. - - When a game involves two favorite teams, it counts toward BOTH teams' limits. - Games are sorted by most recent first. - - Team identity goes through the ``_favorite_key`` override point rather - than reading ``home_abbr``/``away_abbr`` directly, because abbreviations - are not unique in every league (NRL matches on team ID instead). - """ - sorted_games = sorted( - processed_games, - key=lambda g: g.get("start_time_utc") - or datetime.min.replace(tzinfo=timezone.utc), - reverse=True, - ) - - if not favorite_teams: - return sorted_games - - selected_games = [] - selected_ids = set() - team_counts = {team: 0 for team in favorite_teams} - - for game in sorted_games: - game_id = game.get("id") - if game_id in selected_ids: - continue - - home = self._favorite_key(game, "home") - away = self._favorite_key(game, "away") - - home_fav = home in favorite_teams - away_fav = away in favorite_teams - - if not home_fav and not away_fav: - continue - - home_needs = home_fav and team_counts[home] < self.recent_games_to_show - away_needs = away_fav and team_counts[away] < self.recent_games_to_show - - if home_needs or away_needs: - selected_games.append(game) - selected_ids.add(game_id) - if home_fav: - team_counts[home] += 1 - if away_fav: - team_counts[away] += 1 - - self.logger.debug( - f"Selected recent game {away}@{home}: team_counts={team_counts}" - ) - - if all(c >= self.recent_games_to_show for c in team_counts.values()): - self.logger.debug("All favorite teams satisfied, stopping selection") - break - - self.logger.info( - f"Selected {len(selected_games)} recent games for {len(favorite_teams)} " - f"favorite teams: {team_counts}" - ) - return selected_games - - def update(self): - """Update recent games data.""" - if not self.is_enabled: return - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - self.last_update = current_time # Update time even if fetch fails - - # Fetch rankings if enabled - if self.show_ranking: - self._fetch_team_rankings() - - try: - data = self._fetch_data() # Uses shared cache - if not data or 'events' not in data: - self.logger.warning("No events found in shared data.") # Changed log prefix - if not self.games_list: - self.current_game = None # Clear display if no games were showing - return - - events = data['events'] - self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix - - # Define date range for "recent" games (last 21 days to capture games from 3 weeks ago) - now = datetime.now(timezone.utc) - recent_cutoff = now - timedelta(days=21) - self.logger.info(f"Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)") - - # Process games and filter for final games, date range & favorite teams - processed_games = [] - for event in events: - game = self._extract_game_details(event) - # Filter criteria: must be final AND within recent date range - if game and game['is_final']: - game_time = game.get('start_time_utc') - if game_time and game_time >= recent_cutoff: - processed_games.append(game) - # Filter for favorite teams only if the config is set - if self.show_favorite_teams_only: - # Get all games involving favorite teams - favorite_team_games = [game for game in processed_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - self.logger.info(f"Found {len(favorite_team_games)} favorite team games out of {len(processed_games)} total final games within last 21 days") - - # Select N games per favorite team (where N = recent_games_to_show) - # Example: recent_games_to_show=1 with 2 favorite teams = 2 games total - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: - # Sort by game time and take the most recent N games - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - # Take up to recent_games_to_show games for this team - team_games.extend(team_specific_games[:self.recent_games_to_show]) - - # Sort the final list by game time (most recent first) - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - # Remove duplicates (in case a game involves multiple favorite teams) - seen_ids = set() - unique_team_games = [] - for game in team_games: - if game['id'] not in seen_ids: - seen_ids.add(game['id']) - unique_team_games.append(game) - team_games = unique_team_games - - # Debug: Show which games are selected for display - for i, game in enumerate(team_games): - self.logger.info(f"Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}") - else: - team_games = processed_games # Show all recent games if no favorites defined - self.logger.info(f"Found {len(processed_games)} total final games within last 21 days (no favorite teams filtering)") - # Sort games by start time, most recent first, and limit to recent_games_to_show - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - team_games = team_games[:self.recent_games_to_show] - - # Check if the list of games to display has changed - new_game_ids = {g['id'] for g in team_games} - current_game_ids = {g['id'] for g in self.games_list} - - if new_game_ids != current_game_ids: - self.logger.info(f"Found {len(team_games)} final games within window for display.") # Changed log prefix - self.games_list = team_games - # Reset index if list changed or current game removed - if not self.current_game or not self.games_list or self.current_game['id'] not in new_game_ids: - self.current_game_index = 0 - self.current_game = self.games_list[0] if self.games_list else None - self.last_game_switch = current_time # Reset switch timer - else: - # Try to maintain position if possible - try: - self.current_game_index = next(i for i, g in enumerate(self.games_list) if g['id'] == self.current_game['id']) - self.current_game = self.games_list[self.current_game_index] # Update data just in case - except StopIteration: - self.current_game_index = 0 - self.current_game = self.games_list[0] - self.last_game_switch = current_time - - elif self.games_list: - # List content is same, just update data for current game - self.current_game = self.games_list[self.current_game_index] - - if not self.games_list: - self.logger.info("No relevant recent games found to display.") # Changed log prefix - self.current_game = None # Ensure display clears if no games - - except Exception as e: - self.logger.error(f"Error updating recent games: {e}", exc_info=True) # Changed log prefix - # Don't clear current game on error, keep showing last known state - # self.current_game = None # Decide if we want to clear display on error - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the layout for a recently completed NCAA FB game.""" # Updated docstring - try: - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url")) - away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url")) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for game: {game.get('id')}") # Changed log prefix - # Draw placeholder text if logos fail (similar to live) - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positioning (closer to edges) - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw Text Elements on Overlay - # Note: Rankings are now handled in the records/rankings section below - - # Final Scores (Centered, same position as live) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = self.display_height - 14 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) - - # "Final" text (Top center) - status_text = game.get("period_text", "Final") # Use formatted period text (e.g., "Final/OT") or default "Final" - status_width = draw_overlay.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - - # Draw records or rankings if enabled - if self.show_records or self.show_ranking: - record_font = self.fonts.get('detail', ImageFont.load_default()) - - # Get team abbreviations - away_abbr = game.get('away_abbr', '') - home_abbr = game.get('home_abbr', '') - - record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}") - - # Display away team info - if away_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - away_text = '' - elif self.show_ranking: - # Show ranking only if available - away_rank = self._team_rankings_cache.get(away_abbr, 0) - if away_rank > 0: - away_text = f"#{away_rank}" - else: - away_text = '' - elif self.show_records: - # Show record only when rankings are disabled - away_text = game.get('away_record', '') - else: - away_text = '' - - if away_text: - away_record_x = 0 - self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - if self.show_ranking and self.show_records: - # When both rankings and records are enabled, rankings replace records completely - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - # Show nothing for unranked teams when rankings are prioritized - home_text = '' - elif self.show_ranking: - # Show ranking only if available - home_rank = self._team_rankings_cache.get(home_abbr, 0) - if home_rank > 0: - home_text = f"#{home_rank}" - else: - home_text = '' - elif self.show_records: - # Show record only when rankings are disabled - home_text = game.get('home_record', '') - else: - home_text = '' - - if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}") - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - self._custom_scorebug_layout(game, draw_overlay) - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() # Update display here - - except Exception as e: - self.logger.error(f"Error displaying recent game: {e}", exc_info=True) # Changed log prefix - - def display(self, force_clear=False) -> bool: - """Display recent games, handling switching.""" - if not self.is_enabled or not self.games_list: - # If disabled or no games, ensure display might be cleared by main loop if needed - # Or potentially clear it here? For now, rely on main loop/other managers. - if not self.games_list and self.current_game: - self.current_game = None # Clear internal state if list becomes empty - return False - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.games_list) - self.current_game = self.games_list[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force redraw on switch - - # Log team switching with sport prefix - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - sport_prefix = self.sport_key.upper() if hasattr(self, 'sport_key') else 'SPORT' - self.logger.info(f"[{sport_prefix} Recent] Showing {away_abbr} vs {home_abbr}") - else: - self.logger.debug(f"Switched to game index {self.current_game_index}") - - if self.current_game: - self._render_game(self.current_game, force_clear) - return True - # update_display() is called within _draw_scorebug_layout for recent - return False - - except Exception as e: - self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix - return False - -class SportsLive(SportsCore): - # Per-sport constants for the "is this live game actually over?" check. - # These are values, not behavior, so they are class attributes rather than - # override points (see docs/SPORTS_UNIFICATION.md "Override points"). - # - # FINAL_PERIOD: the period at/after which an expired clock can mean "over". - # 4 for four-quarter sports; hockey overrides to 3. - # CLOCK_COUNTS_DOWN: whether "0:00" means the clock expired. False for - # sports whose clock counts up (soccer/afl/nrl), where 0:00 is kickoff — - # running the expiry branch there would evict games that just started. - FINAL_PERIOD = 4 - CLOCK_COUNTS_DOWN = True - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 - self.last_update = 0 - self.live_games = [] - self.current_game_index = 0 - self.last_game_switch = 0 # Will be set to current_time when games are first loaded - self.game_display_duration = self.mode_config.get("live_game_duration", 20) - self.last_display_update = 0 - self.last_log_time = 0 - self.log_interval = 300 - self.last_count_log_time = 0 # Track when we last logged count data - self.count_log_interval = 5 # Only log count data every 5 seconds - # Initialize test_mode - defaults to False (live mode) - self.test_mode = self.mode_config.get("test_mode", False) - # Freshness bookkeeping for _detect_stale_games(). The base class only - # *reads* this map; a subclass's update() stamps entries as it ingests a - # feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}. - # Until a subclass writes "last_seen", the staleness branch of - # _detect_stale_games is inert and only the game-over check applies. - self.game_update_timestamps: Dict[str, Dict[str, float]] = {} - self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default - - @abstractmethod - def _test_mode_update(self) -> None: - return - - def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live. - - Two independent signals: - 1. ``period_text`` says "final" — universal across every sport. - 2. The clock has expired at/after :attr:`FINAL_PERIOD` — only meaningful - where :attr:`CLOCK_COUNTS_DOWN` is true. - - Fails *safe*: anything ambiguous returns False and the game keeps being - displayed. The only caller, :meth:`_detect_stale_games`, removes games - on a True, so a false positive silently drops a live game. - """ - game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - - # `period_text` may be present-but-None; `or ""` keeps that from raising - # AttributeError — the caller has no try/except around this call. - period_text = (game.get("period_text") or "").lower() - if "final" in period_text: - self.logger.debug( - f"_is_game_really_over({game_str}): " - f"returning True - 'final' in period_text='{period_text}'" - ) - return True - - if not self.CLOCK_COUNTS_DOWN: - # Count-up clock: 0:00 means the match has not started. - self.logger.debug( - f"_is_game_really_over({game_str}): returning False " - f"(count-up clock, period_text='{period_text}')" - ) - return False - - raw_clock = game.get("clock") - # `or 0` rather than a get() default: feeds routinely send an explicit - # null period, and `None >= FINAL_PERIOD` raises TypeError — which would - # take down the whole live-update pass, since the only caller - # (_detect_stale_games) has no try/except around it. - period = game.get("period") or 0 - - # Only check clock-based finish if we have a valid clock string. A - # missing or non-string clock is NOT coerced to "0:00": sports without a - # game clock (e.g. baseball, where `period` is the inning) would - # otherwise be declared over from the FINAL_PERIOD-th period onward. - if isinstance(raw_clock, str) and raw_clock.strip() and period >= self.FINAL_PERIOD: - clock = raw_clock - # Compare numerically rather than against a literal set: feeds spell - # an expired clock "0:00", ":00" and "00:00" depending on sport, and - # a membership test silently misses every spelling not listed. - clock_normalized = clock.replace(":", "").strip() - if clock_normalized.isdigit() and int(clock_normalized) == 0: - self.logger.debug( - f"_is_game_really_over({game_str}): " - f"returning True - clock at 0:00 (clock='{clock}', period={period})" - ) - return True - - self.logger.debug( - f"_is_game_really_over({game_str}): returning False" - ) - return False - - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated. - - Mutates ``games`` **in place** and returns None. Removal is by value - (``list.remove`` uses ``dict.__eq__``), so two structurally-equal game - dicts in the same list would drop the first occurrence. - """ - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def update(self): - """Update live game data and handle game switching.""" - if not self.is_enabled: - return - - # Define current_time and interval before the problematic line (originally line 455) - # Ensure 'import time' is present at the top of the file. - current_time = time.time() - - # Define interval using a pattern similar to NFLLiveManager's update method. - # Uses getattr for robustness, assuming attributes for live_games, test_mode, - # no_data_interval, and update_interval are available on self. - _live_games_attr = self.live_games - _test_mode_attr = self.test_mode # test_mode is often from a base class or config - _no_data_interval_attr = self.no_data_interval # Default similar to NFLLiveManager - _update_interval_attr = self.update_interval # Default similar to NFLLiveManager - - interval = _no_data_interval_attr if not _live_games_attr and not _test_mode_attr else _update_interval_attr - - # Original line from traceback (line 455), now with variables defined: - if current_time - self.last_update >= interval: - self.last_update = current_time - - # Fetch rankings if enabled - if self.show_ranking: - self._fetch_team_rankings() - - if self.test_mode: - # Simulate clock running down in test mode - self._test_mode_update() - else: - # Fetch live game data - data = self._fetch_data() - new_live_games = [] - if data and "events" in data: - for game in data["events"]: - details = self._extract_game_details(game) - if details and (details["is_live"] or details["is_halftime"]): - # If show_favorite_teams_only is true, only add if it's a favorite. - # Otherwise, add all games. - if self.show_all_live or not self.show_favorite_teams_only or (self.show_favorite_teams_only and (details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams)): - if self.show_odds: - self._fetch_odds(details) - new_live_games.append(details) - # Log changes or periodically - current_time_for_log = time.time() # Use a consistent time for logging comparison - should_log = ( - current_time_for_log - self.last_log_time >= self.log_interval or - len(new_live_games) != len(self.live_games) or - any(g1['id'] != g2.get('id') for g1, g2 in zip(self.live_games, new_live_games)) or # Check if game IDs changed - (not self.live_games and new_live_games) # Log if games appeared - ) - - if should_log: - if new_live_games: - filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "all teams" - self.logger.info(f"Found {len(new_live_games)} live/halftime games for {filter_text}.") - for game_info in new_live_games: # Renamed game to game_info - self.logger.info(f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})") - else: - filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "criteria" - self.logger.info(f"No live/halftime games found for {filter_text}.") - self.last_log_time = current_time_for_log - - - # Update game list and current game - if new_live_games: - # Check if the games themselves changed, not just scores/time - new_game_ids = {g['id'] for g in new_live_games} - current_game_ids = {g['id'] for g in self.live_games} - - if new_game_ids != current_game_ids: - self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time - # Reset index if current game is gone or list is new - if not self.current_game or self.current_game['id'] not in new_game_ids: - self.current_game_index = 0 - self.current_game = self.live_games[0] if self.live_games else None - self.last_game_switch = current_time - else: - # Find current game's new index if it still exists - try: - self.current_game_index = next(i for i, g in enumerate(self.live_games) if g['id'] == self.current_game['id']) - self.current_game = self.live_games[self.current_game_index] # Update current_game with fresh data - # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching - if self.last_game_switch == 0: - self.last_game_switch = current_time - except StopIteration: # Should not happen if check above passed, but safety first - self.current_game_index = 0 - self.current_game = self.live_games[0] - self.last_game_switch = current_time - - else: - # Just update the data for the existing games - temp_game_dict = {g['id']: g for g in new_live_games} - self.live_games = [temp_game_dict.get(g['id'], g) for g in self.live_games] # Update in place - if self.current_game: - self.current_game = temp_game_dict.get(self.current_game['id'], self.current_game) - # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching - # This handles the case where games were loaded previously but last_game_switch was never set - if self.last_game_switch == 0: - self.last_game_switch = current_time - - # Display update handled by main loop based on interval - - else: - # No live games found - if self.live_games: # Were there games before? - self.logger.info("Live games previously showing have ended or are no longer live.") # Changed log prefix - self.live_games = [] - self.current_game = None - self.current_game_index = 0 - - else: - # Error fetching data or no events - if self.live_games: # Were there games before? - self.logger.warning("Could not fetch update; keeping existing live game data for now.") # Changed log prefix - else: - self.logger.warning("Could not fetch data and no existing live games.") # Changed log prefix - self.current_game = None # Clear current game if fetch fails and no games were active - - # Handle game switching (outside test mode check) - # Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet) - # This prevents immediate switching when the system has been running for a while before games load - if not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.live_games) - self.current_game = self.live_games[self.current_game_index] - self.last_game_switch = current_time - self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix - # Force display update via flag or direct call if needed, but usually let main loop handle diff --git a/src/base_odds_manager.py b/src/base_odds_manager.py index 4b455069..efb10bba 100644 --- a/src/base_odds_manager.py +++ b/src/base_odds_manager.py @@ -50,8 +50,7 @@ class BaseOddsManager: # 2026-08-04 it began 403ing browser strings and bare custom tokens # alike; what it accepts is a token with a URL that says who is # calling. Every other ESPN caller in the tree already sends this - # (src/common/api_helper.py, src/base_classes/data_sources.py); the - # odds path was simply missed, and it is the one whose failures cost + # (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost # the caller its whole update budget. # # Deliberately no retry adapter, unlike api_helper: retries multiply diff --git a/src/common/sports_helpers.py b/src/common/sports_helpers.py index 6ae1c517..b78f0ca5 100644 --- a/src/common/sports_helpers.py +++ b/src/common/sports_helpers.py @@ -2,9 +2,8 @@ Ten helpers are byte-identical (executable AST, docstrings stripped) in the scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in -``src/common``. The bodies below were copied from those plugin copies -- not -from ``src/base_classes`` -- at ledmatrix-plugins ``f09bff2`` (origin/main, -2026-09-14): +``src/common``. The bodies below were copied from those plugin copies at +ledmatrix-plugins ``f09bff2`` (origin/main, 2026-09-14): - In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl, soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh`` @@ -34,9 +33,9 @@ where both can. Plugins import this and floor ``ledmatrix_min_version`` on the first core release that ships it (see ``CHANGELOG.md``). ``_favorite_key`` is the one method not taken from the plugins: it is the -override point from ``src/base_classes/sports/core.py``, carried here so later -phases (shared celebrations and game selection) have a hardware-free home for -the seam. No plugin defines it today and nothing in this module calls it. +override point from the since-removed ``src/base_classes`` sports core, +carried here so later phases (shared celebrations and game selection) have a +hardware-free home for the seam. No plugin defines it today and nothing in this module calls it. WHAT A HOST MUST PROVIDE ------------------------ @@ -80,8 +79,8 @@ runs. By convention list it after ``SportsCoreSharedMixin``:: HARDWARE-FREE ------------- -Nothing here may import ``src.display_manager``, ``src.base_classes``, -``src.plugin_system`` or anything else that reaches ``rgbmatrix`` at module +Nothing here may import ``src.display_manager``, ``src.plugin_system`` or +anything else that reaches ``rgbmatrix`` at module level; ``test/test_common_is_hardware_free.py`` enforces that for all of ``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily, exactly as the plugin copy does. @@ -224,8 +223,8 @@ class SportsHelpersMixin: containing that string. The default returns ``None`` for a missing abbreviation, which never matches. - Carried from ``src/base_classes/sports/core.py`` for later phases; - nothing in this module calls it yet. + Carried from the since-removed ``src/base_classes`` sports core for + later phases; nothing in this module calls it yet. """ return game.get(f"{side}_abbr") diff --git a/src/plugin_system/schema_manager.py b/src/plugin_system/schema_manager.py index d4eb70b8..0042f75e 100644 --- a/src/plugin_system/schema_manager.py +++ b/src/plugin_system/schema_manager.py @@ -99,8 +99,7 @@ def normalize_legacy_booleans(config: Any, schema: Any, #: config section, so they are allowed in every plugin's config whether or not #: the plugin's schema declares them. The one list for validation, for the web #: save filter and for the load-time checks -- a private copy is how JSON saves -#: came to drop ``skin`` and the ``vegas_*`` keys while the validator accepted -#: them. +#: came to drop the ``vegas_*`` keys while the validator accepted them. #: #: Values are the schema used when the plugin does not declare the property. CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = { @@ -123,19 +122,6 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = { "default": False, "description": "Enable live priority takeover when plugin has live content" }, - # Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an enum here: - # validation must keep passing when a configured skin gets uninstalled - # (rendering falls back to built-in). The install-dependent enum is - # injected only at serve time (inject_skin_selector) for the web UI - # dropdown. - "skin": { - "type": ["string", "object", "null"], - "description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}" - }, - "skin_options": { - "type": "object", - "description": "Options passed through to the selected skin" - }, # Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py. # Left untyped: the adapter validates them itself and ignores a bad # value with a log line, so a stored one must never block a save. @@ -158,6 +144,32 @@ CORE_VEGAS_TUNING_KEYS = frozenset({ }) +#: Per-plugin keys the core used to own and no longer reads. ``skin`` and +#: ``skin_options`` belonged to the skin system, which was removed; a +#: config.json written before then can still carry them in any plugin section, +#: and most plugin schemas set ``additionalProperties: false``. They are +#: dropped wherever a section is prepared (prepare_plugin_config) or validated, +#: and the web saves drop them from the stored section, so an old config loads +#: and saves without a validation error and loses them on its next save. +RETIRED_PLUGIN_KEYS = frozenset({'skin', 'skin_options'}) + + +def drop_retired_plugin_keys(config: Any, schema: Any) -> Any: + """``config`` without the RETIRED_PLUGIN_KEYS its plugin's schema leaves undeclared. + + A plugin whose schema declares one of these names owns it and keeps it; + without a schema nothing is dropped. Never mutates ``config``, and returns + it unchanged when there is nothing to drop. + """ + if not isinstance(config, dict) or not isinstance(schema, dict) \ + or RETIRED_PLUGIN_KEYS.isdisjoint(config): + return config + declared = schema.get('properties') + declared = declared if isinstance(declared, dict) else {} + return {key: value for key, value in config.items() + if key not in RETIRED_PLUGIN_KEYS or key in declared} + + def with_core_plugin_properties(schema: Dict[str, Any]) -> Dict[str, Any]: """A deep copy of a plugin schema with CORE_PLUGIN_PROPERTIES allowed. @@ -299,14 +311,15 @@ def prepare_plugin_config(config: Any, schema: Optional[Dict[str, Any]], changed_paths: Optional[List[str]] = None) -> Dict[str, Any]: """The config a plugin runs with, from its stored (or submitted) section. - Legacy booleans are read as ``{"enabled": ...}`` objects - (normalize_legacy_booleans), then schema defaults fill in whatever is - missing. Loading a plugin, both config saves, GET /plugins/config, hot - reload and the dev tools all go through this, so a plugin sees the same - shape however its config reached it. + Retired core keys are dropped (drop_retired_plugin_keys), legacy booleans + are read as ``{"enabled": ...}`` objects (normalize_legacy_booleans), then + schema defaults fill in whatever is missing. Loading a plugin, both config + saves, GET /plugins/config, hot reload and the dev tools all go through + this, so a plugin sees the same shape however its config reached it. """ config = config if isinstance(config, dict) else {} if schema: + config = drop_retired_plugin_keys(config, schema) config = normalize_legacy_booleans(config, schema, changed_paths) return merge_config_defaults(config, defaults) @@ -620,7 +633,8 @@ class SchemaManager: # Core plugin properties (CORE_PLUGIN_PROPERTIES) are handled by # the base plugin system and should not cause validation failures: # they are allowed even when the plugin's schema doesn't declare - # them, and never required. + # them, and never required. Retired ones are ignored. + config = drop_retired_plugin_keys(config, schema) enhanced_schema = with_core_plugin_properties(schema) if plugin_id: declared = schema.get("properties", {}) if isinstance(schema, dict) else {} @@ -658,53 +672,6 @@ class SchemaManager: self.logger.error(error_msg) return False, [error_msg] - def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str, - current_value: Any = None) -> Dict[str, Any]: - """Return a copy of a plugin's schema with a "skin" dropdown added - when installed skins target this plugin (docs/SKIN_SYSTEM.md). - - Serve-time only — validation never sees this enum, so a config - referencing an uninstalled skin stays valid (rendering falls back - to the built-in layout). The currently-configured value is always - included in the enum for the same reason: the dropdown must be able - to display a selection whose skin was removed. - """ - # A per-mode mapping ({"live": ..., "recent": ...}) can't be edited - # through a string dropdown — injecting one would let the form save - # a string over the mapping. Leave the schema alone; per-mode users - # edit via the raw JSON config editor. - if isinstance(current_value, dict): - return schema - - try: - from src.skin_system import skin_runtime - matching = skin_runtime.skins_for_plugin(plugin_id) - except Exception as e: - self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}") - return schema - - choices = sorted(matching.keys()) - if isinstance(current_value, str) and current_value and \ - current_value != "built-in" and current_value not in choices: - choices.append(current_value) - if not choices: - return schema - - enhanced = copy.deepcopy(schema) - enhanced.setdefault("properties", {}) - if "skin" not in enhanced["properties"]: - names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices} - enhanced["properties"]["skin"] = { - "type": "string", - "title": "Visual Skin", - "description": "Replace this scoreboard's look with an installed skin " - "(data, scheduling, and vegas mode are unaffected)", - "enum": ["built-in", *choices], - "enumNames": ["Built-in", *(names[sid] for sid in choices)], - "default": "built-in" - } - return enhanced - def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str: """ Format a validation error into a readable message. diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 46174f99..12e74212 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -53,6 +53,17 @@ class PluginStoreManager: # "..", "../x") into a filesystem path that purge_uninstalled_plugins # would delete — an empty id resolves to the plugins root itself. _PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + @staticmethod + def is_plugin_entry(entry) -> bool: + """Whether a registry entry is a plugin core can install. + + A missing ``type`` means plugin. Anything else (registries used to + carry ``"type": "skin"`` entries, and a custom registry still can) is + hidden from the store and refused at install, rather than being + unpacked into the plugins directory as if it were a plugin. + """ + return isinstance(entry, dict) and (entry.get('type') or 'plugin') == 'plugin' def __init__(self, plugins_dir: str = "plugins", uninstalled_registry_path: Optional[str] = None): @@ -1314,18 +1325,10 @@ class PluginStoreManager: if not plugin_info: self.logger.error(f"Plugin not found in registry: {plugin_id}") return False - - # Visual skins share the registry. _install_skin_from_info can put one - # in skins/, but no current scoreboard plugin renders skins, so the - # store refuses them rather than installing something that does - # nothing (docs/SKIN_SYSTEM.md). Manual installs under skins/ and - # uninstall_skin are unaffected. - if (plugin_info.get('type') or 'plugin') == 'skin': - from src.skin_system import SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE - if not SKINS_RENDER_SUPPORTED: - self.logger.error(f"Not installing skin {plugin_id}: {SKINS_UNSUPPORTED_MESSAGE}") - return False - return self._install_skin_from_info(plugin_id, plugin_info, branch) + if not self.is_plugin_entry(plugin_info): + self.logger.error(f"Not installing {plugin_id}: registry entry type " + f"{plugin_info.get('type')!r} is not a plugin") + return False repo_url = plugin_info.get('repo') if not repo_url: @@ -2479,152 +2482,6 @@ class PluginStoreManager: continue return None - _SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$') - - def _resolve_skin_target(self, skin_id: str) -> Optional[Path]: - """Validate an externally-supplied skin id and resolve it to a path - strictly inside the skins directory. Returns None (after logging) - for ids that are malformed or would escape the directory — registry - entries and manifests are external input and must not be able to - write or delete outside skins/.""" - from src.skin_system import skin_runtime - - if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \ - or '..' in skin_id: - self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}") - return None - skins_dir = skin_runtime.get_skins_directory().resolve() - target = (skins_dir / skin_id).resolve() - if target.parent != skins_dir: - self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting") - return None - return target - - def _install_skin_from_info(self, skin_id: str, skin_info: Dict, - branch: Optional[str] = None) -> bool: - """Install a registry entry of type "skin" into skins//. - - Reuses the plugin download machinery (git / monorepo zip / archive) - but validates skin.json instead of manifest.json and never installs - dependencies — skins are render-only (stdlib + PIL + the provided - SkinContext), which is also what keeps them safe to iterate on. - - Downloads into a staging directory and validates there; the - existing installation is only replaced after the new one passes, - so a failed download or bad manifest can't destroy a working skin. - """ - from src.skin_system import skin_runtime - from src.skin_system.skin_base import SKIN_API_VERSION - - repo_url = skin_info.get('repo') - if not repo_url: - self.logger.error(f"Skin {skin_id} missing repository URL") - return False - - target = self._resolve_skin_target(skin_id) - if target is None: - return False - skins_dir = target.parent - skins_dir.mkdir(parents=True, exist_ok=True) - # Leading "_" keeps staging invisible to skin discovery - staging = skins_dir / f"_staging-{skin_id}" - if staging.exists() and not self._safe_remove_directory(staging): - return False - - subpath = skin_info.get('plugin_path') - branch_candidates = self._distinct_sequence([ - branch, - skin_info.get('branch'), - skin_info.get('default_branch'), - skin_info.get('last_commit_branch'), - 'main', - 'master' - ]) - - try: - branch_used = None - if subpath: - for candidate in branch_candidates: - download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" - if self._install_from_monorepo(download_url, subpath, staging): - branch_used = candidate - break - else: - branch_used = self._install_via_git(repo_url, staging, branch_candidates) - if branch_used is None and not staging.exists(): - for candidate in branch_candidates: - download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" - if self._install_via_download(download_url, staging): - branch_used = candidate - break - - if branch_used is None and not staging.exists(): - self.logger.error(f"Failed to install skin {skin_id} via git or archive download") - return False - - try: - with open(staging / 'skin.json', 'r', encoding='utf-8') as f: - manifest = json.load(f) - except (OSError, json.JSONDecodeError) as e: - self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}") - return False - - missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name') - if not manifest.get(k)] - if missing: - self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}") - return False - - # Unlike plugins, a mismatched id is rejected rather than - # renamed: the manifest id is external input, and the registry - # id is what the user asked to install. - if manifest['id'] != skin_id: - self.logger.error( - f"Skin manifest id {manifest['id']!r} doesn't match registry id " - f"{skin_id!r}; not installing") - return False - - def _api_major(v): - try: - return int(str(v).split('.')[0]) - except (ValueError, IndexError): - return None - - if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION): - self.logger.error( - f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this " - f"LEDMatrix provides {SKIN_API_VERSION}; not installing") - return False - - # Validated — swap into place - if target.exists() and not self._safe_remove_directory(target): - self.logger.error(f"Could not replace existing skin directory: {target}") - return False - shutil.move(str(staging), str(target)) - skin_runtime.discover_skins(force_refresh=True) - self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})") - return True - finally: - if staging.exists(): - self._safe_remove_directory(staging) - - def uninstall_skin(self, skin_id: str) -> bool: - """Remove an installed skin. Plugin configs referencing it keep - validating; rendering falls back to the built-in layout.""" - from src.skin_system import skin_runtime - - target = self._resolve_skin_target(skin_id) - if target is None: - return False - if not target.exists(): - self.logger.info(f"Skin {skin_id} not found (already uninstalled)") - return True - if self._safe_remove_directory(target): - skin_runtime.discover_skins(force_refresh=True) - self.logger.info(f"Successfully uninstalled skin: {skin_id}") - return True - return False - def uninstall_plugin(self, plugin_id: str) -> bool: """ Uninstall a plugin by removing its directory. @@ -2638,12 +2495,6 @@ class PluginStoreManager: plugin_path = self._find_plugin_path(plugin_id) if plugin_path is None or not plugin_path.exists(): - # A skin id passed to the plugin uninstall path (the store UI - # uses one uninstall flow) removes the skin instead - skin_target = self._resolve_skin_target(plugin_id) \ - if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None - if skin_target is not None and skin_target.exists(): - return self.uninstall_skin(plugin_id) self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)") return True # Already uninstalled, consider this success diff --git a/src/skin_system/__init__.py b/src/skin_system/__init__.py deleted file mode 100644 index 422358da..00000000 --- a/src/skin_system/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Skin system: user-installable visual overlays for sports scoreboards. - -A skin replaces only the rendering of a scoreboard (live / recent / -upcoming) while the host plugin keeps doing data fetching, scheduling, -caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md. -""" - -# Skins are not offered to users yet. The only render hook is -# SportsCore._render_game in src/base_classes/sports/core.py, and none of the -# current scoreboard plugins (monorepo or third-party) build on -# src.base_classes, so a selected skin never draws. The web UI and store -# read these instead of offering install/selection; stored "skin" config -# values still load and save. See docs/SKIN_SYSTEM.md. -SKINS_RENDER_SUPPORTED = False -SKINS_UNSUPPORTED_MESSAGE = ( - "Skins aren't supported yet: the current scoreboard plugins don't render " - "them. Installed skins and saved skin settings are kept but have no effect." -) - -from src.skin_system.skin_base import ( # noqa: E402 - SKIN_API_VERSION, - VIEW_MODEL_VERSION, - ScoreboardSkin, - SkinContext, -) -from src.skin_system.skin_runtime import ( - build_context, - discover_skins, - get_skins_directory, - load_skin, -) - -__all__ = [ - "SKIN_API_VERSION", - "VIEW_MODEL_VERSION", - "ScoreboardSkin", - "SkinContext", - "build_context", - "discover_skins", - "get_skins_directory", - "load_skin", -] diff --git a/src/skin_system/fixtures/baseball_live.json b/src/skin_system/fixtures/baseball_live.json deleted file mode 100644 index c5bcec34..00000000 --- a/src/skin_system/fixtures/baseball_live.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Bot 7th", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "LAD", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "SF", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "status": "STATUS_IN_PROGRESS", - "status_state": "in", - "inning": 7, - "inning_half": "bottom", - "balls": 3, - "strikes": 2, - "outs": 2, - "bases_occupied": [ - true, - true, - true - ], - "start_time": "2026-07-16T23:05:00Z", - "series_summary": "LAD leads 2-1" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/baseball_recent.json b/src/skin_system/fixtures/baseball_recent.json deleted file mode 100644 index 15ed238f..00000000 --- a/src/skin_system/fixtures/baseball_recent.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "LAD", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "SF", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "status": "STATUS_FINAL", - "status_state": "post", - "inning": 9, - "inning_half": "top", - "balls": 0, - "strikes": 0, - "outs": 3, - "bases_occupied": [ - false, - false, - false - ], - "start_time": "2026-07-16T23:05:00Z", - "series_summary": "Series tied 2-2" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/baseball_upcoming.json b/src/skin_system/fixtures/baseball_upcoming.json deleted file mode 100644 index 48ccb857..00000000 --- a/src/skin_system/fixtures/baseball_upcoming.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "LAD", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "SF", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "status": "STATUS_SCHEDULED", - "status_state": "pre", - "inning": 0, - "inning_half": "top", - "balls": 0, - "strikes": 0, - "outs": 0, - "bases_occupied": [ - false, - false, - false - ], - "start_time": "2026-07-16T23:05:00Z", - "series_summary": "" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_live.json b/src/skin_system/fixtures/basketball_live.json deleted file mode 100644 index 06e86880..00000000 --- a/src/skin_system/fixtures/basketball_live.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Q4 2:34", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "OKC", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "", - "away_abbr": "MIN", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "", - "is_within_window": true, - "period": 4, - "period_text": "Q4", - "clock": "2:34" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_recent.json b/src/skin_system/fixtures/basketball_recent.json deleted file mode 100644 index 96e15d06..00000000 --- a/src/skin_system/fixtures/basketball_recent.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "OKC", - "home_id": "19", - "home_score": "5", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "", - "away_abbr": "MIN", - "away_id": "26", - "away_score": "3", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "", - "is_within_window": true, - "period": 4, - "period_text": "Final", - "clock": "0:00" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_upcoming.json b/src/skin_system/fixtures/basketball_upcoming.json deleted file mode 100644 index 32cd7df6..00000000 --- a/src/skin_system/fixtures/basketball_upcoming.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "OKC", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "", - "away_abbr": "MIN", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "", - "is_within_window": true, - "period": 0, - "period_text": "", - "clock": "0:00" -} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_live.json b/src/skin_system/fixtures/football_live.json deleted file mode 100644 index b680e640..00000000 --- a/src/skin_system/fixtures/football_live.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Q3 8:12", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "KC", - "home_id": "19", - "home_score": "21", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "BUF", - "away_id": "26", - "away_score": "17", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 3, - "period_text": "Q3", - "clock": "8:12", - "home_timeouts": 2, - "away_timeouts": 3, - "down_distance_text": "3rd & 4", - "down_distance_text_long": "3rd & 4 at KC 22", - "is_redzone": true, - "possession": "12", - "possession_indicator": "away", - "scoring_event": null -} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_recent.json b/src/skin_system/fixtures/football_recent.json deleted file mode 100644 index 1f2efb5c..00000000 --- a/src/skin_system/fixtures/football_recent.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "KC", - "home_id": "19", - "home_score": "21", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "BUF", - "away_id": "26", - "away_score": "17", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 4, - "period_text": "Final", - "clock": "0:00", - "home_timeouts": 0, - "away_timeouts": 0, - "down_distance_text": "", - "down_distance_text_long": "", - "is_redzone": false, - "possession": null, - "possession_indicator": null, - "scoring_event": null -} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_upcoming.json b/src/skin_system/fixtures/football_upcoming.json deleted file mode 100644 index 7b28c0a9..00000000 --- a/src/skin_system/fixtures/football_upcoming.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "KC", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "BUF", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 0, - "period_text": "", - "clock": "0:00", - "home_timeouts": 3, - "away_timeouts": 3, - "down_distance_text": "", - "down_distance_text_long": "", - "is_redzone": false, - "possession": null, - "possession_indicator": null, - "scoring_event": null -} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_live.json b/src/skin_system/fixtures/hockey_live.json deleted file mode 100644 index 2d53425d..00000000 --- a/src/skin_system/fixtures/hockey_live.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "P3 14:55", - "is_live": true, - "is_final": false, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "COL", - "home_id": "19", - "home_score": "2", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "VGK", - "away_id": "26", - "away_score": "2", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 3, - "period_text": "P3", - "clock": "14:55", - "power_play": true, - "penalties": [], - "home_shots": 27, - "away_shots": 31 -} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_recent.json b/src/skin_system/fixtures/hockey_recent.json deleted file mode 100644 index 0127162a..00000000 --- a/src/skin_system/fixtures/hockey_recent.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "Final/OT", - "is_live": false, - "is_final": true, - "is_upcoming": false, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "COL", - "home_id": "19", - "home_score": "3", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "VGK", - "away_id": "26", - "away_score": "2", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 5, - "period_text": "Final/OT", - "clock": "0:00", - "power_play": false, - "penalties": [], - "home_shots": 35, - "away_shots": 33 -} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_upcoming.json b/src/skin_system/fixtures/hockey_upcoming.json deleted file mode 100644 index 64caf089..00000000 --- a/src/skin_system/fixtures/hockey_upcoming.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "401570001", - "game_time": "7:05PM", - "game_date": "Jul 16th", - "start_time_utc": "2026-07-16T23:05:00+00:00", - "status_text": "7:05 PM", - "is_live": false, - "is_final": false, - "is_upcoming": true, - "is_halftime": false, - "is_period_break": false, - "home_abbr": "COL", - "home_id": "19", - "home_score": "0", - "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", - "home_logo_url": null, - "home_record": "58-33", - "away_abbr": "VGK", - "away_id": "26", - "away_score": "0", - "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", - "away_logo_url": null, - "away_record": "49-42", - "is_within_window": true, - "period": 0, - "period_text": "", - "clock": "0:00", - "power_play": false, - "penalties": [], - "home_shots": 0, - "away_shots": 0 -} \ No newline at end of file diff --git a/src/skin_system/fixtures/placeholder_away.png b/src/skin_system/fixtures/placeholder_away.png deleted file mode 100644 index fc9ec4ee4f940bec40e58d063d29060d8d0ff268..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 444 zcmV;t0YmOcVuP{Q^@l4TkzVpNnAmTcVaY=;Z z2JWzQa-!JOQwTMOt+60Q?5SzMThoBI)CQ2tttHmU0CIUmi(=aiXbzSTS5@0H1UBN8k`r0DcQ{hXRXeGx*1#Q>)D{D20C|Ig z634`5z*5jCNe+3dQ^?bV^n|*(4|tg9IbIjf_0bm`A?R*t#5-{wa6~q=S4{tP$(QS~ m*Q});d(lnF<5PnvZ~XwytJ_ej<6Qax00002qnDW(v3>IUIqCuAhHm*64 z6q*Gvno)P=z@M*gwcqFcph1*Ix;vHNE#Q_)L%l}28t3p<@KU*v9uqfd!;$c1%$~Xu zIZXjafKy7@kYu8nfurH`u_T$O8t{NmlA2%*c)}-1Oppewo6`t!Ow}pm`Shg>yDCRa z9d5Tp#4J~)yE|NJwQZBQf)?+@Xdq1=N@fEV5QK@;Vv8ksJY7SdtL5kQ@(}1_80dJ`dAeUQ9tepYm@`x72wi?hJEFrR>iIKCK zYrtXx@c`(4Qdg~kGc2hs2GRiX z1_dRKiOqnepiz<>@>ZvirwQo^b#ou^Fwt|oE}rY7FE~Qb-O`A6;ymDpY-q2T{_B!2 o*JZC+OI`M&o07+;22/ containing a skin.json -manifest and a Python module exposing a ScoreboardSkin subclass. The -host (a sports scoreboard's base classes) builds a SkinContext per -render and calls render_live / render_recent / render_upcoming with the -game view model. The skin draws onto ctx.canvas and returns True; the -host composites the canvas onto the display. A skin never talks to the -display, the network, or the plugin directly. - -Skin API Version: 1.0.0 -View Model Version: 1.0 -""" - -from abc import ABC -from dataclasses import dataclass, field -from typing import Any, Callable, Dict, Optional, Tuple, Union - -from PIL import Image, ImageDraw - -try: - import freetype -except ImportError: # pragma: no cover - freetype ships with the project deps - freetype = None - -from src.adaptive_layout import FitResult, LayoutContext, Region - -# Major must match a skin manifest's skin_api_version major or the skin -# is refused at load time (renames/removals bump major; additions minor). -SKIN_API_VERSION = "1.0.0" - -# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md). -VIEW_MODEL_VERSION = "1.0" - - -def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int, - color: Tuple[int, int, int], face: Any, - clip_w: int, clip_h: int) -> None: - """Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas. - - DisplayManager._draw_bdf_text only draws onto the panel image; skins - draw onto their own canvas, so the fitted-font path (fit_text can - return freetype faces) needs this standalone equivalent. - """ - try: - ascender_px = face.size.ascender >> 6 - except Exception: - ascender_px = 0 - baseline_y = y + ascender_px - for char in text: - face.load_char(char) - bitmap = face.glyph.bitmap - glyph_left = face.glyph.bitmap_left - glyph_top = face.glyph.bitmap_top - for i in range(bitmap.rows): - for j in range(bitmap.width): - byte_index = i * bitmap.pitch + (j // 8) - if byte_index < len(bitmap.buffer) and \ - bitmap.buffer[byte_index] & (1 << (7 - (j % 8))): - px = x + glyph_left + j - py = baseline_y - glyph_top + i - if 0 <= px < clip_w and 0 <= py < clip_h: - draw.point((px, py), fill=color) - x += face.glyph.advance.x >> 6 - - -@dataclass -class SkinContext: - """Everything a skin may touch during one render call. - - The canvas is a fresh RGB image sized to the current display (or - vegas card). Draw onto it via the helpers below or raw ``draw``; - never call display/update methods — the host composites the canvas. - """ - - canvas: Image.Image - draw: ImageDraw.ImageDraw - layout: LayoutContext - width: int - height: int - fonts: Dict[str, Any] - options: Dict[str, Any] - logger: Any - sport: Optional[str] = None - view_model_version: str = VIEW_MODEL_VERSION - # load_logo("home") / load_logo("away") -> RGBA PIL image or None. - # Bound to the current game; hits the host's logo cache (never loads - # from disk twice), downloads missing logos like the built-in layout. - load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None) - # draw_text_outlined(text, (x, y), font, fill=..., outline_color=...) - # — the classic scorebug outlined text, drawn onto this canvas. - # TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts - # use draw_fit / draw_text, which handle BDF faces too. - draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None) - - def draw_text(self, text: str, x: int, y: int, - color: Tuple[int, int, int] = (255, 255, 255), - font: Any = None) -> None: - """Draw text at a top-left position, handling both PIL fonts and - the freetype BDF faces that layout.fit_text can return.""" - if font is None: - font = self.fonts.get('time') - if freetype is not None and isinstance(font, freetype.Face): - _draw_bdf_text_on(self.draw, text, int(x), int(y), color, font, - self.width, self.height) - else: - self.draw.text((int(x), int(y)), text, font=font, fill=color) - - def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]], - color: Tuple[int, int, int] = (255, 255, 255), - align: str = "center", valign: str = "center") -> None: - """Draw a layout.fit_text() result aligned within a Region — the - canvas-local equivalent of adaptive_layout.draw_fitted_text.""" - region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1]) - x, y = region.align_xy(fit.width, fit.height, align, valign) - self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font) - - def draw_image(self, img: Optional[Image.Image], - box: Union[Region, Tuple[int, int]], *, - mode: str = "contain", align: str = "center", - valign: str = "center", cache_key: Any = None) -> None: - """Fit an image (a logo, art) into a Region and paste it, honoring - alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo( - 'home'), ...)` stays safe when a logo is missing.""" - if img is None: - return - region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1]) - fitted = self.layout.fit_image(img, region, mode=mode, - cache_key=cache_key) - result = fitted.image # fit_image returns an ImageFitResult (always RGBA) - if result is None: - return - x, y = region.align_xy(result.width, result.height, align, valign) - self.canvas.paste(result, (int(x), int(y)), result) - - -class ScoreboardSkin(ABC): - """Base class for scoreboard skins. - - Override only the modes you want to restyle; any mode you leave - unimplemented (or return False from) falls back to the plugin's - built-in renderer, so a live-only skin still gets recent/upcoming - screens for free. - - Skins should be stateless: three host instances (live, recent, - upcoming) each hold their own skin instance, and a render must be - derivable from (ctx, game) alone. - """ - - SKIN_API_VERSION = SKIN_API_VERSION - - def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]): - self.manifest = manifest - self.options = options or {} - - def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool: - return False - - def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool: - return False - - def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool: - return False - - def render_vegas_card(self, ctx: SkinContext, - game: Dict[str, Any]) -> Optional[Image.Image]: - """Render one vegas scroll card at ctx.width x ctx.height. Return - the finished image, or None to let the host use its default vegas - rendering (which captures the regular display output).""" - return None diff --git a/src/skin_system/skin_runtime.py b/src/skin_system/skin_runtime.py deleted file mode 100644 index 6c923eb0..00000000 --- a/src/skin_system/skin_runtime.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -Skin runtime: discovery, validation, loading, and context building. - -Deliberately generic — this module knows nothing about sports beyond -passing a `sport` label through; the sports flavor lives in skin_base -(ScoreboardSkin) and in the hosts that call build_context. - -Every failure path here logs and returns None: a broken or missing skin -must never take down the plugin that references it — the host falls -back to its built-in renderer. -""" - -import importlib.util -import json -import sys -import threading -from pathlib import Path -from typing import Any, Dict, Optional, Tuple - -from PIL import Image, ImageDraw - -from src.adaptive_layout import LayoutContext -from src.logging_config import get_logger -from src.skin_system.skin_base import ( - SKIN_API_VERSION, - ScoreboardSkin, - SkinContext, -) - -logger = get_logger(__name__) - -_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name") -_DEFAULT_ENTRY_POINT = "skin.py" - -_lock = threading.RLock() -# skins_dir -> (fingerprint, {skin_id: manifest+path}) -_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {} - -_shared_layout_font_manager: Optional[Any] = None - - -def _get_font_manager() -> Any: - """Shared FontManager for skin LayoutContexts. SportsCore hosts don't - carry a plugin_manager, so skins share one module-level FontManager — - the same shape as base_plugin._fallback_font_manager, constructed - directly so rendering never has to import the whole plugin system.""" - global _shared_layout_font_manager - if _shared_layout_font_manager is None: - from src.font_manager import FontManager - _shared_layout_font_manager = FontManager({}) - return _shared_layout_font_manager - - -def get_skins_directory() -> Path: - """Central skins directory: /skins. Lives outside the - plugin directories on purpose — plugin reinstall/update deletes the - whole plugin directory, and a skin must survive that.""" - return Path(__file__).resolve().parents[2] / "skins" - - -def _major(version: str) -> Optional[int]: - try: - return int(str(version).split(".")[0]) - except (ValueError, AttributeError, IndexError): - return None - - -def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]: - manifest_path = skin_dir / "skin.json" - if not manifest_path.is_file(): - return None - try: - with open(manifest_path, "r", encoding="utf-8") as f: - manifest = json.load(f) - except (OSError, json.JSONDecodeError) as e: - logger.error("Skin manifest %s is unreadable: %s", manifest_path, e) - return None - missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)] - if missing: - logger.error("Skin manifest %s missing required fields: %s", - manifest_path, ", ".join(missing)) - return None - if manifest["id"] != skin_dir.name: - logger.warning("Skin manifest id %r does not match directory name %r", - manifest["id"], skin_dir.name) - manifest["_skin_dir"] = str(skin_dir) - return manifest - - -def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]: - """Cache key for a skins directory: its mtime plus every skin.json's - (path, mtime). The directory mtime alone misses in-place manifest edits - (a skin updated without adding/removing entries).""" - try: - parts = [skins_dir.stat().st_mtime] - for manifest_path in sorted(skins_dir.glob("*/skin.json")): - parts.append((str(manifest_path), manifest_path.stat().st_mtime)) - return tuple(parts) - except OSError: - return None - - -def discover_skins(skins_dir: Optional[Path] = None, - force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: - """Return {skin_id: manifest} for every valid skin package installed. - - Cached per directory and invalidated when the directory or any - skin.json changes; pass force_refresh to bypass. - """ - skins_dir = Path(skins_dir) if skins_dir else get_skins_directory() - cache_key = str(skins_dir) - fingerprint = _discovery_fingerprint(skins_dir) - if fingerprint is None: - return {} - - with _lock: - cached = _discovery_cache.get(cache_key) - if cached and not force_refresh and cached[0] == fingerprint: - return dict(cached[1]) - - skins: Dict[str, Dict[str, Any]] = {} - for entry in sorted(skins_dir.iterdir()): - if not entry.is_dir() or entry.name.startswith((".", "_")): - continue - manifest = _read_manifest(entry) - if manifest: - skins[manifest["id"]] = manifest - _discovery_cache[cache_key] = (fingerprint, skins) - return dict(skins) - - -def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]: - """(sports, sport_keys) a skin declares it supports.""" - targets = manifest.get("targets") or {} - return (list(targets.get("sports") or []), - list(targets.get("sport_keys") or [])) - - -def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str], - sport_key: Optional[str]) -> bool: - """True when the skin declares support for this sport family or exact - sport key. A skin with no targets at all matches everything.""" - sports, sport_keys = skin_targets(manifest) - if not sports and not sport_keys: - return True - if sport and sport in sports: - return True - if sport_key and sport_key in sport_keys: - return True - return False - - -def skins_for_plugin(plugin_id: str, - skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]: - """Installed skins that plausibly apply to a plugin, for UI dropdowns. - - A skin matches when the plugin id is listed in targets.plugins, or any - declared sport / sport_key appears as a token of the plugin id (so a - skin targeting sports=["baseball"] matches "baseball-scoreboard", and - sport_keys=["milb"] matches "milb-scoreboard").""" - if skins is None: - skins = discover_skins() - tokens = set(str(plugin_id).lower().replace("-", "_").split("_")) - matched = {} - for skin_id, manifest in skins.items(): - targets = manifest.get("targets") or {} - if plugin_id in (targets.get("plugins") or []): - matched[skin_id] = manifest - continue - sports, sport_keys = skin_targets(manifest) - if any(str(t).lower() in tokens for t in sports + sport_keys): - matched[skin_id] = manifest - return matched - - -def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]: - """Import the skin's entry module under a namespaced sys.modules key, - namespacing its sibling .py files the same way — the collision- - avoidance scheme plugins use (plugin_loader._namespace_plugin_modules), - so two skins can both ship a helpers.py. - - The entry module is cached: the live/recent/upcoming hosts all load - the same skin, and only the first load executes any code. (A skin - whose *code* changed on disk needs a service restart to take effect — - Python modules can't be safely hot-swapped.) - """ - entry_path = skin_dir / entry_point - if not entry_path.is_file(): - logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path) - return None - - module_name = f"_skin_{skin_id}_{Path(entry_point).stem}" - with _lock: - cached_entry = sys.modules.get(module_name) - if cached_entry is not None: - return cached_entry - - # Import siblings under their namespaced alias, and *bind* the bare - # name (cached or fresh) so `import helpers` inside the entry module - # resolves to this skin's copy. The bare bindings are transient — - # restored below so another skin's identically-named sibling can't - # be shadowed by ours. - replaced_bare: Dict[str, Any] = {} - try: - for sibling in skin_dir.glob("*.py"): - if sibling.name == entry_point: - continue - alias = f"_skin_{skin_id}_{sibling.stem}" - module = sys.modules.get(alias) - if module is None: - spec = importlib.util.spec_from_file_location(alias, sibling) - if not spec or not spec.loader: - continue - module = importlib.util.module_from_spec(spec) - sys.modules[alias] = module - replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem)) - sys.modules[sibling.stem] = module - try: - spec.loader.exec_module(module) - except Exception as e: - logger.error("Skin '%s' sibling module %s failed to import: %s", - skin_id, sibling.name, e, exc_info=True) - sys.modules.pop(alias, None) - return None - else: - replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem)) - sys.modules[sibling.stem] = module - - try: - spec = importlib.util.spec_from_file_location(module_name, entry_path) - if not spec or not spec.loader: - logger.error("Skin '%s': could not create import spec for %s", - skin_id, entry_path) - return None - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module - except Exception as e: - sys.modules.pop(module_name, None) - logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True) - return None - finally: - for bare_name, previous in replaced_bare.items(): - if previous is None: - sys.modules.pop(bare_name, None) - else: - sys.modules[bare_name] = previous - - -def load_skin(skin_id: str, sport: Optional[str] = None, - sport_key: Optional[str] = None, - options: Optional[Dict[str, Any]] = None, - skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]: - """Load and instantiate a skin. Returns None (after logging why) on - any failure — callers treat None as 'use the built-in renderer'.""" - skins = discover_skins(skins_dir) - manifest = skins.get(skin_id) - if manifest is None: - logger.warning("Skin '%s' is configured but not installed under %s; " - "using built-in renderer", - skin_id, skins_dir or get_skins_directory()) - return None - - manifest_major = _major(manifest.get("skin_api_version")) - api_major = _major(SKIN_API_VERSION) - if manifest_major != api_major: - logger.error("Skin '%s' targets skin API %s but this LEDMatrix " - "provides %s — the skin needs an update; using " - "built-in renderer", - skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION) - return None - - if not skin_matches_target(manifest, sport, sport_key): - # Soft: the user explicitly configured it, so warn but load anyway - # (a baseball skin may render an acceptable generic scoreboard). - logger.warning("Skin '%s' does not declare support for sport=%r / " - "sport_key=%r; loading anyway", skin_id, sport, sport_key) - - skin_dir = Path(manifest["_skin_dir"]) - module = _load_skin_module(skin_id, skin_dir, - manifest.get("entry_point", _DEFAULT_ENTRY_POINT)) - if module is None: - return None - - class_name = manifest["class_name"] - skin_class = getattr(module, class_name, None) - if skin_class is None or not isinstance(skin_class, type) or \ - not issubclass(skin_class, ScoreboardSkin): - logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass", - skin_id, class_name) - return None - - try: - return skin_class(manifest, options or {}) - except Exception as e: - logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True) - return None - - -def build_context(host: Any, game: Dict[str, Any], - size: Optional[Tuple[int, int]] = None) -> SkinContext: - """Build a SkinContext for one render call. - - `host` is a SportsCore-style object: display_manager, fonts, logger, - sport, skin_options, _load_and_resize_logo, _draw_text_with_outline. - `size` overrides the canvas size (vegas cards); default is the - current display size read live from the display manager. - """ - if size is not None: - width, height = int(size[0]), int(size[1]) - else: - dm = host.display_manager - width = getattr(dm, "width", None) or dm.matrix.width - height = getattr(dm, "height", None) or dm.matrix.height - - canvas = Image.new("RGB", (width, height), (0, 0, 0)) - draw = ImageDraw.Draw(canvas) - layout = LayoutContext(width, height, _get_font_manager()) - - def load_logo(side: str) -> Optional[Image.Image]: - if side not in ("home", "away"): - return None - try: - logo_path = game.get(f"{side}_logo_path") - if logo_path is not None and not isinstance(logo_path, Path): - logo_path = Path(logo_path) - return host._load_and_resize_logo( - game.get(f"{side}_id"), game.get(f"{side}_abbr"), - logo_path, game.get(f"{side}_logo_url")) - except Exception as e: - host.logger.warning("Skin logo load failed for %s: %s", side, e) - return None - - def draw_text_outlined(text, position, font, fill=(255, 255, 255), - outline_color=(0, 0, 0)): - host._draw_text_with_outline(draw, text, position, font, - fill=fill, outline_color=outline_color) - - return SkinContext( - canvas=canvas, - draw=draw, - layout=layout, - width=width, - height=height, - fonts=dict(host.fonts), - options=dict(getattr(host, "skin_options", {}) or {}), - logger=host.logger, - sport=getattr(host, "sport", None), - load_logo=load_logo, - draw_text_outlined=draw_text_outlined, - ) diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index 7c2b46a0..cb2a37f6 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -639,15 +639,6 @@ "POST" ] ], - [ - "/api/v3/skins", - "api_v3.list_skins", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], [ "/api/v3/starlark/apps", "api_v3.get_starlark_apps", diff --git a/test/test_api_extractors.py b/test/test_api_extractors.py deleted file mode 100644 index cced61af..00000000 --- a/test/test_api_extractors.py +++ /dev/null @@ -1,342 +0,0 @@ -""" -Tests for src/base_classes/api_extractors.py - -Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor, -SoccerAPIExtractor, and the shared _extract_common_details logic. -""" - -import logging -import pytest -from src.base_classes.api_extractors import ( - ESPNFootballExtractor, - ESPNBaseballExtractor, - ESPNHockeyExtractor, - SoccerAPIExtractor, -) - - -# --------------------------------------------------------------------------- -# Shared test data factories -# --------------------------------------------------------------------------- - -def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF", - home_score: str = "14", away_score: str = "7", - date_str: str = "2024-01-15T20:00:00Z", - include_situation: bool = False, - situation: dict | None = None, - status_detail: str = "2nd Qtr 8:42", - period: int = 2) -> dict: - """Build a minimal ESPN-style game event dict.""" - comp_status = { - "type": { - "state": state, - "shortDetail": status_detail, - "detail": status_detail, - "name": "STATUS_IN_PROGRESS", - }, - "period": period, - "displayClock": "8:42", - } - comp = { - "status": comp_status, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"}, - "score": home_score, - }, - { - "homeAway": "away", - "team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"}, - "score": away_score, - }, - ], - } - if include_situation: - comp["situation"] = situation or {} - return { - "id": "test-game-1", - "date": date_str, - "competitions": [comp], - } - - -def _make_logger() -> logging.Logger: - return logging.getLogger("test_extractor") - - -# --------------------------------------------------------------------------- -# ESPNFootballExtractor -# --------------------------------------------------------------------------- - -class TestESPNFootballExtractor: - def setup_method(self): - self.extractor = ESPNFootballExtractor(_make_logger()) - - def test_extract_live_game_basic_fields(self): - event = _make_espn_event(state="in", home_score="14", away_score="7") - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["home_abbr"] == "KC" - assert result["away_abbr"] == "BUF" - assert result["home_score"] == "14" - assert result["away_score"] == "7" - assert result["is_live"] is True - assert result["is_final"] is False - assert result["is_upcoming"] is False - - def test_extract_final_game(self): - event = _make_espn_event(state="post") - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["is_final"] is True - assert result["is_live"] is False - - def test_extract_upcoming_game(self): - event = _make_espn_event(state="pre") - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["is_upcoming"] is True - - def test_sport_specific_fields_default_when_pregame(self): - event = _make_espn_event(state="pre") - fields = self.extractor.get_sport_specific_fields(event) - assert "down" in fields - assert "distance" in fields - assert "possession" in fields - assert "is_redzone" in fields - assert fields["is_redzone"] is False - - def test_sport_specific_fields_live_with_situation(self): - situation = { - "down": 3, - "distance": 7, - "possession": "KC", - "isRedZone": True, - "homeTimeouts": 2, - "awayTimeouts": 1, - } - event = _make_espn_event(state="in", include_situation=True, situation=situation) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["down"] == 3 - assert fields["distance"] == 7 - assert fields["is_redzone"] is True - assert fields["home_timeouts"] == 2 - assert fields["away_timeouts"] == 1 - - def test_scoring_event_detected(self): - # situation must be non-empty (truthy) for the live block to execute - situation = {"down": 1, "distance": 10} - event = _make_espn_event( - state="in", - include_situation=True, - situation=situation, - status_detail="touchdown scored", - ) - fields = self.extractor.get_sport_specific_fields(event) - assert "touchdown" in fields.get("scoring_event", "").lower() - - def test_returns_none_on_empty_event(self): - assert self.extractor.extract_game_details({}) is None - - def test_returns_none_when_teams_missing(self): - event = { - "id": "x", - "date": "2024-01-15T20:00:00Z", - "competitions": [ - { - "status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}}, - "competitors": [], # no competitors - } - ], - } - assert self.extractor.extract_game_details(event) is None - - def test_date_z_suffix_parsed(self): - event = _make_espn_event(date_str="2024-01-15T20:00:00Z") - result = self.extractor.extract_game_details(event) - # Should not raise and should return a result - assert result is not None - - def test_id_propagated(self): - event = _make_espn_event() - result = self.extractor.extract_game_details(event) - assert result["id"] == "test-game-1" - - -# --------------------------------------------------------------------------- -# ESPNBaseballExtractor -# --------------------------------------------------------------------------- - -class TestESPNBaseballExtractor: - def setup_method(self): - self.extractor = ESPNBaseballExtractor(_make_logger()) - - def test_extract_live_game(self): - event = _make_espn_event( - state="in", home_abbr="NYY", away_abbr="BOS", - home_score="3", away_score="2" - ) - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["home_abbr"] == "NYY" - assert result["is_live"] is True - - def test_baseball_sport_fields_defaults(self): - event = _make_espn_event(state="pre") - fields = self.extractor.get_sport_specific_fields(event) - assert "inning" in fields - assert "outs" in fields - assert "bases" in fields - assert "strikes" in fields - assert "balls" in fields - - def test_baseball_sport_fields_live(self): - situation = { - "inning": 7, - "outs": 2, - "bases": "110", - "strikes": 2, - "balls": 3, - "pitcher": "Smith", - "batter": "Jones", - } - event = _make_espn_event(state="in", include_situation=True, situation=situation) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["inning"] == 7 - assert fields["outs"] == 2 - assert fields["strikes"] == 2 - assert fields["pitcher"] == "Smith" - - def test_returns_none_on_empty(self): - assert self.extractor.extract_game_details({}) is None - - -# --------------------------------------------------------------------------- -# ESPNHockeyExtractor -# --------------------------------------------------------------------------- - -class TestESPNHockeyExtractor: - def setup_method(self): - self.extractor = ESPNHockeyExtractor(_make_logger()) - - def test_extract_live_game(self): - event = _make_espn_event( - state="in", home_abbr="BOS", away_abbr="TOR", - home_score="2", away_score="1" - ) - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["is_live"] is True - - def test_hockey_period_text_p1(self): - situation = {"isPowerPlay": False} - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=1 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "P1" - - def test_hockey_period_text_p2(self): - situation = {"isPowerPlay": False} # non-empty so the live block executes - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=2 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "P2" - - def test_hockey_period_text_p3(self): - situation = {"isPowerPlay": False} - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=3 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "P3" - - def test_hockey_period_text_ot(self): - situation = {"isPowerPlay": False} - event = _make_espn_event( - state="in", include_situation=True, situation=situation, period=4 - ) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["period_text"] == "OT1" - - def test_hockey_power_play(self): - situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8} - event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2) - fields = self.extractor.get_sport_specific_fields(event) - assert fields["power_play"] is True - assert fields["shots_on_goal"]["home"] == 12 - assert fields["shots_on_goal"]["away"] == 8 - - def test_hockey_fields_defaults_pregame(self): - event = _make_espn_event(state="pre") - fields = self.extractor.get_sport_specific_fields(event) - assert "period" in fields - assert "power_play" in fields - assert fields["power_play"] is False - - def test_returns_none_on_empty(self): - assert self.extractor.extract_game_details({}) is None - - -# --------------------------------------------------------------------------- -# SoccerAPIExtractor -# --------------------------------------------------------------------------- - -class TestSoccerAPIExtractor: - def setup_method(self): - self.extractor = SoccerAPIExtractor(_make_logger()) - - def _make_soccer_event(self, is_live: bool = True) -> dict: - return { - "id": "soccer-1", - "home_team": {"abbreviation": "ARS", "name": "Arsenal"}, - "away_team": {"abbreviation": "CHE", "name": "Chelsea"}, - "home_score": "2", - "away_score": "1", - "status": "LIVE", - "is_live": is_live, - "is_final": not is_live, - "is_upcoming": False, - "half": "1", - "stoppage_time": "2", - "home_yellow_cards": 1, - "away_yellow_cards": 2, - "home_red_cards": 0, - "away_red_cards": 0, - "home_possession": 55, - "away_possession": 45, - } - - def test_extract_live_game(self): - event = self._make_soccer_event(is_live=True) - result = self.extractor.extract_game_details(event) - assert result is not None - assert result["home_abbr"] == "ARS" - assert result["away_abbr"] == "CHE" - assert result["is_live"] is True - - def test_sport_specific_cards(self): - event = self._make_soccer_event() - fields = self.extractor.get_sport_specific_fields(event) - assert fields["cards"]["home_yellow"] == 1 - assert fields["cards"]["away_yellow"] == 2 - assert fields["cards"]["home_red"] == 0 - - def test_sport_specific_possession(self): - event = self._make_soccer_event() - fields = self.extractor.get_sport_specific_fields(event) - assert fields["possession"]["home"] == 55 - assert fields["possession"]["away"] == 45 - - def test_sport_specific_half(self): - event = self._make_soccer_event() - fields = self.extractor.get_sport_specific_fields(event) - assert fields["half"] == "1" - - def test_scores_as_strings(self): - event = self._make_soccer_event() - result = self.extractor.extract_game_details(event) - assert result["home_score"] == "2" - assert result["away_score"] == "1" diff --git a/test/test_common_is_hardware_free.py b/test/test_common_is_hardware_free.py index 72cdf512..f385e675 100644 --- a/test/test_common_is_hardware_free.py +++ b/test/test_common_is_hardware_free.py @@ -1,9 +1,8 @@ """src/common must stay importable without display hardware. Plugins import src.common.sports_* in place of their bundled copies. If any of -those modules reaches src.display_manager (and through it rgbmatrix) or the -src.base_classes package (whose core.py imports DisplayManager), a scoreboard -adopting it acquires a hardware dependency it never had, and the headless +those modules reaches src.display_manager (and through it rgbmatrix), a +scoreboard adopting it acquires a hardware dependency it never had, and the headless tooling -- the web preview, check_plugin.py, these tests on a laptop -- stops being able to load it. @@ -26,7 +25,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] COMMON = REPO_ROOT / "src" / "common" -FORBIDDEN = ("src.base_classes", "src.display_manager", "src.plugin_system") +FORBIDDEN = ("src.display_manager", "src.plugin_system") #: Existing module-level violations, by file name, each with the reason it is #: tolerated. Empty when this test was added (core 3.4.0 + sports_helpers): @@ -124,13 +123,13 @@ def test_the_scan_sees_a_direct_import(tmp_path): except ImportError: pass if True: - import src.base_classes.sports + import src.plugin_system.plugin_manager from src import plugin_system def later(): from src.display_manager import DisplayManager """)) targets = {t for n in _module_level_imports(tree) for t in _targets(n)} - assert {"src.base_classes.sports", "src.display_manager", + assert {"src.plugin_system.plugin_manager", "src.display_manager", "src.plugin_system"} <= targets diff --git a/test/test_data_sources.py b/test/test_data_sources.py deleted file mode 100644 index d7366923..00000000 --- a/test/test_data_sources.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Tests for src/base_classes/data_sources.py - -Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource. -All HTTP calls are mocked to avoid network access. -""" - -import logging -from datetime import datetime, date -from unittest.mock import MagicMock, patch, Mock -import pytest -import requests - -from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource - - -def _make_logger() -> logging.Logger: - return logging.getLogger("test_data_sources") - - -def _mock_response(json_data: dict, status_code: int = 200): - resp = Mock(spec=requests.Response) - resp.status_code = status_code - resp.json.return_value = json_data - resp.raise_for_status = Mock() - if status_code >= 400: - resp.raise_for_status.side_effect = requests.HTTPError(response=resp) - return resp - - -# --------------------------------------------------------------------------- -# ESPNDataSource -# --------------------------------------------------------------------------- - -class TestESPNDataSource: - def setup_method(self): - self.source = ESPNDataSource(_make_logger()) - - def test_get_headers(self): - headers = self.source.get_headers() - assert headers["Accept"] == "application/json" - assert "LEDMatrix" in headers["User-Agent"] - - def test_fetch_live_games_returns_live_events(self): - live_event = { - "competitions": [{"status": {"type": {"state": "in"}}}] - } - non_live_event = { - "competitions": [{"status": {"type": {"state": "pre"}}}] - } - payload = {"events": [live_event, non_live_event]} - - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("football", "nfl") - - assert len(result) == 1 - assert result[0] is live_event - - def test_fetch_live_games_empty_when_none_live(self): - payload = {"events": [ - {"competitions": [{"status": {"type": {"state": "post"}}}]} - ]} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("football", "nfl") - assert result == [] - - def test_fetch_live_games_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("network failure")): - result = self.source.fetch_live_games("football", "nfl") - assert result == [] - - def test_fetch_schedule_returns_all_events(self): - events = [{"id": "1"}, {"id": "2"}] - payload = {"events": events} - start = datetime(2024, 1, 1) - end = datetime(2024, 1, 7) - - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_schedule("football", "nfl", (start, end)) - - assert len(result) == 2 - - def test_fetch_schedule_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("timeout")): - result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now())) - assert result == [] - - def test_fetch_standings_success(self): - payload = {"standings": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_standings("football", "nfl") - assert result == payload - - def test_fetch_standings_returns_empty_on_error(self): - # A transport failure is a RequestException, not a bare Exception. - # The old stand-in passed only because the handler caught everything, - # including bugs in the method under test. - with patch.object(self.source.session, "get", - side_effect=requests.ConnectionError("error")): - result = self.source.fetch_standings("football", "nfl") - assert result == {} - - # ------------------------------------------------------------------ - # fetch_standings endpoint selection - # - # College leagues publish a poll at /rankings and a records table at - # /standings; professional leagues have only /standings. Probing them in - # the wrong order still returns 200 -- just without a poll in it -- so - # nothing failed and the rank badge simply never appeared. Order is the - # behaviour here, so these tests assert it directly. - # ------------------------------------------------------------------ - - @staticmethod - def _requested_endpoints(mock_get): - """The endpoint names probed, in the order they were requested.""" - return [call.args[0].rsplit("/", 1)[-1] for call in mock_get.call_args_list] - - def test_professional_league_asks_standings_first(self): - payload = {"standings": []} - with patch.object(self.source.session, "get", - return_value=_mock_response(payload)) as mock_get: - result = self.source.fetch_standings("football", "nfl") - assert result == payload - assert self._requested_endpoints(mock_get) == ["standings"] - - def test_college_league_asks_rankings_first(self): - poll = {"rankings": [{"name": "AP Top 25"}]} - with patch.object(self.source.session, "get", - return_value=_mock_response(poll)) as mock_get: - result = self.source.fetch_standings("football", "college-football") - assert result == poll - assert self._requested_endpoints(mock_get) == ["rankings"] - - def test_rankings_200_without_a_poll_falls_through_to_standings(self): - """A 200 is not the same as an answer. - - This is the case the old code could not see: the endpoint responded, - so nothing raised, but the body carried no poll. - """ - empty_poll = _mock_response({"rankings": []}) - table = _mock_response({"standings": [{"entries": []}]}) - with patch.object(self.source.session, "get", - side_effect=[empty_poll, table]) as mock_get: - result = self.source.fetch_standings( - "basketball", "mens-college-basketball") - assert result == {"standings": [{"entries": []}]} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_404_on_the_first_endpoint_falls_through_quietly(self): - missing = _mock_response({}, status_code=404) - table = _mock_response({"standings": []}) - with patch.object(self.source.session, "get", - side_effect=[missing, table]) as mock_get: - result = self.source.fetch_standings("baseball", "college-baseball") - assert result == {"standings": []} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_recovers_from_a_non_404_failure_on_the_first_endpoint(self): - table = _mock_response({"standings": [{"entries": []}]}) - with patch.object(self.source.session, "get", - side_effect=[requests.ConnectionError("reset"), table]) as mock_get: - result = self.source.fetch_standings("football", "college-football") - assert result == {"standings": [{"entries": []}]} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_both_endpoints_failing_returns_empty(self): - with patch.object(self.source.session, "get", - side_effect=requests.ConnectionError("down")) as mock_get: - result = self.source.fetch_standings("football", "nfl") - assert result == {} - assert self._requested_endpoints(mock_get) == ["standings", "rankings"] - - def test_a_non_object_payload_is_treated_as_a_miss(self): - odd = _mock_response(["not", "an", "object"]) - table = _mock_response({"standings": []}) - with patch.object(self.source.session, "get", - side_effect=[odd, table]) as mock_get: - result = self.source.fetch_standings("football", "college-football") - assert result == {"standings": []} - assert self._requested_endpoints(mock_get) == ["rankings", "standings"] - - def test_a_bug_in_this_method_is_not_swallowed_as_a_failed_endpoint(self): - """The guard for the narrowed handler. - - An error raised while reading the payload used to be caught by the - endpoint handler and reported as 'no poll here', which would silently - drop rankings for a league that has them. It must surface instead. - """ - boom = Mock(spec=requests.Response) - boom.status_code = 200 - boom.raise_for_status = Mock() - boom.json.side_effect = TypeError("a bug, not a network failure") - with patch.object(self.source.session, "get", return_value=boom): - with pytest.raises(TypeError): - self.source.fetch_standings("football", "nfl") - - def test_base_url_set_correctly(self): - assert "espn.com" in self.source.base_url - - -# --------------------------------------------------------------------------- -# MLBAPIDataSource -# --------------------------------------------------------------------------- - -class TestMLBAPIDataSource: - def setup_method(self): - self.source = MLBAPIDataSource(_make_logger()) - - def test_fetch_live_games_filters_live(self): - live_game = {"status": {"abstractGameState": "Live"}} - final_game = {"status": {"abstractGameState": "Final"}} - payload = {"dates": [{"games": [live_game, final_game]}]} - - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("baseball", "mlb") - - assert len(result) == 1 - assert result[0] is live_game - - def test_fetch_live_games_empty_dates(self): - payload = {"dates": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("baseball", "mlb") - assert result == [] - - def test_fetch_live_games_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_live_games("baseball", "mlb") - assert result == [] - - def test_fetch_schedule_aggregates_all_dates(self): - payload = { - "dates": [ - {"games": [{"id": "1"}, {"id": "2"}]}, - {"games": [{"id": "3"}]}, - ] - } - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now())) - assert len(result) == 3 - - def test_fetch_schedule_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now())) - assert result == [] - - def test_fetch_standings_success(self): - payload = {"records": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_standings("baseball", "mlb") - assert result == payload - - def test_fetch_standings_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_standings("baseball", "mlb") - assert result == {} - - -# --------------------------------------------------------------------------- -# SoccerAPIDataSource -# --------------------------------------------------------------------------- - -class TestSoccerAPIDataSource: - def setup_method(self): - self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123") - - def test_headers_include_api_key(self): - headers = self.source.get_headers() - assert headers["X-Auth-Token"] == "test-key-123" - - def test_headers_without_api_key(self): - source = SoccerAPIDataSource(_make_logger()) - headers = source.get_headers() - assert "X-Auth-Token" not in headers - - def test_fetch_live_games_success(self): - payload = {"matches": [{"id": "m1"}, {"id": "m2"}]} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_live_games("soccer", "eng.1") - assert len(result) == 2 - - def test_fetch_live_games_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_live_games("soccer", "eng.1") - assert result == [] - - def test_fetch_schedule_success(self): - payload = {"matches": [{"id": "m1"}]} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now())) - assert len(result) == 1 - - def test_fetch_schedule_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now())) - assert result == [] - - def test_fetch_standings_success(self): - payload = {"standings": []} - with patch.object(self.source.session, "get", return_value=_mock_response(payload)): - result = self.source.fetch_standings("soccer", "PL") - assert result == payload - - def test_fetch_standings_returns_empty_on_error(self): - with patch.object(self.source.session, "get", side_effect=Exception("err")): - result = self.source.fetch_standings("soccer", "PL") - assert result == {} diff --git a/test/test_discovery_path_contract.py b/test/test_discovery_path_contract.py index 993797be..6e8e96ca 100644 --- a/test/test_discovery_path_contract.py +++ b/test/test_discovery_path_contract.py @@ -209,30 +209,3 @@ class TestStandaloneBackupContract: assert "'.standalone-backup-'" in pm_text.replace('"', "'") assert ".standalone-backup-" in sm_text - -class TestSkinTargetResolution: - def _store(self, tmp_path): - return PluginStoreManager( - plugins_dir=str(tmp_path / "plugins"), - uninstalled_registry_path=str(tmp_path / "uninstalled.json")) - - def test_valid_skin_id_resolves_inside_skins_dir(self, tmp_path): - from src.skin_system import skin_runtime - store = self._store(tmp_path) - target = store._resolve_skin_target("my-skin") - assert target is not None - assert target.parent == skin_runtime.get_skins_directory().resolve() - - @pytest.mark.parametrize("bad_id", [ - "../evil", - "..", - "a/../../etc", - "/etc/passwd", - "skin/../../outside", - "", - None, - 123, - ]) - def test_traversal_and_malformed_ids_rejected(self, tmp_path, bad_id): - store = self._store(tmp_path) - assert store._resolve_skin_target(bad_id) is None diff --git a/test/test_retired_plugin_config_keys.py b/test/test_retired_plugin_config_keys.py new file mode 100644 index 00000000..7e7df496 --- /dev/null +++ b/test/test_retired_plugin_config_keys.py @@ -0,0 +1,167 @@ +"""``skin`` and ``skin_options`` outlive the skin system in stored configs. + +The skin system was removed, but a config.json written while it existed can +carry ``skin`` / ``skin_options`` in any plugin section. They used to be core +plugin properties; now they are RETIRED_PLUGIN_KEYS, dropped where a section +is prepared or validated. Most plugin schemas set +``"additionalProperties": false``, so without that a device upgraded with such +a config would flag the plugin degraded on every start. + +The web save paths are covered in +test/web_interface/test_plugin_config_json_saves.py (TestRetiredSkinKeys). +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from src.config_manager import ConfigManager +from src.plugin_system.plugin_manager import PluginManager +from src.plugin_system.schema_manager import ( + CORE_PLUGIN_PROPERTIES, + RETIRED_PLUGIN_KEYS, + SchemaManager, + drop_retired_plugin_keys, +) + +PLUGIN_ID = "scoreboard-shaped" + +STRICT_SCHEMA = { + "type": "object", + "additionalProperties": False, + "properties": { + "enabled": {"type": "boolean", "default": True}, + "favorite_teams": {"type": "array", "items": {"type": "string"}, + "default": []}, + }, +} + +OLD_SECTION = { + "enabled": True, + "favorite_teams": ["TB"], + "skin": {"live": "neon"}, + "skin_options": {"accent": "#ff0000"}, +} + + +def _copy(value): + return json.loads(json.dumps(value)) + + +class TestDropRetiredPluginKeys: + def test_they_are_no_longer_core_properties(self): + assert RETIRED_PLUGIN_KEYS == {"skin", "skin_options"} + assert RETIRED_PLUGIN_KEYS.isdisjoint(CORE_PLUGIN_PROPERTIES) + + def test_drops_both(self): + assert drop_retired_plugin_keys(OLD_SECTION, STRICT_SCHEMA) == { + "enabled": True, "favorite_teams": ["TB"]} + + def test_does_not_mutate_the_section(self): + section = _copy(OLD_SECTION) + drop_retired_plugin_keys(section, STRICT_SCHEMA) + assert section == OLD_SECTION + + def test_returns_the_same_object_when_there_is_nothing_to_drop(self): + section = {"enabled": True} + assert drop_retired_plugin_keys(section, STRICT_SCHEMA) is section + + def test_a_plugin_that_declares_skin_keeps_it(self): + schema = _copy(STRICT_SCHEMA) + schema["properties"]["skin"] = {"type": "string"} + assert drop_retired_plugin_keys(OLD_SECTION, schema) == { + "enabled": True, "favorite_teams": ["TB"], "skin": {"live": "neon"}} + + def test_without_a_schema_nothing_is_dropped(self): + assert drop_retired_plugin_keys(OLD_SECTION, None) is OLD_SECTION + + def test_tolerates_a_non_dict(self): + assert drop_retired_plugin_keys(None, STRICT_SCHEMA) is None + + +@pytest.fixture +def schema_manager(tmp_path): + plugin_dir = tmp_path / "plugins" / PLUGIN_ID + plugin_dir.mkdir(parents=True) + (plugin_dir / "config_schema.json").write_text(json.dumps(STRICT_SCHEMA), + encoding="utf-8") + (plugin_dir / "manifest.json").write_text(json.dumps({"id": PLUGIN_ID}), + encoding="utf-8") + return SchemaManager(plugins_dir=tmp_path / "plugins", project_root=tmp_path) + + +class TestValidation: + def test_the_prepared_section_has_neither_and_validates(self, schema_manager): + prepared = schema_manager.prepare_plugin_config(PLUGIN_ID, _copy(OLD_SECTION)) + assert "skin" not in prepared and "skin_options" not in prepared + assert prepared["favorite_teams"] == ["TB"] + assert schema_manager.validate_config_against_schema( + prepared, STRICT_SCHEMA, PLUGIN_ID) == (True, []) + + def test_the_raw_section_validates_too(self, schema_manager): + assert schema_manager.validate_config_against_schema( + _copy(OLD_SECTION), STRICT_SCHEMA, PLUGIN_ID) == (True, []) + + def test_validate_all_plugin_configs_passes(self, schema_manager, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"timezone": "UTC", PLUGIN_ID: OLD_SECTION}), + encoding="utf-8") + config_manager = ConfigManager(config_path=str(config_file), + secrets_path=str(tmp_path / "secrets.json")) + results = config_manager.validate_all_plugin_configs(schema_manager) + assert results[PLUGIN_ID] == {"valid": True, "errors": []} + + def test_a_real_violation_is_still_reported(self, schema_manager): + section = dict(OLD_SECTION, not_declared=1) + valid, errors = schema_manager.validate_config_against_schema( + section, STRICT_SCHEMA, PLUGIN_ID) + assert valid is False and len(errors) == 1 + assert "not_declared" in errors[0] + + +class TestLoadPath: + """The real PluginManager.load_plugin, with the plugin class stubbed.""" + + def test_an_old_section_loads_without_warning_or_degraded(self, tmp_path): + plugins_dir = tmp_path / "plugins" + plugin_dir = plugins_dir / PLUGIN_ID + plugin_dir.mkdir(parents=True) + manifest = {"id": PLUGIN_ID, "name": "Scoreboard-shaped", "version": "1.0.0", + "entry_point": "manager.py", "class_name": "Plugin"} + (plugin_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + (plugin_dir / "config_schema.json").write_text(json.dumps(STRICT_SCHEMA), + encoding="utf-8") + + config_manager = MagicMock() + config_manager.load_config.return_value = {PLUGIN_ID: _copy(OLD_SECTION)} + with patch('src.common.permission_utils.ensure_directory_permissions'): + manager = PluginManager(plugins_dir=str(plugins_dir), + config_manager=config_manager, + display_manager=MagicMock(), + cache_manager=MagicMock()) + manager.logger = MagicMock() + manager.health_tracker = MagicMock() + manager.plugin_manifests[PLUGIN_ID] = manifest + manager.plugin_loader.find_plugin_directory = MagicMock(return_value=plugin_dir) + + received = {} + + def fake_load_plugin(**kwargs): + received.update(kwargs["config"]) + instance = MagicMock() + instance.validate_config.return_value = True + return instance, MagicMock() + + manager.plugin_loader.load_plugin = MagicMock(side_effect=fake_load_plugin) + + assert manager.load_plugin(PLUGIN_ID) is True + assert "skin" not in received and "skin_options" not in received + assert received["favorite_teams"] == ["TB"] + warnings = [c for c in manager.logger.warning.call_args_list + if "does not match its schema" in str(c.args[0])] + assert warnings == [] + degraded = [c.args for c in manager.health_tracker.set_degraded.call_args_list + if c.args[0] == PLUGIN_ID] + assert degraded, "schema validation never ran" + assert degraded[-1][1] is None diff --git a/test/test_skin_runtime_cache.py b/test/test_skin_runtime_cache.py deleted file mode 100644 index 460e1902..00000000 --- a/test/test_skin_runtime_cache.py +++ /dev/null @@ -1,377 +0,0 @@ -"""Gap tests for src/skin_system/skin_runtime.py: the discovery cache, -module namespacing internals, API gating edge cases, and targeting. - -test/test_skin_system.py already covers discovery validation, load_skin -basics, and build_context — nothing here duplicates those. - -NOTE: every test uses a UNIQUE skin id. load_skin caches the entry -module in sys.modules per skin id and never re-executes it, so reusing -an id across tests would silently serve another test's module. -""" - -import builtins -import json -import os -import sys -import time -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -# skin_runtime -> skin_base can transitively reach hardware modules via -# sports imports in sibling tests' processes; stub the matrix driver -# before importing, matching test_skin_system.py. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.skin_system import skin_runtime -from src.skin_system.skin_base import SKIN_API_VERSION, ScoreboardSkin - - -DEFAULT_BODY = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class {cls}(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " return True\n" -) - - -@pytest.fixture(autouse=True) -def _clean_runtime_state(): - """Clear the discovery cache and any skin modules this test creates.""" - skin_runtime._discovery_cache.clear() - before = {k for k in sys.modules if k.startswith("_skin_")} - yield - skin_runtime._discovery_cache.clear() - created = [k for k in sys.modules - if k.startswith("_skin_") and k not in before] - for k in created: - sys.modules.pop(k, None) - - -def make_skin(skins_dir: Path, skin_id: str, *, - api_version: str = SKIN_API_VERSION, - class_name: str = "TestSkin", - body: str = None, - extra_files: dict = None, - entry_point: str = None, - manifest_id: str = None, - manifest_extra: dict = None, - write_entry: bool = True) -> Path: - """Write a skin package directory and return its path.""" - skin_dir = skins_dir / skin_id - skin_dir.mkdir(parents=True, exist_ok=True) - manifest = { - "id": manifest_id or skin_id, - "name": skin_id, - "version": "1.0.0", - "skin_api_version": api_version, - "class_name": class_name, - } - if entry_point: - manifest["entry_point"] = entry_point - manifest.update(manifest_extra or {}) - (skin_dir / "skin.json").write_text(json.dumps(manifest)) - if write_entry: - entry_name = entry_point or "skin.py" - (skin_dir / entry_name).write_text( - body if body is not None else DEFAULT_BODY.format(cls=class_name)) - for name, content in (extra_files or {}).items(): - (skin_dir / name).write_text(content) - return skin_dir - - -def counting_read_manifest(monkeypatch): - """Wrap skin_runtime._read_manifest with a call counter.""" - original = skin_runtime._read_manifest - counter = {"count": 0} - - def wrapper(skin_dir): - counter["count"] += 1 - return original(skin_dir) - - monkeypatch.setattr(skin_runtime, "_read_manifest", wrapper) - return counter - - -def bump_mtime(path: Path, offset: float = 100.0): - """Set a distinct, strictly later mtime so the fingerprint changes.""" - t = time.time() + offset - os.utime(path, (t, t)) - - -# --------------------------------------------------------------------------- -# A. Discovery cache -# --------------------------------------------------------------------------- - -class TestDiscoveryCache: - def test_second_call_serves_cache(self, tmp_path, monkeypatch): - make_skin(tmp_path, "t01-cache-hit") - counter = counting_read_manifest(monkeypatch) - first = skin_runtime.discover_skins(tmp_path) - count_after_first = counter["count"] - assert count_after_first >= 1 - second = skin_runtime.discover_skins(tmp_path) - assert counter["count"] == count_after_first # no re-read - assert second == first - assert "t01-cache-hit" in second - - def test_manifest_edit_invalidates_without_force_refresh(self, tmp_path): - skin_dir = make_skin(tmp_path, "t02-edit") - skins = skin_runtime.discover_skins(tmp_path) - assert skins["t02-edit"]["name"] == "t02-edit" - - manifest_path = skin_dir / "skin.json" - manifest = json.loads(manifest_path.read_text()) - manifest["name"] = "renamed" - manifest_path.write_text(json.dumps(manifest)) - bump_mtime(manifest_path) - - skins = skin_runtime.discover_skins(tmp_path) # no force_refresh - assert skins["t02-edit"]["name"] == "renamed" - - def test_new_skin_dir_invalidates(self, tmp_path): - make_skin(tmp_path, "t03-first") - assert set(skin_runtime.discover_skins(tmp_path)) == {"t03-first"} - - new_dir = make_skin(tmp_path, "t03-second") - bump_mtime(new_dir / "skin.json") - bump_mtime(tmp_path) - - skins = skin_runtime.discover_skins(tmp_path) # no force_refresh - assert set(skins) == {"t03-first", "t03-second"} - - def test_py_file_change_does_not_invalidate(self, tmp_path, monkeypatch): - # PIN: the fingerprint only globs */skin.json — editing a skin's - # .py file alone does NOT invalidate the cache; the cached - # manifests are still served (a code change needs a restart). - skin_dir = make_skin(tmp_path, "t04-pyedit") - counter = counting_read_manifest(monkeypatch) - skin_runtime.discover_skins(tmp_path) - count_after_first = counter["count"] - - (skin_dir / "skin.py").write_text("# rewritten\n" + - DEFAULT_BODY.format(cls="TestSkin")) - bump_mtime(skin_dir / "skin.py") - - skins = skin_runtime.discover_skins(tmp_path) - assert counter["count"] == count_after_first # cache still served - assert "t04-pyedit" in skins - - def test_force_refresh_rereads_with_unchanged_fingerprint(self, tmp_path, - monkeypatch): - make_skin(tmp_path, "t05-force") - counter = counting_read_manifest(monkeypatch) - skin_runtime.discover_skins(tmp_path) - count_after_first = counter["count"] - skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert counter["count"] > count_after_first - - def test_result_mapping_is_copy_but_manifests_shared(self, tmp_path): - make_skin(tmp_path, "t06-copy") - result = skin_runtime.discover_skins(tmp_path) - - # Mutating the returned mapping does not poison the cache... - del result["t06-copy"] - again = skin_runtime.discover_skins(tmp_path) # cache hit - assert "t06-copy" in again - - # ...but the inner manifest dicts ARE shared with the cache (pin). - again["t06-copy"]["name"] = "mutated-inner" - third = skin_runtime.discover_skins(tmp_path) # cache hit - assert third["t06-copy"]["name"] == "mutated-inner" - - def test_missing_directory_returns_empty_and_caches_nothing(self, tmp_path): - missing = tmp_path / "not-yet" - assert skin_runtime.discover_skins(missing) == {} - assert str(missing) not in skin_runtime._discovery_cache - - # Creating the directory later is picked up without force_refresh. - make_skin(missing, "t07-late") - skins = skin_runtime.discover_skins(missing) - assert "t07-late" in skins - - def test_hidden_underscore_and_plain_file_entries_skipped(self, tmp_path): - make_skin(tmp_path, ".hidden-skin") - make_skin(tmp_path, "_private-skin") - (tmp_path / "stray-file").write_text("not a directory") - make_skin(tmp_path, "t08-good") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert set(skins) == {"t08-good"} - - def test_manifest_id_mismatch_keys_by_manifest_id(self, tmp_path): - make_skin(tmp_path, "t09-dirname", manifest_id="t09-manifest-id") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "t09-manifest-id" in skins - assert "t09-dirname" not in skins - assert skins["t09-manifest-id"]["_skin_dir"].endswith("t09-dirname") - - def test_falsy_required_field_drops_skin(self, tmp_path): - make_skin(tmp_path, "t10-empty-class", class_name="") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert skins == {} - - -# --------------------------------------------------------------------------- -# B. Module namespacing (_load_skin_module via load_skin) -# --------------------------------------------------------------------------- - -BODY_WITH_HELPERS = ( - "import helpers\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " pass\n" -) - - -class TestModuleNamespacing: - def test_namespaced_sys_modules_keys(self, tmp_path): - make_skin(tmp_path, "t11-ns", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "VALUE = 11\n"}) - skin = skin_runtime.load_skin("t11-ns", skins_dir=tmp_path) - assert skin is not None - assert "_skin_t11-ns_skin" in sys.modules - assert "_skin_t11-ns_helpers" in sys.modules - - def test_preseeded_bare_name_restored(self, tmp_path, monkeypatch): - sentinel = object() - monkeypatch.setitem(sys.modules, "helpers", sentinel) - make_skin(tmp_path, "t12a-restore", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "VALUE = 'a'\n"}) - skin = skin_runtime.load_skin("t12a-restore", skins_dir=tmp_path) - assert skin is not None - assert sys.modules["helpers"] is sentinel - - def test_absent_bare_name_stays_absent(self, tmp_path): - saved = sys.modules.pop("helpers", None) - try: - assert "helpers" not in sys.modules - make_skin(tmp_path, "t12b-absent", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "VALUE = 'b'\n"}) - skin = skin_runtime.load_skin("t12b-absent", skins_dir=tmp_path) - assert skin is not None - assert "helpers" not in sys.modules - finally: - if saved is not None: - sys.modules["helpers"] = saved - - def test_stdlib_shadowing_sibling_leaves_real_module_intact(self, tmp_path): - real_json = sys.modules["json"] - make_skin(tmp_path, "t12c-json", - extra_files={"json.py": "SKIN_LOCAL = True\n"}) - skin = skin_runtime.load_skin("t12c-json", skins_dir=tmp_path) - assert skin is not None - assert sys.modules["json"] is real_json - assert not hasattr(sys.modules["json"], "SKIN_LOCAL") - assert json.loads('{"ok": 1}') == {"ok": 1} # stdlib still works - # The skin's copy lives only under its namespaced alias. - assert getattr(sys.modules["_skin_t12c-json_json"], "SKIN_LOCAL") is True - - def test_entry_module_executed_once_across_loads(self, tmp_path, - monkeypatch): - executions = [] - monkeypatch.setattr(builtins, "_t13_skin_executions", executions, - raising=False) - body = ( - "import builtins\n" - "builtins._t13_skin_executions.append(1)\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " pass\n" - ) - make_skin(tmp_path, "t13-cached", body=body) - for _ in range(3): - skin = skin_runtime.load_skin("t13-cached", skins_dir=tmp_path) - assert skin is not None - assert len(executions) == 1 # module executed exactly once - - def test_sibling_import_failure_returns_none_and_restores_bare( - self, tmp_path, monkeypatch): - sentinel = object() - monkeypatch.setitem(sys.modules, "helpers", sentinel) - make_skin(tmp_path, "t14-sibfail", body=BODY_WITH_HELPERS, - extra_files={"helpers.py": "raise RuntimeError('sibling boom')\n"}) - assert skin_runtime.load_skin("t14-sibfail", skins_dir=tmp_path) is None - assert sys.modules["helpers"] is sentinel - - def test_missing_entry_point_file(self, tmp_path): - make_skin(tmp_path, "t15-noentry", write_entry=False) - assert skin_runtime.load_skin("t15-noentry", skins_dir=tmp_path) is None - - def test_custom_entry_point(self, tmp_path): - make_skin(tmp_path, "t16-custom", entry_point="render.py") - skin = skin_runtime.load_skin("t16-custom", skins_dir=tmp_path) - assert isinstance(skin, ScoreboardSkin) - assert "_skin_t16-custom_render" in sys.modules - assert "_skin_t16-custom_skin" not in sys.modules - - def test_class_name_pointing_at_unrelated_class(self, tmp_path): - body = "class NotASkin:\n pass\n" - make_skin(tmp_path, "t17a-wrongclass", body=body, - class_name="NotASkin") - assert skin_runtime.load_skin("t17a-wrongclass", - skins_dir=tmp_path) is None - - def test_class_name_pointing_at_instance(self, tmp_path): - body = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class MySkin(ScoreboardSkin):\n" - " pass\n" - "obj = MySkin({}, {})\n" - ) - make_skin(tmp_path, "t17b-instance", body=body, class_name="obj") - assert skin_runtime.load_skin("t17b-instance", - skins_dir=tmp_path) is None - - def test_constructor_raising_returns_none(self, tmp_path): - body = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " def __init__(self, manifest, options):\n" - " raise ValueError('ctor boom')\n" - ) - make_skin(tmp_path, "t18-ctor", body=body) - assert skin_runtime.load_skin("t18-ctor", skins_dir=tmp_path) is None - - -# --------------------------------------------------------------------------- -# C. API gate + targeting -# --------------------------------------------------------------------------- - -class TestApiGateAndTargeting: - def test_same_major_higher_minor_loads(self, tmp_path): - make_skin(tmp_path, "t19-minor", api_version="1.9.0") - skin = skin_runtime.load_skin("t19-minor", skins_dir=tmp_path) - assert isinstance(skin, ScoreboardSkin) - - def test_malformed_api_version_refused(self, tmp_path): - make_skin(tmp_path, "t20-malformed", api_version="abc") - assert skin_runtime.load_skin("t20-malformed", - skins_dir=tmp_path) is None - - @pytest.mark.parametrize("manifest,sport,sport_key,expected", [ - # No targets key at all -> matches everything - ({"id": "x"}, "baseball", "mlb", True), - ({"id": "x"}, None, None, True), - # Empty targets dict -> matches everything - ({"id": "x", "targets": {}}, "hockey", None, True), - # sports family match - ({"id": "x", "targets": {"sports": ["baseball"]}}, - "baseball", None, True), - # sport_keys exact match - ({"id": "x", "targets": {"sport_keys": ["milb"]}}, - None, "milb", True), - # OR semantics: sport_keys matches even though sports excludes it - ({"id": "x", "targets": {"sports": ["hockey"], - "sport_keys": ["milb"]}}, - "baseball", "milb", True), - # Neither matches - ({"id": "x", "targets": {"sports": ["hockey"]}}, - "baseball", None, False), - ({"id": "x", "targets": {"sports": ["hockey"], - "sport_keys": ["nhl"]}}, - "baseball", "milb", False), - ]) - def test_skin_matches_target(self, manifest, sport, sport_key, expected): - assert skin_runtime.skin_matches_target( - manifest, sport, sport_key) is expected diff --git a/test/test_skin_system.py b/test/test_skin_system.py deleted file mode 100644 index 1a7138c2..00000000 --- a/test/test_skin_system.py +++ /dev/null @@ -1,571 +0,0 @@ -"""Tests for the skin system: discovery, version gating, fallback -semantics, module isolation, and the view-model contract.""" - -import json -import logging -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from PIL import Image, ImageFont - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so the fallback-semantics tests can import SportsCore off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.skin_system import skin_runtime -from src.skin_system.skin_base import ( - SKIN_API_VERSION, - ScoreboardSkin, - SkinContext, -) - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures" - -# The v1.0 guaranteed view-model keys (docs/CREATING_SKINS.md). Renaming -# or removing any of these is a breaking change to every published skin: -# it requires a VIEW_MODEL_VERSION major bump and a compat shim. -GUARANTEED_KEYS = [ - "id", "game_time", "game_date", "start_time_utc", "status_text", - "is_live", "is_final", "is_upcoming", "is_halftime", - "home_abbr", "home_id", "home_score", "home_logo_path", "home_record", - "away_abbr", "away_id", "away_score", "away_logo_path", "away_record", -] - - -def write_skin(skins_dir: Path, skin_id: str, *, api_version: str = SKIN_API_VERSION, - body: str = None, extra_files: dict = None, - class_name: str = "TestSkin") -> Path: - skin_dir = skins_dir / skin_id - skin_dir.mkdir(parents=True) - manifest = { - "id": skin_id, "name": skin_id, "version": "1.0.0", - "skin_api_version": api_version, "class_name": class_name, - "targets": {"sports": ["baseball"]}, - } - (skin_dir / "skin.json").write_text(json.dumps(manifest)) - if body is None: - body = ( - "from src.skin_system.skin_base import ScoreboardSkin\n" - f"class {class_name}(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " ctx.draw.rectangle([0, 0, 4, 4], fill=(255, 0, 0))\n" - " return True\n" - ) - (skin_dir / "skin.py").write_text(body) - for name, content in (extra_files or {}).items(): - (skin_dir / name).write_text(content) - return skin_dir - - -class TestDiscovery: - def test_discovers_valid_skin(self, tmp_path): - write_skin(tmp_path, "my-skin") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "my-skin" in skins - assert skins["my-skin"]["_skin_dir"].endswith("my-skin") - - def test_skips_manifest_missing_required_fields(self, tmp_path): - skin_dir = tmp_path / "broken" - skin_dir.mkdir() - (skin_dir / "skin.json").write_text(json.dumps({"id": "broken"})) - assert skin_runtime.discover_skins(tmp_path, force_refresh=True) == {} - - def test_skips_unreadable_manifest_and_non_skin_dirs(self, tmp_path): - (tmp_path / "not-a-skin").mkdir() - bad = tmp_path / "bad-json" - bad.mkdir() - (bad / "skin.json").write_text("{nope") - write_skin(tmp_path, "good-skin") - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert list(skins) == ["good-skin"] - - def test_missing_directory_is_empty(self, tmp_path): - assert skin_runtime.discover_skins(tmp_path / "nope") == {} - - def test_example_skin_in_repo_is_discoverable(self): - skins = skin_runtime.discover_skins(force_refresh=True) - assert "example-classic-baseball" in skins - - -class TestLoadSkin: - def test_loads_and_instantiates(self, tmp_path): - write_skin(tmp_path, "my-skin") - skin = skin_runtime.load_skin("my-skin", sport="baseball", - skins_dir=tmp_path) - assert isinstance(skin, ScoreboardSkin) - - def test_unknown_skin_returns_none(self, tmp_path): - assert skin_runtime.load_skin("ghost", skins_dir=tmp_path) is None - - def test_api_major_mismatch_is_refused(self, tmp_path): - write_skin(tmp_path, "old-skin", api_version="99.0.0") - assert skin_runtime.load_skin("old-skin", skins_dir=tmp_path) is None - - def test_target_mismatch_still_loads(self, tmp_path): - write_skin(tmp_path, "my-skin") # targets baseball - skin = skin_runtime.load_skin("my-skin", sport="hockey", - skins_dir=tmp_path) - assert skin is not None # soft warning, not a hard block - - def test_import_error_returns_none(self, tmp_path): - write_skin(tmp_path, "crashy", body="raise RuntimeError('boom')\n") - assert skin_runtime.load_skin("crashy", skins_dir=tmp_path) is None - - def test_wrong_class_returns_none(self, tmp_path): - write_skin(tmp_path, "classless", body="x = 1\n") - assert skin_runtime.load_skin("classless", skins_dir=tmp_path) is None - - def test_options_are_passed_through(self, tmp_path): - write_skin(tmp_path, "my-skin") - skin = skin_runtime.load_skin("my-skin", skins_dir=tmp_path, - options={"accent": [1, 2, 3]}) - assert skin.options == {"accent": [1, 2, 3]} - - def test_sibling_modules_are_isolated_between_skins(self, tmp_path): - helper = "VALUE = {!r}\n" - body = ( - "import helpers\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " ctx.logger.info(helpers.VALUE)\n" - " self.helper_value = helpers.VALUE\n" - " return False\n" - ) - write_skin(tmp_path, "skin-a", body=body, - extra_files={"helpers.py": helper.format("A")}) - write_skin(tmp_path, "skin-b", body=body, - extra_files={"helpers.py": helper.format("B")}) - skin_a = skin_runtime.load_skin("skin-a", skins_dir=tmp_path) - skin_b = skin_runtime.load_skin("skin-b", skins_dir=tmp_path) - ctx = _make_context() - skin_a.render_live(ctx, {}) - skin_b.render_live(ctx, {}) - assert skin_a.helper_value == "A" - assert skin_b.helper_value == "B" - - def test_same_skin_loads_repeatedly_with_siblings(self, tmp_path): - """The live/recent/upcoming hosts each load the same skin — the - 2nd and 3rd loads must still resolve sibling modules (regression: - cached siblings used to be skipped without rebinding).""" - body = ( - "import reload_helpers\n" - "from src.skin_system.skin_base import ScoreboardSkin\n" - "class TestSkin(ScoreboardSkin):\n" - " def render_live(self, ctx, game):\n" - " self.helper_value = reload_helpers.VALUE\n" - " return False\n" - ) - write_skin(tmp_path, "reload-skin", body=body, - extra_files={"reload_helpers.py": "VALUE = 'R'\n"}) - ctx = _make_context() - for _ in range(3): - skin = skin_runtime.load_skin("reload-skin", skins_dir=tmp_path) - assert skin is not None - skin.render_live(ctx, {}) - assert skin.helper_value == "R" - - -def _make_host(fonts=None): - host = MagicMock() - host.sport = "baseball" - host.sport_key = "mlb" - host.skin_options = {"accent": True} - host.fonts = fonts or {"time": ImageFont.load_default()} - host.logger = logging.getLogger("test_skin_system") - host.display_manager.width = 128 - host.display_manager.height = 32 - return host - - -def _make_context(width=128, height=32): - host = _make_host() - return skin_runtime.build_context(host, {}, size=(width, height)) - - -class TestBuildContext: - def test_context_shape(self): - host = _make_host() - game = {"home_abbr": "LAD", "away_abbr": "SF"} - ctx = skin_runtime.build_context(host, game) - assert (ctx.width, ctx.height) == (128, 32) - assert ctx.canvas.size == (128, 32) - assert ctx.sport == "baseball" - assert ctx.options == {"accent": True} - assert ctx.layout.bounds.w == 128 - - def test_explicit_size_overrides_display(self): - ctx = skin_runtime.build_context(_make_host(), {}, size=(64, 64)) - assert ctx.canvas.size == (64, 64) - - def test_load_logo_binds_game_and_survives_failure(self): - host = _make_host() - host._load_and_resize_logo.side_effect = RuntimeError("disk gone") - ctx = skin_runtime.build_context( - host, {"home_id": "1", "home_abbr": "LAD", - "home_logo_path": "x.png", "home_logo_url": None}) - assert ctx.load_logo("home") is None # exception swallowed - assert ctx.load_logo("elsewhere") is None # bad side rejected - - def test_draw_helpers_draw_on_canvas(self): - ctx = _make_context() - ctx.draw_text("HI", 2, 2, font=ImageFont.load_default()) - fit = ctx.layout.fit_text("42", ctx.layout.bounds) - ctx.draw_fit(fit, ctx.layout.bounds) - logo = Image.new("RGBA", (16, 16), (255, 0, 0, 255)) - ctx.draw_image(logo, ctx.layout.bounds.left_col(20)) - ctx.draw_image(None, ctx.layout.bounds) # None must no-op - assert ctx.canvas.convert("L").getbbox() is not None - - -class _FallbackProbe: - """Bare-bones SportsCore stand-in that exercises the real _render_game.""" - - def __init__(self, skin): - from src.base_classes.sports import SportsCore - self._cls = SportsCore - self.SKIN_MODE = "live" - self.logger = logging.getLogger("test_skin_system") - self.sport = "baseball" - self.sport_key = "mlb" - self.skin_options = {} - self.fonts = {"time": ImageFont.load_default()} - self._skin = skin - self._skin_load_attempted = True - self._skin_failures = 0 - self._skin_slow_renders = 0 - self._skin_config = "test-skin" - self.display_manager = MagicMock() - self.display_manager.width = 128 - self.display_manager.height = 32 - self.display_manager.image = Image.new("RGB", (128, 32)) - self.builtin_calls = 0 - - def _resolve_skin_id(self): - return "test-skin" - - def _draw_scorebug_layout(self, game, force_clear=False): - self.builtin_calls += 1 - - def _render_game(self, game, force_clear=False): - from src.base_classes.sports import SportsCore - SportsCore._render_game(self, game, force_clear) - - def _get_skin(self): - return self._skin - - -class TestRenderGameFallback: - def test_skin_handles_render(self): - class GoodSkin(ScoreboardSkin): - def render_live(self, ctx, game): - ctx.draw.rectangle([0, 0, 10, 10], fill=(0, 255, 0)) - return True - - probe = _FallbackProbe(GoodSkin({}, {})) - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 0 - probe.display_manager.update_display.assert_called_once() - assert probe.display_manager.image.convert("L").getbbox() is not None - - def test_skin_declining_falls_back(self): - probe = _FallbackProbe(ScoreboardSkin({}, {})) # all renders -> False - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 1 - - def test_no_skin_falls_back(self): - probe = _FallbackProbe(None) - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 1 - - def test_three_strikes_disables_skin(self): - class BrokenSkin(ScoreboardSkin): - calls = 0 - - def render_live(self, ctx, game): - BrokenSkin.calls += 1 - raise ValueError("kaboom") - - probe = _FallbackProbe(BrokenSkin({}, {})) - for i in range(5): - probe._render_game({"status_text": "Q1"}) - # every render fell back to the built-in layout... - assert probe.builtin_calls == 5 - # ...and the skin stopped being called after the 3rd failure - assert BrokenSkin.calls == 3 - assert probe._skin_failures == 3 - - def test_skin_cannot_mutate_callers_game_dict(self): - class MutatingSkin(ScoreboardSkin): - def render_live(self, ctx, game): - game.clear() - game["hacked"] = True - return True - - probe = _FallbackProbe(MutatingSkin({}, {})) - game = {"status_text": "Q1", "home_score": "3"} - probe._render_game(game) - assert game == {"status_text": "Q1", "home_score": "3"} - - -class TestSkinModeResolution: - def _core(self, skin_config, mode="live"): - from src.base_classes.sports import SportsCore - probe = _FallbackProbe(None) - probe.SKIN_MODE = mode - probe._skin_config = skin_config - return SportsCore._resolve_skin_id(probe) - - def test_plain_id_applies_to_all_modes(self): - assert self._core("retro", "live") == "retro" - assert self._core("retro", "recent") == "retro" - - def test_per_mode_mapping(self): - cfg = {"live": "retro", "recent": "built-in"} - assert self._core(cfg, "live") == "retro" - assert self._core(cfg, "recent") is None - assert self._core(cfg, "upcoming") is None - - def test_builtin_and_empty_mean_none(self): - assert self._core("built-in") is None - assert self._core("") is None - assert self._core(None) is None - - -class TestViewModelContract: - @pytest.mark.parametrize("sport", ["baseball", "basketball", "football", "hockey"]) - @pytest.mark.parametrize("mode", ["live", "recent", "upcoming"]) - def test_fixtures_carry_all_guaranteed_keys(self, sport, mode): - with open(FIXTURES_DIR / f"{sport}_{mode}.json") as f: - game = json.load(f) - missing = [k for k in GUARANTEED_KEYS if k not in game] - assert not missing, f"{sport}_{mode} fixture missing {missing}" - - def test_extractor_produces_guaranteed_keys(self): - """The real extractor's output must be a superset of the documented - contract — this is the test that catches accidental renames.""" - import pytz - from src.base_classes.sports import SportsCore - - event = { - "id": "401570001", - "date": "2026-07-16T23:05:00Z", - "competitions": [{ - "status": {"type": {"name": "STATUS_IN_PROGRESS", "state": "in", - "shortDetail": "Bot 7th"}}, - "competitors": [ - {"homeAway": "home", "id": "19", - "team": {"abbreviation": "LAD"}, "score": "5", - "records": [{"summary": "58-33"}]}, - {"homeAway": "away", "id": "26", - "team": {"abbreviation": "SF"}, "score": "3", - "records": [{"summary": "49-42"}]}, - ], - }], - } - probe = MagicMock() - probe.logger = logging.getLogger("test_skin_system") - probe.favorite_teams = [] - probe.config = {} - probe.logo_dir = Path("assets/logos") - probe._get_timezone.return_value = pytz.utc - probe.display_manager.format_date_with_ordinal.return_value = "Jul 16th" - - details, _, _, _, _ = SportsCore._extract_game_details_common(probe, event) - assert details is not None - missing = [k for k in GUARANTEED_KEYS if k not in details] - assert not missing, ( - f"_extract_game_details_common no longer emits {missing}. " - "These keys are part of the frozen skin view-model contract " - "(VIEW_MODEL_VERSION) — renaming or removing them breaks every " - "published skin. Add a compat shim or bump the major version.") - - -class TestPluginMatching: - def test_matches_by_sport_token_and_sport_key(self, tmp_path): - write_skin(tmp_path, "bb-skin") # targets sports=["baseball"] - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "bb-skin" in skin_runtime.skins_for_plugin("baseball-scoreboard", skins) - assert "bb-skin" not in skin_runtime.skins_for_plugin("football-scoreboard", skins) - - def test_matches_by_explicit_plugin_list(self, tmp_path): - skin_dir = write_skin(tmp_path, "exact-skin") - manifest = json.loads((skin_dir / "skin.json").read_text()) - manifest["targets"] = {"plugins": ["my-custom-plugin"]} - (skin_dir / "skin.json").write_text(json.dumps(manifest)) - skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) - assert "exact-skin" in skin_runtime.skins_for_plugin("my-custom-plugin", skins) - assert "exact-skin" not in skin_runtime.skins_for_plugin("baseball-scoreboard", skins) - - -class TestSchemaInjection: - def _manager(self): - from src.plugin_system.schema_manager import SchemaManager - return SchemaManager() - - def test_injects_enum_with_installed_and_configured_skins(self): - sm = self._manager() - schema = {"type": "object", "properties": {}} - out = sm.inject_skin_selector(schema, "baseball-scoreboard", - current_value="gone-skin") - enum = out["properties"]["skin"]["enum"] - assert enum[0] == "built-in" - assert "example-classic-baseball" in enum - # an uninstalled-but-configured skin must stay selectable so the - # saved config never becomes invalid in the UI - assert "gone-skin" in enum - assert "skin" not in schema["properties"] # source schema untouched - - def test_no_matching_skins_leaves_schema_alone(self): - sm = self._manager() - schema = {"type": "object", "properties": {}} - out = sm.inject_skin_selector(schema, "totally-unrelated-plugin") - assert "skin" not in out.get("properties", {}) - - def test_validation_accepts_skin_keys_without_enum(self): - sm = self._manager() - schema = {"type": "object", "properties": {"foo": {"type": "string"}}} - ok, errors = sm.validate_config_against_schema( - {"skin": "any-id-even-uninstalled", "skin_options": {"x": 1}}, - schema, "baseball-scoreboard") - assert ok, errors - ok, errors = sm.validate_config_against_schema( - {"skin": {"live": "a", "recent": "built-in"}}, schema, "p") - assert ok, errors - - -class TestExampleSkin: - @pytest.mark.parametrize("mode", ["live", "recent", "upcoming"]) - @pytest.mark.parametrize("size", [(128, 32), (64, 32), (128, 64)]) - def test_renders_all_modes_and_sizes(self, mode, size): - skin = skin_runtime.load_skin("example-classic-baseball", sport="baseball") - assert skin is not None - host = _make_host() - host._load_and_resize_logo.return_value = Image.new("RGBA", (32, 32), (200, 0, 0, 255)) - with open(FIXTURES_DIR / f"baseball_{mode}.json") as f: - game = json.load(f) - ctx = skin_runtime.build_context(host, game, size=size) - assert getattr(skin, f"render_{mode}")(ctx, game) is True - assert ctx.canvas.convert("L").getbbox() is not None - - -class TestRenderSkinCard: - """render_skin_card (vegas cards) shares _render_game's 3-strike counter. - - Both paths reset the counter on success — transient failures must not - accumulate across a session and disable a working skin. - """ - - def _probe(self, skin): - from src.base_classes.sports import SportsCore - probe = _FallbackProbe(skin) - probe.render_skin_card = ( - lambda game, size: SportsCore.render_skin_card(probe, game, size)) - return probe - - def test_vegas_card_returned_when_skin_provides_one(self): - card_img = Image.new("RGB", (96, 32), (0, 0, 255)) - - class CardSkin(ScoreboardSkin): - def render_vegas_card(self, ctx, game): - return card_img - - probe = self._probe(CardSkin({}, {})) - assert probe.render_skin_card({}, (96, 32)) is card_img - - def test_vegas_card_none_falls_through_to_mode_renderer(self): - class ModeOnlySkin(ScoreboardSkin): - def render_live(self, ctx, game): - ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0)) - return True - - probe = self._probe(ModeOnlySkin({}, {})) - card = probe.render_skin_card({}, (96, 32)) - assert card is not None - assert card.size == (96, 32) - assert card.convert("L").getbbox() is not None - - def test_skin_declining_returns_none(self): - probe = self._probe(ScoreboardSkin({}, {})) # all renders -> False - assert probe.render_skin_card({}, (96, 32)) is None - assert probe._skin_failures == 0 # declining is not a failure - - def test_no_skin_returns_none(self): - probe = self._probe(None) - assert probe.render_skin_card({}, (96, 32)) is None - - def test_card_failures_count_toward_shared_disable(self): - class BrokenCardSkin(ScoreboardSkin): - calls = 0 - - def render_vegas_card(self, ctx, game): - BrokenCardSkin.calls += 1 - raise ValueError("kaboom") - - probe = self._probe(BrokenCardSkin({}, {})) - for _ in range(5): - assert probe.render_skin_card({}, (96, 32)) is None - # Skin stopped being consulted after the 3rd failure... - assert BrokenCardSkin.calls == 3 - assert probe._skin_failures == 3 - # ...and the shared counter also disables _render_game's skin path. - probe._render_game({"status_text": "Q1"}) - assert probe.builtin_calls == 1 - assert BrokenCardSkin.calls == 3 # not consulted again - - def test_card_success_resets_strikes(self): - """A successful card render clears accumulated strikes (mirroring - _render_game) — 2 failures + a success + 1 failure leaves the skin - enabled with a single strike, instead of disabling it.""" - card_img = Image.new("RGB", (96, 32), (0, 0, 255)) - - class FlakyCardSkin(ScoreboardSkin): - fail = True - - def render_vegas_card(self, ctx, game): - if FlakyCardSkin.fail: - raise ValueError("kaboom") - return card_img - - probe = self._probe(FlakyCardSkin({}, {})) - FlakyCardSkin.fail = True - probe.render_skin_card({}, (96, 32)) - probe.render_skin_card({}, (96, 32)) - assert probe._skin_failures == 2 - - FlakyCardSkin.fail = False - assert probe.render_skin_card({}, (96, 32)) is card_img - assert probe._skin_failures == 0 # success cleared the strikes - - FlakyCardSkin.fail = True - probe.render_skin_card({}, (96, 32)) - assert probe._skin_failures == 1 # counting from the reset state - FlakyCardSkin.fail = False - assert probe.render_skin_card({}, (96, 32)) is card_img # still enabled - - def test_card_success_via_mode_renderer_also_resets_strikes(self): - """The fallthrough path (render_vegas_card None -> mode renderer - True) resets the counter as well.""" - class ModeOnlySkin(ScoreboardSkin): - def render_live(self, ctx, game): - ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0)) - return True - - probe = self._probe(ModeOnlySkin({}, {})) - probe._skin_failures = 2 - assert probe.render_skin_card({}, (96, 32)) is not None - assert probe._skin_failures == 0 - - def test_render_game_success_also_resets_strikes(self): - """Same reset contract on the display path, for symmetry.""" - class GoodSkin(ScoreboardSkin): - def render_live(self, ctx, game): - return True - - probe = _FallbackProbe(GoodSkin({}, {})) - probe._skin_failures = 2 - probe._render_game({"status_text": "Q1"}) - assert probe._skin_failures == 0 diff --git a/test/test_sports_base_characterization.py b/test/test_sports_base_characterization.py deleted file mode 100644 index d81919bb..00000000 --- a/test/test_sports_base_characterization.py +++ /dev/null @@ -1,660 +0,0 @@ -"""Characterization tests for src/base_classes/sports.py. - -These tests PIN the current behavior of SportsCore / SportsUpcoming / -SportsRecent / SportsLive ahead of the sports-unification merge (features -from nine drifted plugin copies are about to be folded in). They assert -what the code DOES today, not what it should do — a few pinned behaviors -look like bugs and are flagged inline with "PINNED AS-IS". - -Coverage: -- `_extract_game_details_common` + the four sport extractors - (football/hockey/baseball/basketball) against realistic ESPN scoreboard - events (adapted from the ledmatrix-plugins monorepo test fixtures). - The output must remain a superset of the frozen skin view-model - contract (GUARANTEED_KEYS, imported from test_skin_system). -- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive - subclasses: population, favorite-team filtering, empty/failed-fetch - tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache - and every instance's requests session raises ConnectionError. -- Rendering smoke: one `display()` per mode class at 128x32 draws - non-zero ink onto a real PIL image. -- Guard rails: the skin-system seam methods on SportsCore must survive - the merge. -""" - -import logging -import sys -from datetime import datetime, timezone -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -import pytz -import requests -from freezegun import freeze_time -from PIL import Image - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so these tests can import the sports base classes off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.baseball import Baseball -from src.base_classes.basketball import Basketball -from src.base_classes.football import Football -from src.base_classes.hockey import Hockey, HockeyLive -from src.base_classes.sports import ( - SportsCore, - SportsLive, - SportsRecent, - SportsUpcoming, -) - -# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it. -from test.test_skin_system import GUARANTEED_KEYS - -SPORT_CLASSES = [Football, Hockey, Baseball, Basketball] -SPORT_IDS = ["football", "hockey", "baseball", "basketball"] - -# All update()-flow tests run at this frozen instant so the 21-day -# SportsRecent window and time.time() interval gates are deterministic. -FROZEN_NOW = "2026-01-20 12:00:00" - - -# --------------------------------------------------------------------------- -# ESPN scoreboard event builders (shape adapted from the monorepo fixtures, -# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json: -# team-shaped competitors with status/score/records). -# --------------------------------------------------------------------------- - -def _competitor(abbr, team_id, score, home_away, record="30-10-5"): - return { - "homeAway": home_away, - "id": team_id, - "score": score, - "team": { - "id": team_id, - "abbreviation": abbr, - "name": abbr.title(), - "displayName": abbr.title(), - "logo": None, - }, - "records": [{"summary": record}], - # The hockey extractor reads competitor["statistics"] for shot counts; - # it now defaults to an empty list when the key is absent rather than - # dropping the whole event (see - # test_hockey_event_without_statistics_still_extracts). - "statistics": [], - } - - -def make_event(event_id, state, date, home=("TB", "20", "3"), - away=("DAL", "9", "2"), period=2, clock="12:45", - name=None, short_detail=None, situation=None, - home_record="30-10-5", away_record="25-14-6"): - """Build a realistic ESPN scoreboard event in the given state - ('in' / 'post' / 'pre').""" - defaults = { - "in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"), - "post": ("STATUS_FINAL", "Final"), - "pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"), - } - default_name, default_detail = defaults[state] - status = { - "clock": 0.0, - "displayClock": clock, - "period": period, - "type": { - "id": "2", - "name": name or default_name, - "state": state, - "completed": state == "post", - "description": short_detail or default_detail, - "detail": short_detail or default_detail, - "shortDetail": short_detail or default_detail, - }, - } - competition = { - "id": event_id, - "date": date, - "status": status, - "competitors": [ - _competitor(home[0], home[1], home[2], "home", home_record), - _competitor(away[0], away[1], away[2], "away", away_record), - ], - } - if situation is not None: - competition["situation"] = situation - return { - "id": event_id, - "date": date, - "name": f"{away[0]} at {home[0]}", - "shortName": f"{away[0]} @ {home[0]}", - "competitions": [competition], - # Real ESPN payloads duplicate status at the event top level; the - # baseball extractor reads it there for live innings. - "status": status, - } - - -def make_probe(favorites=None): - """Bare-bones SportsCore stand-in for exercising the real extractors - unbound (same pattern as TestViewModelContract in test_skin_system).""" - probe = MagicMock() - probe.logger = logging.getLogger("test_sports_base_characterization") - probe.favorite_teams = list(favorites or []) - probe.config = {} - probe.logo_dir = Path("assets/logos") - probe._get_timezone.return_value = pytz.utc - probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th" - # The sport extractors call self._extract_game_details_common — route - # it to the real implementation instead of a MagicMock. - probe._extract_game_details_common = ( - lambda event: SportsCore._extract_game_details_common(probe, event)) - return probe - - -def extract(sport_cls, event, favorites=None): - return sport_cls._extract_game_details(make_probe(favorites), event) - - -# --------------------------------------------------------------------------- -# 1. _extract_game_details_common contract, per wired sport -# --------------------------------------------------------------------------- - -class TestExtractGameDetailsContract: - @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) - def test_live_event_guaranteed_keys_and_values(self, sport_cls): - event = make_event("401", "in", "2026-01-15T18:30:00Z") - details = extract(sport_cls, event) - assert details is not None - missing = [k for k in GUARANTEED_KEYS if k not in details] - assert not missing, ( - f"{sport_cls.__name__} extractor no longer emits {missing} — " - "these keys are the frozen skin view-model contract.") - assert details["id"] == "401" - assert details["home_abbr"] == "TB" - assert details["away_abbr"] == "DAL" - assert details["home_id"] == "20" - assert details["away_id"] == "9" - assert details["home_score"] == "3" - assert details["away_score"] == "2" - assert details["home_record"] == "30-10-5" - assert details["away_record"] == "25-14-6" - assert details["is_live"] is True - assert details["is_final"] is False - assert details["is_upcoming"] is False - assert details["status_text"] == "P2 12:45" - assert details["start_time_utc"] == datetime( - 2026, 1, 15, 18, 30, tzinfo=timezone.utc) - # Sport-specific formatting of the same event: - if sport_cls in (Football, Basketball): - assert details["period_text"] == "Q2" - assert details["clock"] == "12:45" - elif sport_cls is Hockey: - assert details["period_text"] == "P2" - assert details["clock"] == "12:45" - else: # Baseball keys inning/status instead of period_text - assert details["inning"] == 2 - assert details["status_state"] == "in" - - @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) - def test_final_event_classification(self, sport_cls): - event = make_event("402", "post", "2026-01-14T00:00:00Z", - home=("BOS", "1", "4"), away=("TOR", "21", "2"), - period=3, clock="0:00") - details = extract(sport_cls, event) - assert details is not None - assert details["is_final"] is True - assert details["is_live"] is False - assert details["is_upcoming"] is False - assert details["home_score"] == "4" - assert details["away_score"] == "2" - if sport_cls in (Football, Hockey, Basketball): - assert details["period_text"] == "Final" - - @pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS) - def test_upcoming_event_classification(self, sport_cls): - event = make_event("403", "pre", "2026-01-15T18:30:00Z", - home=("NYR", "13", "0"), away=("PIT", "16", "0"), - period=0, clock="0:00") - details = extract(sport_cls, event) - assert details is not None - assert details["is_upcoming"] is True - assert details["is_live"] is False - assert details["is_final"] is False - # Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM, - # date rendered through display_manager.format_date_with_ordinal. - assert details["game_time"] == "6:30PM" - assert details["game_date"] == "Jan 15th" - - def test_halftime_state_flags(self): - # is_halftime keys off name STATUS_HALFTIME (or state "halftime") - # while state "in" still counts as live. - event = make_event("404", "in", "2026-01-15T18:30:00Z", - name="STATUS_HALFTIME", short_detail="Halftime") - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["is_live"] is True - assert details["is_halftime"] is True - - def test_state_name_conflict_is_both_final_and_upcoming(self): - # PINNED AS-IS (looks like a bug): is_upcoming also matches on - # status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so - # an event with state="post" but name="Scheduled" reports BOTH - # is_final and is_upcoming True. - event = make_event("405", "post", "2026-01-14T00:00:00Z", - name="Scheduled") - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["is_final"] is True - assert details["is_upcoming"] is True - - def test_zero_zero_record_blanked(self): - event = make_event("406", "pre", "2026-01-15T18:30:00Z", - home_record="0-0", away_record="0-0-0") - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["home_record"] == "" - assert details["away_record"] == "" - - def test_missing_abbreviation_uses_name_prefix(self): - event = make_event("407", "pre", "2026-01-15T18:30:00Z") - for comp in event["competitions"][0]["competitors"]: - del comp["team"]["abbreviation"] - comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \ - else "Penguins" - details, *_ = SportsCore._extract_game_details_common( - make_probe(), event) - assert details["home_abbr"] == "Sha" - assert details["away_abbr"] == "Pen" - - def test_empty_or_malformed_event_returns_none_tuple(self): - probe = make_probe() - assert SportsCore._extract_game_details_common(probe, {}) == \ - (None, None, None, None, None) - assert SportsCore._extract_game_details_common(probe, None) == \ - (None, None, None, None, None) - # Malformed event (no competitions) is swallowed, not raised. - assert SportsCore._extract_game_details_common( - probe, {"id": "999"}) == (None, None, None, None, None) - - def test_football_live_situation_fields(self): - event = make_event( - "408", "in", "2026-01-15T18:30:00Z", - situation={ - "shortDownDistanceText": "3rd & 4", - "downDistanceText": "3rd & 4 at TB 30", - "isRedZone": False, - "possession": "20", - "homeTimeouts": 2, - "awayTimeouts": 3, - }) - details = extract(Football, event) - assert details["down_distance_text"] == "3rd & 4" - assert details["down_distance_text_long"] == "3rd & 4 at TB 30" - assert details["possession"] == "20" - assert details["possession_indicator"] == "home" # matches home id - assert details["home_timeouts"] == 2 - assert details["away_timeouts"] == 3 - - def test_hockey_live_power_play_and_default_shots(self): - event = make_event("409", "in", "2026-01-15T18:30:00Z", - situation={"isPowerPlay": True, "penalties": ""}) - details = extract(Hockey, event) - assert details["power_play"] is True - # Empty statistics arrays -> save-percentage math yields 0 shots. - assert details["home_shots"] == 0 - assert details["away_shots"] == 0 - - def test_hockey_event_without_statistics_still_extracts(self): - # FIXED (was pinned as returning None): the hockey extractor used to - # iterate competitor["statistics"] unguarded, so a competitor without - # the key raised KeyError internally and the WHOLE event was dropped - # despite valid scores and status. It now defaults to an empty list, - # matching the behaviour already shipped in the hockey plugin, so the - # event survives with zeroed shot counts -- the same values - # test_hockey_live_power_play_and_default_shots already expects for an - # EMPTY statistics array. - event = make_event("410", "in", "2026-01-15T18:30:00Z") - for comp in event["competitions"][0]["competitors"]: - del comp["statistics"] - details = extract(Hockey, event) - assert details is not None - assert details["home_abbr"] == "TB" - assert details["away_abbr"] == "DAL" - assert details["home_score"] == "3" - assert details["home_shots"] == 0 - assert details["away_shots"] == 0 - - def test_baseball_live_inning_and_count(self): - event = make_event( - "411", "in", "2026-07-16T23:05:00Z", - home=("LAD", "19", "5"), away=("SF", "26", "3"), - period=7, short_detail="Bot 7th", - situation={ - "count": {"balls": 2, "strikes": 1}, - "outs": 2, - "onFirst": True, - "onSecond": False, - "onThird": True, - }) - details = extract(Baseball, event) - assert details["inning"] == 7 # from top-level status period - assert details["inning_half"] == "bottom" - assert details["balls"] == 2 - assert details["strikes"] == 1 - assert details["outs"] == 2 - assert details["bases_occupied"] == [True, False, True] - assert details["status"] == "status_in_progress" - assert details["series_summary"] == "" - - def test_baseball_live_without_top_level_status_still_extracts(self): - # FIXED (was pinned as returning None): the baseball extractor read - # game_event["status"] -- the event TOP-LEVEL status -- for the - # inning, so an otherwise-valid live event lacking that duplicate key - # was dropped entirely. Real ESPN events carry status in both places, - # but MiLB events (synthesized from the MLB Stats API into an - # ESPN-like shape) populate only the competition-level one. It now - # reads the competition-level `status` that - # _extract_game_details_common has already validated, so it can never - # be missing at that point. - event = make_event("412", "in", "2026-07-16T23:05:00Z", period=7) - del event["status"] - details = extract(Baseball, event) - assert details is not None - assert details["inning"] == 7 - - def test_baseball_live_without_top_level_status_extracts_for_favorites(self): - # The favourite-team branch logs the status payload for diagnostics and - # read the same event top-level key the test above proves can be - # absent. So the identical MiLB event that extracts fine for a - # non-favourite raised KeyError and was dropped once the team WAS a - # favourite -- the worst shape for the bug, since it only hit the games - # the user cared most about, and only on the diagnostic path that was - # supposed to help debug them. - event = make_event("413", "in", "2026-07-16T23:05:00Z", period=7) - del event["status"] - details = extract(Baseball, event, favorites=["TB"]) - assert details is not None - assert details["inning"] == 7 - - -# --------------------------------------------------------------------------- -# 2. update() flow on concrete subclasses (offline, cache-fed) -# --------------------------------------------------------------------------- - -class _UpcomingHarness(Hockey, SportsUpcoming): - """Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data.""" - - def _fetch_data(self): - return self.cache_manager.get(f"{self.sport_key}_schedule") - - -class _RecentHarness(Hockey, SportsRecent): - def _fetch_data(self): - return self.cache_manager.get(f"{self.sport_key}_schedule") - - -class _LiveHarness(HockeyLive): - def _fetch_data(self): - return self.cache_manager.get(f"{self.sport_key}_schedule") - - -def make_schedule(): - """A mixed schedule around the frozen 'now' of 2026-01-20.""" - return {"events": [ - # Final 6 days ago — inside the recent 21-day window. - make_event("9001", "post", "2026-01-14T00:00:00Z", - home=("BOS", "1", "4"), away=("TOR", "21", "2"), - period=3, clock="0:00"), - # Live game. - make_event("9002", "in", "2026-01-15T00:30:00Z", - home=("TB", "20", "3"), away=("DAL", "9", "2")), - # Two scheduled games. - make_event("9003", "pre", "2026-01-16T00:00:00Z", - home=("NYR", "13", "0"), away=("PIT", "16", "0"), - period=0), - make_event("9004", "pre", "2026-01-17T00:00:00Z", - home=("BOS", "1", "0"), away=("MTL", "10", "0"), - period=0), - # Final from November — outside the recent 21-day window. - make_event("9005", "post", "2025-11-01T00:00:00Z", - home=("SEA", "124292", "1"), away=("VAN", "22", "5"), - period=3, clock="0:00"), - ]} - - -@pytest.fixture -def build_manager(monkeypatch, tmp_path): - """Factory for concrete sports managers: mocked display/cache managers, - logo dir redirected to tmp, background service stubbed, and the - requests session rigged to prove nothing hits the network.""" - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - - # Rig requests.Session BEFORE any manager is built. Construction creates - # both SportsCore.session and the ESPNDataSource.session; replacing only - # manager.session after the fact (below) leaves data_source.session real, - # so an accidental fetch during or right after construction could reach - # the network. Patching the class makes every session created here raise. - def _offline_get(*args, **kwargs): - raise requests.exceptions.ConnectionError( - "characterization tests are offline") - - monkeypatch.setattr(requests.Session, "get", _offline_get) - - def build(cls, schedule, **mode_cfg): - config = { - "timezone": "UTC", - "display": {}, - "nhl_scoreboard": {"enabled": True, **mode_cfg}, - } - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - display_manager.format_date_with_ordinal.side_effect = ( - lambda dt: dt.strftime("%b %d")) - cache_manager = MagicMock() - cache_manager.get.return_value = schedule - cache_manager.cache_dir = str(tmp_path) - manager = cls(config, display_manager, cache_manager, - logging.getLogger("test_sports_base_characterization"), - "nhl") - # Safety net: any accidental network fetch must fail loudly. - manager.session = MagicMock() - manager.session.get.side_effect = requests.exceptions.ConnectionError( - "characterization tests are offline") - return manager - - return build - - -def _ids(games): - return [g["id"] for g in games] - - -@freeze_time(FROZEN_NOW) -class TestUpcomingUpdateFlow: - def test_populates_games_list_sorted_by_start_time(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule()) - manager.update() - # PINNED AS-IS: SportsUpcoming filters purely on is_upcoming - # (state 'pre') — there is NO date filter, so 'pre' games whose - # start time is already in the past (9003/9004 vs frozen 1/20) - # are still shown. - assert _ids(manager.games_list) == ["9003", "9004"] - assert manager.current_game["id"] == "9003" - - def test_filters_by_favorite_teams(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=["BOS"]) - manager.update() - assert _ids(manager.games_list) == ["9004"] - assert manager.current_game["id"] == "9004" - - def test_favorites_only_with_no_favorites_shows_nothing( - self, build_manager): - # PINNED AS-IS: show_favorite_teams_only=True with an empty - # favorite_teams list drops every game rather than falling back - # to showing all games. - manager = build_manager(_UpcomingHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=[]) - manager.update() - assert manager.games_list == [] - assert manager.current_game is None - - def test_caps_at_upcoming_games_to_show(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule(), - upcoming_games_to_show=1) - manager.update() - assert _ids(manager.games_list) == ["9003"] - - def test_tolerates_empty_events_list(self, build_manager): - manager = build_manager(_UpcomingHarness, {"events": []}) - manager.update() # must not raise - assert manager.games_list == [] - assert manager.current_game is None - - def test_tolerates_fetch_returning_none(self, build_manager): - manager = build_manager(_UpcomingHarness, None) - manager.update() # must not raise - assert manager.games_list == [] - assert manager.current_game is None - - def test_disabled_manager_update_is_noop(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule(), - enabled=False) - manager.update() - assert manager.games_list == [] - manager.cache_manager.get.assert_not_called() - - -@freeze_time(FROZEN_NOW) -class TestRecentUpdateFlow: - def test_populates_only_finals_within_21_day_window(self, build_manager): - manager = build_manager(_RecentHarness, make_schedule()) - manager.update() - # 9001 (final, 6 days old) kept; 9005 (final, ~80 days old) - # excluded by the 21-day cutoff; live/pre games excluded. - assert _ids(manager.games_list) == ["9001"] - assert manager.current_game["id"] == "9001" - assert manager.current_game["is_final"] is True - - def test_filters_by_favorite_teams(self, build_manager): - manager = build_manager(_RecentHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=["TOR"]) - manager.update() - assert _ids(manager.games_list) == ["9001"] - - stranger = build_manager(_RecentHarness, make_schedule(), - show_favorite_teams_only=True, - favorite_teams=["XXX"]) - stranger.update() - assert stranger.games_list == [] - assert stranger.current_game is None - - def test_tolerates_empty_events_list(self, build_manager): - manager = build_manager(_RecentHarness, {"events": []}) - manager.update() # must not raise - assert manager.games_list == [] - assert manager.current_game is None - - -@freeze_time(FROZEN_NOW) -class TestLiveUpdateFlow: - def test_selects_only_live_games(self, build_manager): - manager = build_manager(_LiveHarness, make_schedule()) - manager.update() - assert _ids(manager.live_games) == ["9002"] - assert manager.current_game["id"] == "9002" - assert manager.current_game["is_live"] is True - - def test_no_live_games_clears_current_game(self, build_manager): - schedule = {"events": [ - make_event("9001", "post", "2026-01-14T00:00:00Z"), - make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0), - ]} - manager = build_manager(_LiveHarness, schedule) - manager.update() - assert manager.live_games == [] - assert manager.current_game is None - - -# --------------------------------------------------------------------------- -# 3. Rendering smoke — one display() per mode class at 128x32 -# --------------------------------------------------------------------------- - -def _fake_logo(*args, **kwargs): - return Image.new("RGBA", (24, 24), (180, 30, 30, 255)) - - -@freeze_time(FROZEN_NOW) -class TestRenderingSmoke: - def _assert_rendered(self, manager): - manager.display_manager.update_display.assert_called() - assert manager.display_manager.image.convert("L").getbbox() is not None - - def test_upcoming_display_draws_ink(self, build_manager): - manager = build_manager(_UpcomingHarness, make_schedule()) - manager.update() - manager._load_and_resize_logo = _fake_logo - assert manager.display(force_clear=True) is True - self._assert_rendered(manager) - - def test_recent_display_draws_ink(self, build_manager): - manager = build_manager(_RecentHarness, make_schedule()) - manager.update() - manager._load_and_resize_logo = _fake_logo - assert manager.display(force_clear=True) is True - self._assert_rendered(manager) - - def test_live_display_draws_ink(self, build_manager): - manager = build_manager(_LiveHarness, make_schedule()) - manager.update() - manager._load_and_resize_logo = _fake_logo - assert manager.display(force_clear=True) is True - self._assert_rendered(manager) - - def test_draw_scorebug_layout_direct_call_does_not_raise( - self, build_manager): - # The base-class placeholder renderer must also stay callable. - manager = build_manager(_UpcomingHarness, make_schedule()) - game = manager._extract_game_details(make_schedule()["events"][2]) - manager._load_and_resize_logo = _fake_logo - SportsCore._draw_scorebug_layout(manager, game) - assert manager.display_manager.image.convert("L").getbbox() is not None - - -# --------------------------------------------------------------------------- -# 4. Guard rails — seams the merge must not silently drop -# --------------------------------------------------------------------------- - -class TestGuardRails: - def test_skin_seam_methods_survive(self): - for name in ("_resolve_skin_id", "_get_skin", "_render_game", - "render_skin_card"): - assert callable(getattr(SportsCore, name, None)), ( - f"SportsCore.{name} is part of the skin-system seam " - "(src/skin_system) — the sports-unification merge must " - "keep it.") - - def test_skin_mode_per_class(self): - assert SportsCore.SKIN_MODE == "live" - assert SportsUpcoming.SKIN_MODE == "upcoming" - assert SportsRecent.SKIN_MODE == "recent" - assert SportsLive.SKIN_MODE == "live" # inherits the default - - def test_core_display_and_extractor_seams_survive(self): - for name in ("display", "_draw_scorebug_layout", - "_extract_game_details_common", "update"): - owner = SportsCore if name != "update" else SportsUpcoming - assert callable(getattr(owner, name, None)), name diff --git a/test/test_sports_capabilities.py b/test/test_sports_capabilities.py deleted file mode 100644 index a3cd0f3f..00000000 --- a/test/test_sports_capabilities.py +++ /dev/null @@ -1,1077 +0,0 @@ -"""Tests for the opt-in sports capabilities (phase B2). - -Two properties matter beyond "the code works": - -1. **Opting out is structural.** A mode class that does not mix in - ``CelebrationMixin`` must have none of its attributes or methods — not - merely a disabled flag. ``TestOptOutIsStructural`` asserts that directly, - because it is the property the whole mixin design exists to buy. - -2. **The promoted behavior matches the plugin copies.** These bodies came from - afl/soccer/nrl (goal dialect) and football (score dialect); the tests pin - the reconciled behavior of both, including the three seams where the - lineages genuinely disagreed. - -See docs/SPORTS_UNIFICATION.md. -""" - -import sys -import time -from unittest.mock import MagicMock - -import pytest - -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.sports.capabilities import ( # noqa: E402 - CelebrationMixin, - RotationStrategy, - SimpleRotation, - SmoothWeightedRotation, - WeightedCycleRotation, - get_rotation_strategy, - register_rotation_strategy, -) - - -def game(gid, home="HOM", away="AWY", home_score=0, away_score=0, **extra): - g = { - "id": gid, - "home_abbr": home, - "away_abbr": away, - "home_id": f"{gid}-h", - "away_id": f"{gid}-a", - "home_score": home_score, - "away_score": away_score, - } - g.update(extra) - return g - - -# --------------------------------------------------------------------------- -# Rotation strategies -# --------------------------------------------------------------------------- - -def boost(favorites, factor=3): - """A weight_for callable of the shape the plugins supply.""" - return lambda g: factor if g.get("home_abbr") in favorites else 1 - - -class TestRegistry: - @pytest.mark.parametrize("name,cls", [ - ("simple", SimpleRotation), - ("weighted", WeightedCycleRotation), - ("swrr", SmoothWeightedRotation), - ]) - def test_builtin_names_resolve(self, name, cls): - assert isinstance(get_rotation_strategy(name), cls) - - def test_unknown_name_falls_back_to_simple(self): - """The name comes from user config; a typo should cost the boost, not - the scoreboard.""" - assert isinstance(get_rotation_strategy("typo"), SimpleRotation) - - def test_a_plugin_can_register_its_own(self): - class MyRotation(SimpleRotation): - pass - - register_rotation_strategy("test-only", MyRotation) - try: - assert isinstance(get_rotation_strategy("test-only"), MyRotation) - assert MyRotation.name == "test-only" - finally: - from src.base_classes.sports.capabilities import rotation - rotation._REGISTRY.pop("test-only", None) - - def test_empty_name_is_rejected(self): - with pytest.raises(ValueError): - register_rotation_strategy("", SimpleRotation) - - @pytest.mark.parametrize("bad", [ - SimpleRotation(), # an instance, not the class - str, # unrelated class - lambda **kw: None, # a factory function - ]) - def test_a_non_strategy_factory_is_rejected(self, bad): - """Fail at registration, not several frames later inside schedule(), - where the cause is no longer on the stack.""" - with pytest.raises(TypeError): - register_rotation_strategy("bad-factory", bad) - - def test_weight_for_is_optional(self): - """Default weights are equal, so every strategy degenerates to a plain - round robin — the pre-boost behavior.""" - games = [game("a"), game("b"), game("c")] - for name in ("simple", "weighted", "swrr"): - assert get_rotation_strategy(name).schedule(games) == ["a", "b", "c"] - - -class TestWeights: - def test_games_without_an_id_are_skipped(self): - strategy = get_rotation_strategy("weighted") - assert strategy.weights([game("a"), {"home_abbr": "X"}]) == {"a": 1} - - @pytest.mark.parametrize("bad", [0, -5]) - def test_non_positive_weights_are_clamped_to_one(self, bad): - """A zero weight would starve the game out of the rotation entirely and - collapse total_weight — no caller means that.""" - strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad) - assert strategy.weights([game("a")]) == {"a": 1} - - @pytest.mark.parametrize("bad", [None, "three", object()]) - def test_unusable_weights_fall_back_to_one(self, bad): - strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad) - assert strategy.weights([game("a")]) == {"a": 1} - - def test_huge_weights_are_clamped(self): - """A cycle is sum(weights) long and each step scans every game, so an - unbounded weight from a misread config spins the display thread.""" - strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000) - assert strategy.weights([game("a")]) == {"a": RotationStrategy.MAX_WEIGHT} - - def test_a_clamped_cycle_stays_bounded(self): - strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000) - order = strategy.schedule([game("a"), game("b")]) - assert len(order) == 2 * RotationStrategy.MAX_WEIGHT - - -class TestSimpleRotation: - def test_one_pass_in_feed_order(self): - games = [game("a"), game("b"), game("c")] - assert SimpleRotation().schedule(games) == ["a", "b", "c"] - - def test_weights_are_ignored(self): - games = [game("a", home="FAV"), game("b")] - strategy = SimpleRotation(weight_for=boost({"FAV"}, 5)) - assert strategy.schedule(games) == ["a", "b"] - - def test_empty(self): - assert SimpleRotation().schedule([]) == [] - assert SimpleRotation().next_game([]) is None - - -class TestWeightedCycleRotation: - def test_no_boost_is_a_single_pass(self): - games = [game("a"), game("b"), game("c")] - strategy = WeightedCycleRotation(weight_for=boost({"NONE"})) - assert strategy.schedule(games) == ["a", "b", "c"] - - def test_favorite_gets_boost_many_slots(self): - games = [game("a", home="FAV"), game("b")] - order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games) - assert len(order) == 4 - assert order.count("a") == 3 - assert order.count("b") == 1 - - def test_repeats_are_spaced_not_clumped(self): - """The point of SWRR over naive repetition: 'aaab' is what we must NOT - produce.""" - games = [game("a", home="FAV"), game("b")] - order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games) - assert order != ["a", "a", "a", "b"] - assert order[0] == "a", "highest weight is scheduled first" - - def test_is_stateless_across_calls(self): - games = [game("a", home="FAV"), game("b")] - strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)) - assert strategy.schedule(games) == strategy.schedule(games) - - def test_next_game_returns_the_first_of_the_cycle(self): - games = [game("a"), game("b", home="FAV")] - strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 4)) - assert strategy.next_game(games)["id"] == "b" - - def test_empty(self): - assert WeightedCycleRotation().schedule([]) == [] - - -class TestSmoothWeightedRotation: - def test_no_boost_is_plain_round_robin(self): - games = [game("a"), game("b"), game("c")] - strategy = SmoothWeightedRotation() - assert [strategy.next_game(games)["id"] for _ in range(6)] == [ - "a", "b", "c", "a", "b", "c"] - - def test_favorite_wins_the_share_over_a_long_run(self): - games = [game("a", home="FAV"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3)) - picks = [strategy.next_game(games)["id"] for _ in range(40)] - assert picks.count("a") == 30 - assert picks.count("b") == 10 - - def test_no_clustering_seam_across_cycle_boundaries(self): - """The property that motivates keeping this strategy separate from the - precomputed one: state persists, so there is no restart every N picks - and therefore no place where repeats bunch up.""" - games = [game("a", home="FAV"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3)) - picks = [strategy.next_game(games)["id"] for _ in range(40)] - assert "aaaa" not in "".join(picks) - - def test_a_new_favorite_is_queued_first(self): - """A favorite's game that has just gone live starts at weight 0, gets - its full weight on the next call, and so wins the first pick after it - appears — without a special-cased branch.""" - games = [game("a"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 5)) - for _ in range(3): - strategy.next_game(games) - games.append(game("c", home="FAV")) - assert strategy.next_game(games)["id"] == "c" - - def test_state_for_games_no_longer_live_is_dropped(self): - games = [game("a"), game("b")] - strategy = SmoothWeightedRotation() - strategy.next_game(games) - strategy.next_game([game("a")]) - assert set(strategy._current) == {"a"} - - def test_reset_clears_state(self): - games = [game("a"), game("b")] - strategy = SmoothWeightedRotation() - strategy.next_game(games) - strategy.reset() - assert strategy._current == {} - assert strategy.next_game(games)["id"] == "a" - - def test_schedule_previews_without_perturbing_state(self): - games = [game("a", home="FAV"), game("b")] - strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3)) - preview = strategy.schedule(games) - actual = [strategy.next_game(games)["id"] for _ in range(len(preview))] - assert preview == actual - - def test_preview_uses_the_subclass_ordering(self): - """schedule() promises the order repeated next_game calls produce. Built - from the base class, a subclass that overrides next_game gets a preview - of the wrong algorithm.""" - class Reversed(SmoothWeightedRotation): - def next_game(self, games): - return super().next_game(list(reversed(games))) - - games = [game("a"), game("b"), game("c")] - strategy = Reversed() - preview = strategy.schedule(games) - actual = [strategy.next_game(games)["id"] for _ in range(len(preview))] - assert preview == actual - - def test_empty(self): - assert SmoothWeightedRotation().next_game([]) is None - assert SmoothWeightedRotation().schedule([]) == [] - - def test_games_without_ids_are_ignored(self): - assert SmoothWeightedRotation().next_game([{"home_abbr": "X"}]) is None - - -class TestStrategiesAgreeWithinACycle: - """The survey's core finding: the 'three dialects' are one algorithm. They - must produce the same order within a cycle; they differ only at the - boundary, which is why both shapes survive.""" - - @pytest.mark.parametrize("factor", [2, 3, 5]) - def test_first_cycle_matches(self, factor): - games = [game("a", home="FAV"), game("b"), game("c")] - weight_for = boost({"FAV"}, factor) - assert (SmoothWeightedRotation(weight_for=weight_for).schedule(games) - == WeightedCycleRotation(weight_for=weight_for).schedule(games)) - - -# --------------------------------------------------------------------------- -# Differential: core strategies vs. the plugin implementations they replace -# --------------------------------------------------------------------------- - -BOOST = 3 - - -def _is_fav(g): - return g.get("home_abbr") == "FAV" - - -def _weight_for(g): - return BOOST if _is_fav(g) else 1 - - -class _PluginSwrr: - """afl / nrl / soccer ``_swrr_advance``, transcribed verbatim.""" - - favorite_live_boost = BOOST - - def _is_favorite_game(self, g): - return _is_fav(g) - - def advance(self, games): - if not games: - return None - weights = {} - for g in games: - gid = g.get("id") - if gid is None: - continue - weights[gid] = self.favorite_live_boost if self._is_favorite_game(g) else 1 - if not weights: - return None - if not hasattr(self, "_swrr_weights"): - self._swrr_weights = {} - self._swrr_weights = { - gid: w for gid, w in self._swrr_weights.items() if gid in weights} - for gid, w in weights.items(): - self._swrr_weights[gid] = self._swrr_weights.get(gid, 0) + w - total_weight = sum(weights.values()) - ids_in_order = [g.get("id") for g in games if g.get("id") in weights] - best_gid = max(ids_in_order, key=lambda gid: self._swrr_weights[gid]) - self._swrr_weights[best_gid] -= total_weight - return next(g for g in games if g.get("id") == best_gid) - - -def _plugin_weighted_schedule(games): - """football / baseball / basketball ``_build_weighted_schedule``, verbatim.""" - if not games: - return [] - weights = {g["id"]: (BOOST if _is_fav(g) else 1) for g in games} - total_weight = sum(weights.values()) - if total_weight <= len(games): - return [g["id"] for g in games] - current_weight = {gid: 0 for gid in weights} - schedule = [] - for _ in range(total_weight): - for gid in weights: - current_weight[gid] += weights[gid] - picked = max(current_weight, key=lambda gid: current_weight[gid]) - current_weight[picked] -= total_weight - schedule.append(picked) - return schedule - - -def _plugin_rotation_schedule(games): - """hockey ``_build_rotation_schedule``, transcribed verbatim.""" - weights = [(g["id"], BOOST if _is_fav(g) else 1) for g in games] - total_weight = sum(w for _, w in weights) - if not weights or total_weight <= 0: - return [g["id"] for g in games] - current_weights = {gid: 0 for gid, _ in weights} - schedule = [] - for _ in range(total_weight): - best_id, best_current = None, None - for gid, w in weights: - current_weights[gid] += w - if best_current is None or current_weights[gid] > best_current: - best_id, best_current = gid, current_weights[gid] - current_weights[best_id] -= total_weight - schedule.append(best_id) - return schedule - - -def _cases(): - """Every live-game shape up to 4 games: each either a favorite or not. - - Exhaustive rather than random so the gate is deterministic — a rotation - regression must fail the same way on every run. - """ - import itertools - for size in range(1, 5): - for flags in itertools.product(("FAV", "OTH"), repeat=size): - yield [game(f"g{i}", home=abbr) for i, abbr in enumerate(flags)] - - -class TestMatchesThePluginImplementations: - """The promotion is only safe if these reproduce the plugin copies exactly. - - B5 deletes the bundled copies on the strength of this: each core strategy is - checked against the verbatim source it replaces, over every live-game shape - up to four games. - """ - - @pytest.mark.parametrize("games", list(_cases())) - def test_swrr_matches_the_incremental_plugin_picker(self, games): - plugin = _PluginSwrr() - core = SmoothWeightedRotation(weight_for=_weight_for) - # 60 picks: long enough to cross many cycle boundaries, where a - # state-handling divergence would show up. - assert ([plugin.advance(games)["id"] for _ in range(60)] - == [core.next_game(games)["id"] for _ in range(60)]) - - @pytest.mark.parametrize("games", list(_cases())) - def test_weighted_matches_the_football_lineage(self, games): - assert (_plugin_weighted_schedule(games) - == WeightedCycleRotation(weight_for=_weight_for).schedule(games)) - - @pytest.mark.parametrize("games", list(_cases())) - def test_weighted_matches_hockeys_loop_shape(self, games): - assert (_plugin_rotation_schedule(games) - == WeightedCycleRotation(weight_for=_weight_for).schedule(games)) - - -# --------------------------------------------------------------------------- -# Celebrations -# --------------------------------------------------------------------------- - -class _FakeLive: - """Stand-in for SportsLive: just the surface the mixin touches.""" - - def __init__(self, mode_config=None, favorite_teams=None): - self.mode_config = mode_config or {} - self.favorite_teams = favorite_teams or [] - self.logger = MagicMock() - self.display_manager = MagicMock() - self.is_enabled = True - self.current_game = None - self.last_game_switch = 0 - self.display_calls = [] - - def _favorite_key(self, game, side): - return game.get(f"{side}_abbr") - - def display(self, force_clear=False): - self.display_calls.append(force_clear) - return True - - -class _Celebrating(CelebrationMixin, _FakeLive): - pass - - -class _Coalescing(CelebrationMixin, _FakeLive): - COALESCE_SCORING_SEQUENCE = True - - def score_phrase(self, points, team_abbr): - return "TOUCHDOWN!" if points >= 6 else f"{team_abbr} FIELD GOAL!" - - -class _ById(CelebrationMixin, _FakeLive): - """The nrl shape: ambiguous abbreviations, so favorites match on team id.""" - - def _favorite_key(self, game, side): - return game.get(f"{side}_id") - - -@pytest.fixture -def celebrating(): - def _build(cls=_Celebrating, mode_config=None, favorites=None): - return cls(mode_config=mode_config, favorite_teams=favorites) - return _build - - -class TestOptOutIsStructural: - """The property the mixin design exists to buy: a class that does not opt in - has none of this code — not a disabled flag, not an unused attribute.""" - - def test_a_non_celebrating_class_has_no_celebration_surface(self): - plain = _FakeLive() - for attribute in ("active_celebration", "_score_baselines", - "celebration_enabled", "celebration_duration", - "_check_for_score", "_check_for_win", - "has_active_celebration", "_draw_celebration_layout"): - assert not hasattr(plain, attribute), ( - f"{attribute} leaked onto a class that never opted in") - - def test_the_mixin_is_absent_from_a_non_celebrating_mro(self): - assert CelebrationMixin not in _FakeLive.__mro__ - assert CelebrationMixin in _Celebrating.__mro__ - - def test_mixin_does_not_require_the_base_to_know_about_it(self): - """SportsLive must carry no celebration hooks — that would be the - god-class shape the mixin replaces.""" - from src.base_classes.sports import SportsLive - source = __import__("inspect").getsource(SportsLive) - assert "celebration" not in source.lower() - - -class TestCelebrationConfig: - def test_defaults(self, celebrating): - manager = celebrating() - assert manager.celebration_enabled is True - assert manager.celebration_duration == 8 - assert manager.celebrate_opponent_scores is False - assert manager.active_celebration is None - - def test_reads_the_goal_spelling_of_the_opponent_key(self, celebrating): - """The soccer lineage's published schema says `celebrate_opponent_goals`; - adopting the mixin must not silently reset users' setting.""" - manager = celebrating(mode_config={"celebrate_opponent_goals": True}) - assert manager.celebrate_opponent_scores is True - - def test_reads_the_score_spelling_of_the_opponent_key(self, celebrating): - manager = celebrating(mode_config={"celebrate_opponent_scores": True}) - assert manager.celebrate_opponent_scores is True - - def test_score_spelling_wins_when_both_are_present(self, celebrating): - manager = celebrating(mode_config={"celebrate_opponent_scores": False, - "celebrate_opponent_goals": True}) - assert manager.celebrate_opponent_scores is False - - @pytest.mark.parametrize("bad", ["eight", None, {}, []]) - def test_unusable_duration_falls_back(self, celebrating, bad): - """The duration is compared numerically on the display path, outside - any try block — a string from a hand-edited config would propagate a - TypeError straight out of display().""" - manager = celebrating(mode_config={"celebration_duration": bad}) - assert manager.celebration_duration == 8.0 - - @pytest.mark.parametrize("bad", [0, -5]) - def test_non_positive_duration_is_floored(self, celebrating, bad): - """Zero or negative would arm a celebration that can never render.""" - manager = celebrating(mode_config={"celebration_duration": bad}) - assert manager.celebration_duration == 1.0 - - def test_numeric_string_duration_is_accepted(self, celebrating): - assert celebrating( - mode_config={"celebration_duration": "12"}).celebration_duration == 12.0 - - -class TestScoreDetection: - def test_first_sighting_never_celebrates(self, celebrating): - """A game already in progress at boot must not false-fire.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - assert manager._score_baselines["g1"] == {"away": 1, "home": 3} - - def test_increment_arms_a_celebration(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=0, away_score=0)) - manager._check_for_score(game("g1", home_score=1, away_score=0)) - assert manager.active_celebration["kind"] == "score" - assert manager.active_celebration["scored_side"] == "home" - - def test_no_change_does_not_fire(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=2)) - manager._check_for_score(game("g1", home_score=2)) - assert manager.active_celebration is None - - def test_decrement_rebases_silently(self, celebrating): - """A disallowed goal / correction must not celebrate, and must not leave - a stale baseline that fires on the way back up.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=2)) - manager._check_for_score(game("g1", home_score=1)) - assert manager.active_celebration is None - assert manager._score_baselines["g1"]["home"] == 1 - - def test_disabled_never_fires(self, celebrating): - manager = celebrating(mode_config={"celebration_enabled": False}) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - assert manager.active_celebration is None - - def test_game_without_an_id_is_ignored(self, celebrating): - manager = celebrating() - manager._check_for_score({"home_score": 1, "away_score": 0}) - assert manager.active_celebration is None - - @pytest.mark.parametrize("score", [None, "", "not-a-number-at-all"]) - def test_unusable_scores_are_ignored(self, celebrating, score): - manager = celebrating() - manager._check_for_score(game("g1", home_score=score)) - assert manager._score_baselines == {} - - @pytest.mark.parametrize("raw,expected", [ - ("7", 7), (7, 7), (7.0, 7), (" 7 ", 7), ("7 (SO)", 7), - ({"value": 7}, 7), ({"displayValue": "7"}, 7), - ]) - def test_score_coercion(self, raw, expected): - assert CelebrationMixin._score_to_int(raw) == expected - - def test_away_side_is_detected(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", away_score=0)) - manager._check_for_score(game("g1", away_score=1)) - assert manager.active_celebration["scored_side"] == "away" - - -class TestWhoGetsCelebrated: - def test_no_favorites_celebrates_everyone(self, celebrating): - """The user opted to show this game at all, so any score in it counts.""" - manager = celebrating(favorites=[]) - manager._check_for_score(game("g1", home="XXX", home_score=0)) - manager._check_for_score(game("g1", home="XXX", home_score=1)) - assert manager.active_celebration is not None - - def test_favorite_scores(self, celebrating): - manager = celebrating(favorites=["FAV"]) - manager._check_for_score(game("g1", home="FAV", home_score=0)) - manager._check_for_score(game("g1", home="FAV", home_score=1)) - assert manager.active_celebration is not None - - def test_opponent_suppressed_by_default(self, celebrating): - manager = celebrating(favorites=["FAV"]) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0)) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1)) - assert manager.active_celebration is None - - def test_opponent_celebrated_when_opted_in(self, celebrating): - manager = celebrating(mode_config={"celebrate_opponent_scores": True}, - favorites=["FAV"]) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0)) - manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1)) - assert manager.active_celebration is not None - - def test_matching_goes_through_the_favorite_key_seam(self, celebrating): - """nrl matches on team id because its abbreviations are ambiguous - ('NEW' is both Newcastle and New Zealand). Core must not care why.""" - manager = celebrating(_ById, favorites=["g1-h"]) - manager._check_for_score(game("g1", home="NEW", home_score=0)) - manager._check_for_score(game("g1", home="NEW", home_score=1)) - assert manager.active_celebration is not None - - def test_favorite_key_seam_also_excludes(self, celebrating): - manager = celebrating(_ById, favorites=["someone-else"]) - manager._check_for_score(game("g1", home="NEW", home_score=0)) - manager._check_for_score(game("g1", home="NEW", home_score=1)) - assert manager.active_celebration is None - - -class TestPhrasing: - def test_default_phrase_is_sport_neutral(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home="HOM", home_score=0)) - manager._check_for_score(game("g1", home="HOM", home_score=1)) - assert manager.active_celebration["phrase"] == "HOM SCORES!" - - def test_score_phrase_hook_sees_the_points_delta(self, celebrating): - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=6)) - assert manager.active_celebration["phrase"] == "TOUCHDOWN!" - - def test_score_phrase_hook_distinguishes_smaller_plays(self, celebrating): - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=3)) - assert manager.active_celebration["phrase"] == "HOM FIELD GOAL!" - - def test_win_phrase(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=2, away_score=1)) - assert manager.active_celebration["phrase"] == "HOM WINS!" - - -class TestCoalescing: - def test_off_by_default_two_goals_are_two_celebrations(self, celebrating): - """Soccer/afl/nrl: consecutive increments are distinct events, so - suppressing the second would swallow a real goal.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - first = manager.active_celebration["started_at"] - manager._check_for_score(game("g1", home_score=2)) - assert manager.active_celebration["started_at"] != first - assert manager.active_celebration["home_score"] == 2 - - def test_on_suppresses_the_extra_point_follow_up(self, celebrating): - """Football: a touchdown lands as +6, then +1 seconds later. One - takeover per scoring sequence.""" - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=6)) - armed = manager.active_celebration - manager._check_for_score(game("g1", home_score=7)) - assert manager.active_celebration is armed - - def test_suppression_still_advances_the_baseline(self, celebrating): - """Nothing may re-fire once the window closes.""" - manager = celebrating(_Coalescing) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=6)) - manager._check_for_score(game("g1", home_score=7)) - assert manager._score_baselines["g1"]["home"] == 7 - - -class TestWinDetection: - def test_win_requires_a_baseline(self, celebrating): - """A game seen for the first time already-final (board started after - full time) must not fire.""" - manager = celebrating(favorites=["HOM"]) - manager._check_for_win(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - - def test_win_fires_once_only(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=3, away_score=1)) - manager.active_celebration = None - manager._check_for_win(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - - def test_draw_does_not_celebrate(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=2, away_score=2)) - assert manager.active_celebration is None - - def test_win_is_gated_strictly_on_favorites(self, celebrating): - """Unlike scores, a win with no favorites configured does NOT celebrate: - every game ends, so the fallback would be constant noise.""" - manager = celebrating(favorites=[]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=3, away_score=1)) - assert manager.active_celebration is None - - def test_losing_favorite_does_not_celebrate(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1", home_score=1)) - manager._check_for_win(game("g1", home_score=1, away_score=4)) - assert manager.active_celebration is None - - def test_away_favorite_wins(self, celebrating): - manager = celebrating(favorites=["AWY"]) - manager._check_for_score(game("g1", away_score=1)) - manager._check_for_win(game("g1", home_score=1, away_score=4)) - assert manager.active_celebration["scored_side"] == "away" - - -class TestCelebrationSnapshot: - def test_the_game_is_snapshotted_not_referenced(self, celebrating): - """A win must survive the game leaving live_games.""" - manager = celebrating() - live = game("g1", home_score=0) - manager._check_for_score(live) - live = game("g1", home_score=1) - manager._check_for_score(live) - live["home_abbr"] = "MUTATED" - assert manager.active_celebration["game"]["home_abbr"] == "HOM" - - def test_focus_is_pinned_to_the_involved_game(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - assert manager.current_game["id"] == "g1" - - -class TestDisplayTakeover: - def test_no_celebration_defers_to_the_scorebug(self, celebrating): - manager = celebrating() - assert manager.display(force_clear=True) is True - assert manager.display_calls == [True] - - def test_active_celebration_takes_over(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager._draw_celebration_layout = MagicMock() - assert manager.display() is True - assert manager.display_calls == [], "the scorebug must not also render" - manager._draw_celebration_layout.assert_called_once() - - def test_expired_celebration_clears_and_defers(self, celebrating): - # A short-but-valid duration: celebration_duration is clamped to a 1.0s - # floor, so a config of 0 does NOT expire on the next frame. Backdate - # started_at past the window to exercise the real expiry branch — - # otherwise the celebration is still active and this only passes - # because _draw_celebration_layout happens to raise in the harness - # (that path is covered by test_a_render_failure_falls_through). - manager = celebrating(mode_config={"celebration_duration": 1}) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window - # Mock the layout so a render can't raise: otherwise the exception - # branch clears the celebration too, and this test would pass whether - # or not expiry actually fired. An expired celebration must NOT render. - manager._draw_celebration_layout = MagicMock() - assert manager.display() is True - manager._draw_celebration_layout.assert_not_called() - assert manager.active_celebration is None - assert manager.display_calls == [False] - - def test_expiry_resets_the_dwell_clock(self, celebrating): - """So the scorebug resumes on the scoring game for a full duration - before rotation can move on.""" - manager = celebrating(mode_config={"celebration_duration": 1}) - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window - manager._draw_celebration_layout = MagicMock() # expiry, not a render failure - manager.display() - manager._draw_celebration_layout.assert_not_called() - assert manager.last_game_switch > 0 - - def test_a_render_failure_falls_through_to_the_scorebug(self, celebrating): - """A broken celebration must never blank the display.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom")) - assert manager.display() is True - assert manager.display_calls == [False] - - def test_a_render_failure_disarms_rather_than_retrying(self, celebrating): - """Left armed, the same render fails on every frame for the rest of the - window — a traceback per frame, and no scorebug.""" - manager = celebrating() - manager._check_for_score(game("g1", home_score=0)) - manager._check_for_score(game("g1", home_score=1)) - manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom")) - manager.display() - assert manager.active_celebration is None - manager.display() - assert manager._draw_celebration_layout.call_count == 1 - - -class TestBaselinePruning: - """`_score_baselines` gains an entry per game and only _check_for_win ever - removed one, so a board running all season grows the dict without bound.""" - - def test_prunes_games_no_longer_live(self, celebrating): - manager = celebrating() - for gid in ("g1", "g2", "g3"): - manager._check_for_score(game(gid, home_score=1)) - manager.prune_score_baselines([game("g2")]) - assert set(manager._score_baselines) == {"g2"} - - def test_keeps_every_still_live_game(self, celebrating): - manager = celebrating() - for gid in ("g1", "g2"): - manager._check_for_score(game(gid, home_score=1)) - manager.prune_score_baselines([game("g1"), game("g2")]) - assert set(manager._score_baselines) == {"g1", "g2"} - - def test_empty_live_set_clears_everything(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=1)) - manager.prune_score_baselines([]) - assert manager._score_baselines == {} - - def test_pruning_does_not_disturb_a_surviving_baseline(self, celebrating): - manager = celebrating() - manager._check_for_score(game("g1", home_score=2)) - manager.prune_score_baselines([game("g1")]) - manager._check_for_score(game("g1", home_score=3)) - assert manager.active_celebration is not None, ( - "pruning must not drop a live game's baseline and re-trigger the " - "first-sighting suppression") - - def test_disabled_manager_renders_nothing(self, celebrating): - manager = celebrating() - manager.is_enabled = False - assert manager.display() is False - - -class TestFitFont: - def test_returns_the_first_font_that_fits(self, celebrating): - manager = celebrating() - draw = MagicMock() - draw.textlength.side_effect = [100, 20] - big, small = MagicMock(), MagicMock() - assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small - - def test_falls_back_to_the_smallest(self, celebrating): - manager = celebrating() - draw = MagicMock() - draw.textlength.return_value = 999 - big, small = MagicMock(), MagicMock() - assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small - - -class TestCapabilityExports: - @pytest.mark.parametrize("name", [ - "CelebrationMixin", "RotationStrategy", "SimpleRotation", - "SmoothWeightedRotation", "WeightedCycleRotation", - "get_rotation_strategy", "register_rotation_strategy", - ]) - def test_public_name_is_importable(self, name): - """Plugins import these behind a guarded fallback; the names are the - contract.""" - import src.base_classes.sports.capabilities as capabilities - assert hasattr(capabilities, name) - - def test_rotation_strategy_base_requires_a_schedule(self): - with pytest.raises(NotImplementedError): - RotationStrategy().schedule([game("a")]) - - -# --------------------------------------------------------------------------- -# Celebrations: rendering + previously untested edges -# --------------------------------------------------------------------------- - -from PIL import Image, ImageDraw, ImageFont # noqa: E402 - - -class _RenderableLive(_FakeLive): - """A _FakeLive that can actually execute _draw_celebration_layout: - real fonts, a display manager holding a real PIL image, and the two - SportsCore drawing seams the mixin calls.""" - - def __init__(self, mode_config=None, favorite_teams=None, - width=128, height=32, with_matrix=True): - super().__init__(mode_config=mode_config, favorite_teams=favorite_teams) - font = ImageFont.load_default() - self.fonts = {"time": font, "status": font, "score": font} - self.display_width = width - self.display_height = height - dm = MagicMock() - if with_matrix: - dm.matrix.width = width - dm.matrix.height = height - else: - dm.matrix = None - dm.image = Image.new("RGB", (width, height)) - self.display_manager = dm - self.logo_calls = [] - - def _load_and_resize_logo(self, team_id, abbr, path, url): - self.logo_calls.append(abbr) - logo = Image.new("RGBA", (10, 10), (0, 200, 0, 255)) - return logo - - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255)): - draw.text(position, str(text), font=font, fill=fill) - - -class _RenderableCelebrating(CelebrationMixin, _RenderableLive): - pass - - -def _armed(manager, *, kind="score", side="home", started_ago=0.0): - manager._start_celebration( - game("g1", home_score=7, away_score=3), kind, - scored_side=side, team_abbr="HOM", away_score=3, home_score=7, - points=7, - ) - manager.active_celebration["started_at"] = time.time() - started_ago - return manager.active_celebration - - -class TestDrawCelebrationLayout: - """The takeover render path, executed for real (previously always - mocked out).""" - - def test_renders_and_hands_frame_to_display_manager(self): - manager = _RenderableCelebrating() - celebration = _armed(manager) - manager._draw_celebration_layout(celebration) - # The final frame was assigned and pushed. - assert isinstance(manager.display_manager.image, Image.Image) - assert manager.display_manager.image.mode == "RGB" - assert manager.display_manager.image.size == (128, 32) - manager.display_manager.update_display.assert_called_once() - assert manager.display_manager.image.convert("L").getbbox() is not None - - def test_force_clear_clears_display_first(self): - manager = _RenderableCelebrating() - celebration = _armed(manager) - manager._draw_celebration_layout(celebration, force_clear=True) - manager.display_manager.clear.assert_called_once() - - def test_flash_background_within_first_window(self): - # elapsed < 1.2 with int(elapsed/0.2) even -> flash color backdrop. - manager = _RenderableCelebrating() - celebration = _armed(manager, started_ago=0.05) - manager._draw_celebration_layout(celebration) - flash = manager.display_manager.image - # After the flash window: plain black backdrop. - celebration["started_at"] = time.time() - 5 - manager._draw_celebration_layout(celebration) - steady = manager.display_manager.image - # Corner pixels (away from logos/text) show the two backgrounds. - assert flash.getpixel((64, 30)) != steady.getpixel((64, 30)) or \ - flash.getpixel((3, 0)) != steady.getpixel((3, 0)) - - def test_matrix_dims_fallback_to_display_attrs(self): - manager = _RenderableCelebrating(width=96, height=48, with_matrix=False) - celebration = _armed(manager) - manager._draw_celebration_layout(celebration) - assert manager.display_manager.image.size == (96, 48) - - def test_highlight_color_alternates_with_elapsed(self): - manager = _RenderableCelebrating() - celebration = _armed(manager) - # int(elapsed*4) % 2 == 0 -> yellow; == 1 -> orange. Force each phase - # and diff the frames. - celebration["started_at"] = time.time() - 2.0 # 8 -> even - manager._draw_celebration_layout(celebration) - even = manager.display_manager.image.tobytes() - celebration["started_at"] = time.time() - 2.25 # 9 -> odd - manager._draw_celebration_layout(celebration) - odd = manager.display_manager.image.tobytes() - assert even != odd - - def test_logo_failure_still_renders_text(self): - manager = _RenderableCelebrating() - - def boom(*a, **k): - raise RuntimeError("disk gone") - - manager._load_and_resize_logo = boom - celebration = _armed(manager, started_ago=5) # steady background - manager._draw_celebration_layout(celebration) # must not raise - assert manager.display_manager.image.convert("L").getbbox() is not None - manager.display_manager.update_display.assert_called_once() - - -class TestCelebrationEdges: - def test_should_celebrate_for_three_way_branch(self, celebrating): - g = game("g1", home="FAV", away="OPP") - favored = celebrating(favorites=["FAV"]) - assert favored._should_celebrate_for(g, "home") is True # favorite - assert favored._should_celebrate_for(g, "away") is False # opponent - favored.celebrate_opponent_scores = True - assert favored._should_celebrate_for(g, "away") is True # opted in - unconfigured = celebrating(favorites=[]) - assert unconfigured._should_celebrate_for(g, "away") is True # no favs - - def test_active_celebration_boundary_is_strict(self, celebrating): - manager = celebrating(mode_config={"celebration_duration": 3}) - manager.active_celebration = {"started_at": time.time() - 3.0} - # elapsed == duration -> strictly-less-than comparison says done. - assert manager.has_active_celebration() is False - manager.active_celebration = None - assert manager.has_active_celebration() is False - - @pytest.mark.parametrize("value,expected", [ - ({"value": None}, None), # int(float(None)) TypeError -> caught - ({"value": "abc"}, None), - ({"other": 1}, 0), # neither key -> default 0 - ([3], None), # list -> TypeError -> caught - ("-4", None), # regex fallback finds digits -> 4? No: - ]) - def test_score_to_int_edges(self, value, expected): - result = CelebrationMixin._score_to_int(value) - if value == "-4": - # int(float("-4")) parses directly: -4. - assert result == -4 - else: - assert result == expected - - def test_both_teams_scoring_prefers_away(self, celebrating): - manager = celebrating(favorites=[]) - manager._check_for_score(game("g1", home_score=0, away_score=0)) - manager._check_for_score(game("g1", home_score=7, away_score=3)) - assert manager.active_celebration["scored_side"] == "away" - - def test_away_not_celebratable_falls_through_to_home(self, celebrating): - manager = celebrating(favorites=["HOM"]) # away is the opponent - manager._check_for_score(game("g1", home_score=0, away_score=0)) - manager._check_for_score(game("g1", home_score=7, away_score=3)) - assert manager.active_celebration["scored_side"] == "home" - - def test_coalesce_expired_celebration_fires_fresh(self, celebrating): - manager = celebrating(cls=_Coalescing, - mode_config={"celebration_duration": 1}) - manager._check_for_score(game("g1")) - manager._check_for_score(game("g1", home_score=6)) - first = manager.active_celebration - assert first is not None - first["started_at"] = time.time() - 2 # expired - manager._check_for_score(game("g1", home_score=7)) - # A new celebration replaced the expired one (coalescing only - # suppresses while one is actively on screen). - assert manager.active_celebration is not first - assert manager.active_celebration["home_score"] == 7 - - def test_disabled_win_check_preserves_baseline(self, celebrating): - manager = celebrating(favorites=["HOM"]) - manager._check_for_score(game("g1")) - assert "g1" in manager._score_baselines - manager.celebration_enabled = False - manager._check_for_win(game("g1", home_score=7)) - # Early return BEFORE consuming the baseline: re-enabling later can - # still fire for this game. - assert "g1" in manager._score_baselines - - def test_prune_drops_baselines_for_idless_live_games(self, celebrating): - manager = celebrating() - manager._score_baselines = {"g1": {"away": 0, "home": 0}} - manager.prune_score_baselines([{"no_id_here": True}]) - # live ids collapse to {None}; g1 is not live -> dropped. - assert manager._score_baselines == {} diff --git a/test/test_sports_core_promotions.py b/test/test_sports_core_promotions.py deleted file mode 100644 index eb905002..00000000 --- a/test/test_sports_core_promotions.py +++ /dev/null @@ -1,613 +0,0 @@ -"""Tests for the methods promoted onto SportsCore from the nine bundled -plugin copies of ``sports.py`` (phase B1 of docs/SPORTS_UNIFICATION.md). - -Three methods and their seams land here: - -- ``cleanup()`` — byte-identical in all nine copies. The tests pin the - ordering (session close, then caches, then the completion log) and the - deliberate *omission*: the process-wide background service must never be - shut down by one unloading plugin. -- ``_get_layout_offset()`` — football's resolver-backed variant, with the - classic inline config read as the fallback used by every plugin that - doesn't hand core a ``_config_schema_path()``. -- ``_load_custom_font_from_element_config()`` — baseball's body (the only - copy that handles BDF strikes correctly) under hockey's wider signature, - resolving font files through the ``_font_root()`` seam instead of the - process cwd. -""" - -import ast -import json -import logging -import os -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from PIL import Image, ImageFont - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so these tests can import the sports base classes off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.sports import SportsCore - -LOGGER = logging.getLogger("test_sports_core_promotions") - -CORE_ROOT = Path(__file__).resolve().parents[1] -FONTS_DIR = CORE_ROOT / "assets" / "fonts" -TTF_NAME = "PressStart2P-Regular.ttf" -BDF_NAME = "5x7.bdf" # a BDF whose only valid strike is 7px -BDF_NATIVE_SIZE = 7 - - -class _StubSports(SportsCore): - """Minimal concrete SportsCore — the abstract methods are never called - by anything under test here.""" - - def _fetch_data(self): - return None - - def _extract_game_details(self, game_event): - return None - - -@pytest.fixture -def build(monkeypatch, tmp_path): - """Factory for real SportsCore instances: logo dir redirected to tmp and - the process-wide background service replaced with a MagicMock so the - tests can assert nothing ever calls it.""" - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - - def _build(config=None, cls=_StubSports): - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - cache_manager = MagicMock() - cache_manager.cache_dir = str(tmp_path) - return cls(config if config is not None else {"timezone": "UTC"}, - display_manager, cache_manager, LOGGER, "nhl") - - return _build - - -def probe(config=None): - """Unbound-call stand-in for hosts we don't need a full instance for - (same pattern as make_probe in test_sports_base_characterization).""" - host = MagicMock() - host.logger = LOGGER - host.config = config if config is not None else {} - host._font_cache = {} - host._bdf_native_size_cache = {} - host._config_schema_path.return_value = None - host._font_root.side_effect = lambda: SportsCore._font_root(host) - host._resolve_font_path.side_effect = ( - lambda name: SportsCore._resolve_font_path(host, name)) - return host - - -def offset(host, element, axis, default=0): - return SportsCore._get_layout_offset(host, element, axis, default) - - -def load_font(host, *args, **kwargs): - return SportsCore._load_custom_font_from_element_config(host, *args, **kwargs) - - -# --------------------------------------------------------------------------- -# 1. cleanup() -# --------------------------------------------------------------------------- - -class TestCleanup: - def test_closes_session_and_clears_all_caches(self, build): - manager = build() - session = MagicMock() - manager.session = session - manager._logo_cache["TB"] = object() - manager._font_cache[("PressStart2P-Regular.ttf", 8)] = object() - manager._bdf_native_size_cache["assets/fonts/5x7.bdf"] = 7 - - manager.cleanup() - - session.close.assert_called_once_with() - assert manager._logo_cache == {} - # Promoted alongside the font loader: these hold PIL faces and are - # an unbounded leak across enable/disable cycles if never released. - assert manager._font_cache == {} - assert manager._bdf_native_size_cache == {} - - def test_second_cleanup_is_a_noop(self, build): - manager = build() - manager.session = MagicMock() - manager._logo_cache["TB"] = object() - manager._font_cache[("x", 8)] = object() - - manager.cleanup() - manager.cleanup() # must not raise on already-released state - - assert manager._logo_cache == {} - assert manager._font_cache == {} - assert manager._bdf_native_size_cache == {} - assert manager.session.close.call_count == 2 - - def test_does_not_shut_down_the_shared_background_service(self, build): - # get_background_service() hands out a PROCESS-WIDE singleton shared - # by every scoreboard. One plugin unloading must not stop background - # fetching for the other eight — cleanup() touches it not at all. - manager = build() - service = manager.background_service - manager.session = MagicMock() - - manager.cleanup() - - assert service.shutdown.called is False - assert service.stop.called is False - assert service.method_calls == [], ( - "cleanup() called into the shared background service: " - f"{service.method_calls}") - - def test_completion_is_logged_even_when_session_close_raises(self, build, caplog): - manager = build() - manager.session = MagicMock() - manager.session.close.side_effect = RuntimeError("socket already gone") - manager._logo_cache["TB"] = object() - - with caplog.at_level(logging.DEBUG, logger=LOGGER.name): - manager.cleanup() - - messages = [r.message for r in caplog.records] - assert any("Error closing session" in m for m in messages) - # Ordering is load-bearing: the caches still get cleared and the - # completion log still fires after a failed close. - assert manager._logo_cache == {} - assert any("cleanup completed" in m for m in messages) - - def test_tolerates_missing_attributes(self): - # The hasattr guards exist so a partially constructed instance (an - # __init__ that raised) can still be cleaned up. - host = MagicMock(spec=["logger"]) - host.logger = LOGGER - SportsCore.cleanup(host) - - -# --------------------------------------------------------------------------- -# 2. _get_layout_offset() + the _config_schema_path() seam -# --------------------------------------------------------------------------- - -def layout_config(element, axis, value): - return {"customization": {"layout": {element: {axis: value}}}} - - -class TestLayoutOffsetClassicPath: - """The default path: _config_schema_path() returns None, so offsets come - from the inline customization.layout read every plugin ships today.""" - - def test_config_schema_path_defaults_to_none(self, build): - manager = build() - assert manager._config_schema_path() is None - - def test_reads_configured_int(self): - host = probe(layout_config("home_logo", "x_offset", 5)) - assert offset(host, "home_logo", "x_offset") == 5 - - def test_float_is_truncated_to_int(self): - host = probe(layout_config("score", "y_offset", 2.9)) - result = offset(host, "score", "y_offset") - assert result == 2 and isinstance(result, int) - - def test_numeric_string_is_coerced(self): - host = probe(layout_config("score", "x_offset", "-3.5")) - assert offset(host, "score", "x_offset") == -3 - - def test_unconfigured_element_and_axis_use_default(self): - host = probe(layout_config("score", "x_offset", 5)) - assert offset(host, "status_text", "x_offset", 7) == 7 - assert offset(host, "score", "y_offset", -1) == -1 - assert offset(probe(), "score", "x_offset", 4) == 4 - - def test_non_numeric_string_degrades_to_default(self): - host = probe(layout_config("score", "x_offset", "left")) - assert offset(host, "score", "x_offset", 3) == 3 - - def test_unsupported_type_degrades_to_default(self): - host = probe(layout_config("score", "x_offset", {"nested": 1})) - assert offset(host, "score", "x_offset", 2) == 2 - host = probe(layout_config("score", "x_offset", None)) - assert offset(host, "score", "x_offset", 2) == 2 - - def test_broken_config_object_degrades_to_default(self): - host = probe() - host.config = "not a dict" - assert offset(host, "score", "x_offset", 6) == 6 - - def test_boolean_counts_as_one(self): - # PINNED AS-IS: the classic read predates the resolver and treats a - # bool as its int value (True -> 1). See the resolver test below for - # the stricter, more correct handling. - host = probe(layout_config("score", "x_offset", True)) - assert offset(host, "score", "x_offset", 4) == 1 - - -class TestLayoutOffsetResolverPath: - """When a plugin supplies its config_schema.json, offsets resolve through - src.element_style instead.""" - - @pytest.fixture - def schema_path(self, tmp_path): - path = tmp_path / "config_schema.json" - path.write_text(json.dumps({ - "type": "object", - "properties": { - "customization": { - "type": "object", - "properties": { - "layout": {"type": "object", "properties": {}}, - }, - }, - }, - })) - return str(path) - - def host(self, schema_path, config): - host = probe(config) - host._config_schema_path.return_value = schema_path - del host._style_resolver_cached # MagicMock auto-attrs otherwise - host._style_resolver_cached = None - return host - - def test_reads_configured_offsets(self, schema_path): - host = self.host(schema_path, layout_config("home_logo", "x_offset", 5)) - assert offset(host, "home_logo", "x_offset") == 5 - - def test_numeric_string_is_coerced(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", "-3.5")) - assert offset(host, "score", "x_offset") == -3 - - def test_missing_value_uses_default(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", 5)) - assert offset(host, "score", "y_offset", 9) == 9 - - def test_bad_input_degrades_to_default(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", "left")) - assert offset(host, "score", "x_offset", 3) == 3 - host = self.host(schema_path, {"customization": {"layout": "nope"}}) - assert offset(host, "score", "x_offset", 3) == 3 - - def test_boolean_is_rejected_unlike_the_classic_path(self, schema_path): - # The intended behavior difference: a bool is not a pixel offset, so - # the resolver returns the default where the classic read returns 1. - host = self.host(schema_path, layout_config("score", "x_offset", True)) - assert offset(host, "score", "x_offset", 4) == 4 - - def test_resolver_is_cached_and_rebuilt_when_config_is_swapped(self, schema_path): - host = self.host(schema_path, layout_config("score", "x_offset", 5)) - assert offset(host, "score", "x_offset") == 5 - first = host._style_resolver_cached - assert offset(host, "score", "x_offset") == 5 - assert host._style_resolver_cached is first - - # on_config_change swaps the dict object; the resolver must follow. - host.config = layout_config("score", "x_offset", 11) - assert offset(host, "score", "x_offset") == 11 - assert host._style_resolver_cached is not first - - def test_missing_schema_file_still_resolves_offsets(self, tmp_path): - # Offsets don't depend on schema defaults, so an unreadable schema - # must not cost the plugin its layout customization. - host = self.host(str(tmp_path / "absent.json"), - layout_config("score", "x_offset", 5)) - assert offset(host, "score", "x_offset") == 5 - - -# --------------------------------------------------------------------------- -# 3. _load_custom_font_from_element_config() + the _font_root() seam -# --------------------------------------------------------------------------- - -class TestFontRootSeam: - def test_default_font_root_is_the_core_install_root(self, build): - manager = build() - assert Path(manager._font_root()) == CORE_ROOT - assert (Path(manager._font_root()) / "assets" / "fonts").is_dir() - - def test_resolve_font_path_honors_an_overridden_root(self, tmp_path): - fonts = tmp_path / "assets" / "fonts" - fonts.mkdir(parents=True) - (fonts / "Bundled.ttf").write_bytes(b"not really a font") - host = probe() - host._font_root.side_effect = lambda: str(tmp_path) - assert SportsCore._resolve_font_path(host, "Bundled.ttf") == str( - fonts / "Bundled.ttf") - - def test_unknown_font_returns_the_familiar_relative_path(self): - host = probe() - assert SportsCore._resolve_font_path(host, "Nope.ttf") == os.path.join( - "assets", "fonts", "Nope.ttf") - - -class TestFontLoaderSignature: - """Hockey's signature is the only safe superset: basketball's positional - ``default_font: str`` blows up on an explicit None.""" - - def test_two_arg_call(self): - font = load_font(probe(), {"font": TTF_NAME, "font_size": 10}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.size == 10 - - def test_default_size_is_used_when_config_omits_it(self): - assert load_font(probe(), {}, 12).size == 12 - - def test_three_positional_args(self): - font = load_font(probe(), {}, 6, "4x6-font.ttf") - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.size == 6 - assert font.path.endswith("4x6-font.ttf") - - def test_explicit_default_font_none(self): - # The regression this signature guards: os.path.join(..., None). - font = load_font(probe(), {"font_size": 9}, default_font=None) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.path.endswith(TTF_NAME) - - def test_config_font_wins_over_default_font(self): - font = load_font(probe(), {"font": TTF_NAME}, 8, "4x6-font.ttf") - assert font.path.endswith(TTF_NAME) - - def test_string_font_size_is_coerced(self): - assert load_font(probe(), {"font": TTF_NAME, "font_size": "11"}).size == 11 - - -class TestFontLoaderBehavior: - def test_family_alias_resolves_through_the_font_manager_catalog(self): - # "press_start" is a FontManager catalog family, not a filename; the - # promoted loader must not carry its own duplicate alias table. - host = probe() - font = load_font(host, {"font": "press_start", "font_size": 8}) - assert font.path.endswith(TTF_NAME) - assert ("PressStart2P-Regular.ttf", 8) in host._font_cache - - def test_memo_cache_returns_the_same_face(self): - host = probe() - first = load_font(host, {"font": TTF_NAME, "font_size": 8}) - second = load_font(host, {"font": TTF_NAME, "font_size": 8}) - assert first is second - assert len(host._font_cache) == 1 - # A different size is a different face. - assert load_font(host, {"font": TTF_NAME, "font_size": 9}) is not first - assert len(host._font_cache) == 2 - - def test_bdf_loads_at_its_native_strike_when_the_request_misses(self): - # BDF is a fixed-size bitmap format: FreeType raises "invalid pixel - # size" for anything but the file's own strike. Baseball's retry is - # the only copy that gets this right. - host = probe() - font = load_font(host, {"font": BDF_NAME, "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.size == BDF_NATIVE_SIZE - assert font.path.endswith(BDF_NAME) - assert set(host._bdf_native_size_cache.values()) == {BDF_NATIVE_SIZE} - # The retried face is memoized under the REQUESTED size. - assert host._font_cache[(BDF_NAME, 8)] is font - - def test_bdf_at_its_native_size_needs_no_retry(self): - host = probe() - font = load_font(host, {"font": BDF_NAME, "font_size": BDF_NATIVE_SIZE}) - assert font.size == BDF_NATIVE_SIZE - assert host._bdf_native_size_cache == {} - - def test_bdf_strike_lookup_is_memoized(self, monkeypatch): - calls = [] - real = SportsCore.__module__ - - def counting(path): - calls.append(path) - from src.font_manager import FontManager - return FontManager._read_bdf_native_size(path) - - monkeypatch.setattr(f"{real}._read_bdf_native_size", counting) - host = probe() - load_font(host, {"font": BDF_NAME, "font_size": 8}) - host._font_cache.clear() # force the load path again - load_font(host, {"font": BDF_NAME, "font_size": 8}) - assert len(calls) == 1 - - def test_missing_font_falls_back_and_caches_the_fallback(self, caplog): - host = probe() - with caplog.at_level(logging.WARNING, logger=LOGGER.name): - font = load_font(host, {"font": "DoesNotExist.ttf", "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert font.path.endswith(TTF_NAME) - assert any("Font file not found" in r.message for r in caplog.records) - # Cached under the requested name so a misconfiguration costs one - # disk probe, not one per frame. - assert host._font_cache[("DoesNotExist.ttf", 8)] is font - - def test_unknown_extension_falls_back(self): - host = probe() - font = load_font(host, {"font": "AUTHORS", "font_size": 8}) - assert font.path.endswith(TTF_NAME) - - def test_fallback_honors_the_supplied_default_font(self): - host = probe() - font = load_font(host, {"font": "DoesNotExist.ttf"}, 6, "4x6-font.ttf") - assert font.path.endswith("4x6-font.ttf") - - -class TestFontLoaderCwdIndependence: - """The bug the _font_root() seam exists to prevent: every plugin copy - joins 'assets/fonts' onto the process cwd, so a process started anywhere - else silently degrades to PIL's default bitmap face (the same defect - already fixed in FontManager — see CHANGELOG Unreleased/Fixed).""" - - @pytest.mark.parametrize("font_name,expected_size", - [(TTF_NAME, 8), (BDF_NAME, BDF_NATIVE_SIZE)]) - def test_fonts_load_from_an_unrelated_cwd(self, monkeypatch, font_name, - expected_size): - monkeypatch.chdir("/") - host = probe() - font = load_font(host, {"font": font_name, "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont), ( - f"{font_name} degraded to PIL's default face when the process " - "runs outside the install root") - assert font.size == expected_size - assert Path(font.path) == FONTS_DIR / font_name - - def test_fallback_font_also_survives_an_unrelated_cwd(self, monkeypatch): - monkeypatch.chdir("/") - font = load_font(probe(), {"font": "DoesNotExist.ttf", "font_size": 8}) - assert isinstance(font, ImageFont.FreeTypeFont) - assert Path(font.path) == FONTS_DIR / TTF_NAME - - @pytest.mark.parametrize("key", ["score", "time", "team", "status", - "detail", "rank"]) - def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key): - """`_load_fonts` had the same cwd-relative literals the seam exists to - remove; every scoreboard font silently became PIL's default bitmap face - when the process started outside the install root.""" - monkeypatch.chdir("/") - fonts = SportsCore._load_fonts(probe()) - assert isinstance(fonts[key], ImageFont.FreeTypeFont), ( - f"fonts['{key}'] degraded to PIL's default face outside the " - "install root") - - -class TestShouldLogCooldown: - """`_should_log` reads `self._last_warning_time` unguarded, so it must be - initialized in __init__ — otherwise the first warning of a run raises - AttributeError instead of logging.""" - - def test_cooldown_clock_is_initialized(self, build): - assert build()._last_warning_time == 0 - - def test_first_call_logs_then_cools_down(self, build): - manager = build() - assert manager._should_log("api", cooldown=60) is True - assert manager._should_log("api", cooldown=60) is False - - def test_cooldown_expires(self, build): - manager = build() - assert manager._should_log("api", cooldown=60) is True - manager._warning_cooldowns["api"] -= 61 - assert manager._should_log("api", cooldown=60) is True - - def test_cooldowns_are_tracked_per_warning_type(self, build): - """The parameter was accepted and ignored: one shared timestamp meant an - API warning silenced an unrelated cache warning for the next minute.""" - manager = build() - assert manager._should_log("api", cooldown=60) is True - assert manager._should_log("cache", cooldown=60) is True - assert manager._should_log("api", cooldown=60) is False - assert manager._should_log("cache", cooldown=60) is False - - def test_one_type_expiring_does_not_free_another(self, build): - manager = build() - manager._should_log("api") - manager._should_log("cache") - manager._warning_cooldowns["api"] -= 61 - assert manager._should_log("api") is True - assert manager._should_log("cache") is False - - def test_legacy_single_clock_field_is_kept_in_step(self, build): - """Subclasses in the plugin copies read _last_warning_time directly.""" - manager = build() - manager._should_log("api") - assert manager._last_warning_time == manager._warning_cooldowns["api"] - - -# --------------------------------------------------------------------------- -# 4. Seam guard rails -# --------------------------------------------------------------------------- - -class TestPromotedSeamsExist: - @pytest.mark.parametrize("name", [ - "cleanup", "_get_layout_offset", "_load_custom_font_from_element_config", - "_config_schema_path", "_font_root", "_resolve_font_path", - ]) - def test_method_is_callable_on_the_base_class(self, name): - assert callable(getattr(SportsCore, name, None)), ( - f"SportsCore.{name} is part of the promoted plugin-facing seam " - "(docs/SPORTS_UNIFICATION.md) — plugins probe for it with " - "hasattr before delegating.") - - def test_no_sport_names_leaked_into_core(self): - """core.py must never branch on which sport it is (prose and skin-id - examples in docstrings are fine — executable code is not).""" - tree = ast.parse((CORE_ROOT / "src" / "base_classes" / "sports" - / "core.py").read_text()) - docstrings = set() - for node in ast.walk(tree): - if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, - ast.AsyncFunctionDef)): - first = node.body[0] if node.body else None - if (isinstance(first, ast.Expr) - and isinstance(first.value, ast.Constant) - and isinstance(first.value.value, str)): - docstrings.add(id(first.value)) - - tokens = [] - for node in ast.walk(tree): - if isinstance(node, ast.Constant) and isinstance(node.value, str): - if id(node) not in docstrings: - tokens.append(node.value) - elif isinstance(node, ast.Name): - tokens.append(node.id) - elif isinstance(node, ast.Attribute): - tokens.append(node.attr) - elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, - ast.ClassDef)): - tokens.append(node.name) - - haystack = " ".join(tokens).lower() - for sport in ("afl", "nrl", "hockey", "baseball", "basketball", - "football", "lacrosse", "soccer", "ufc"): - assert sport not in haystack, ( - f"core.py code mentions '{sport}' — core must never learn " - "sport names; add an override point instead.") - - -class TestInstallRootResolution: - """Guards the depth bug the sports.py -> package move introduced. - - The move was byte-identical in every class body, but `__file__` gained a - directory, so `Path(__file__).resolve().parents[2]` silently changed from - the repo root to `/src`. Textual identity is not semantic identity - when code measures its own location: these tests assert the resolved - values, not the index. - """ - - def test_install_root_is_the_repo_root(self): - from src.base_classes.sports.core import _INSTALL_ROOT - - # The repo root is the directory that actually holds src/ and assets/. - assert (_INSTALL_ROOT / "src").is_dir() - assert (_INSTALL_ROOT / "src" / "base_classes" / "sports").is_dir() - assert _INSTALL_ROOT.name != "src", ( - "_INSTALL_ROOT resolved to src/ — the parents[] depth is off by " - "one, which is exactly the regression the package move caused.") - - def test_resolve_project_path_roots_at_repo_not_src(self): - from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT - - resolved = SportsCore._resolve_project_path(None, Path("assets/fonts")) - assert resolved == _INSTALL_ROOT / "assets" / "fonts" - assert "src" not in resolved.relative_to(_INSTALL_ROOT).parts - - def test_absolute_paths_pass_through_unchanged(self): - from src.base_classes.sports.core import SportsCore - - absolute = Path("/tmp/some/logo/dir") - assert SportsCore._resolve_project_path(None, absolute) == absolute - - def test_font_root_and_project_path_share_one_anchor(self): - """Both consumers must derive from the same constant, so a future - move needs exactly one line changed rather than two.""" - from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT - - assert SportsCore._font_root(None) == str(_INSTALL_ROOT) diff --git a/test/test_sports_logo_cache_bounded.py b/test/test_sports_logo_cache_bounded.py deleted file mode 100644 index 8570b81e..00000000 --- a/test/test_sports_logo_cache_bounded.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tests that SportsCore's decoded-logo cache is LRU-bounded. - -``self._logo_cache`` was a plain dict keyed by team abbreviation, with no -eviction. The entries are decoded RGBA thumbnails sized to display*1.5 -- about -36KB on a 256x64 panel, more for wide wordmarks -- and -assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league -therefore held the whole league in memory: roughly 11-18MB per manager -instance, and a league runs three of them (live/recent/upcoming) each with its -own cache. On a 1GB Pi 3B+ with ~290MB available that is worth recovering. - -The bound has to be LRU rather than "clear when full": the logos on screen -right now are exactly the ones that must not be thrown away. -""" - -import logging -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from PIL import Image - -# src.base_classes.sports transitively imports the hardware matrix driver. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.sports import SportsCore # noqa: E402 - -LOGGER = logging.getLogger("test_sports_logo_cache_bounded") - - -class _StubSports(SportsCore): - def _fetch_data(self): - return None - - def _extract_game_details(self, game_event): - return None - - -@pytest.fixture -def host(monkeypatch, tmp_path): - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - # Never reach for the network: every logo these tests ask for exists. - monkeypatch.setattr( - "src.base_classes.sports.core.download_missing_logo", - lambda *a, **k: None) - - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - cache_manager = MagicMock() - cache_manager.cache_dir = str(tmp_path) - instance = _StubSports({"timezone": "UTC"}, display_manager, - cache_manager, LOGGER, "nhl") - instance._logo_dir = tmp_path - return instance, tmp_path - - -def _load(host, abbrev): - """Create a real logo file for `abbrev` and load it through the cache.""" - instance, tmp_path = host - path = tmp_path / f"{abbrev}.png" - if not path.exists(): - Image.new("RGBA", (64, 64), (1, 2, 3, 255)).save(path) - return instance._load_and_resize_logo(abbrev, abbrev, path, None) - - -class TestTheCacheIsBounded: - def test_it_never_exceeds_the_limit(self, host): - instance, _ = host - limit = instance._LOGO_CACHE_MAX - for i in range(limit + 40): - assert _load(host, f"T{i}") is not None - # The regression: this grew to limit + 40, and for NCAA to 307. - assert len(instance._logo_cache) == limit - - def test_the_limit_is_smaller_than_a_real_league(self, host): - """ncaa_logos ships 307 files; the cap has to be well under that.""" - instance, _ = host - assert instance._LOGO_CACHE_MAX < 307 - - -class TestEvictionIsLRUNotArbitrary: - def test_the_least_recently_used_goes_first(self, host): - instance, _ = host - limit = instance._LOGO_CACHE_MAX - for i in range(limit): - _load(host, f"T{i}") - _load(host, "NEW") - assert "T0" not in instance._logo_cache, "oldest should have been evicted" - assert "NEW" in instance._logo_cache - - def test_touching_an_entry_saves_it(self, host): - instance, _ = host - limit = instance._LOGO_CACHE_MAX - for i in range(limit): - _load(host, f"T{i}") - _load(host, "T0") # a cache hit -- T0 is on screen again - _load(host, "NEW") # forces one eviction - - assert "T0" in instance._logo_cache, "a logo in use was thrown away" - assert "T1" not in instance._logo_cache, "T1 was the true LRU entry" - - -class TestHitsStillAvoidDiskWork: - def test_a_second_request_returns_the_cached_object(self, host, monkeypatch): - instance, _ = host - first = _load(host, "TB") - - def _boom(*a, **k): - raise AssertionError("cache hit re-opened the file from disk") - - monkeypatch.setattr(Image, "open", _boom) - assert _load(host, "TB") is first diff --git a/test/test_sports_modes_promotions.py b/test/test_sports_modes_promotions.py deleted file mode 100644 index 72f6a211..00000000 --- a/test/test_sports_modes_promotions.py +++ /dev/null @@ -1,585 +0,0 @@ -"""Tests for the methods promoted onto SportsUpcoming / SportsRecent / -SportsLive from the nine plugin copies (phase B1 of the sports unification; -see docs/SPORTS_UNIFICATION.md). - -Covered promotions: -- SportsRecent: `_get_zero_clock_duration` / `_clear_zero_clock_tracking` - (+ the `_zero_clock_timestamps` initializer). -- SportsLive: `_is_game_really_over` / `_detect_stale_games` - (+ `game_update_timestamps` / `stale_game_timeout`, and the - `FINAL_PERIOD` / `CLOCK_COUNTS_DOWN` class attributes). -- SportsUpcoming: `_select_games_for_display`. -- SportsRecent: `_select_recent_games_for_display`. - -The live pair is the risk centre: `_detect_stale_games` is the only caller -that *removes* games, so `_is_game_really_over` returning a false positive -silently drops a live game from the display. The canonical form deliberately -declines to treat a missing clock as 0:00 — the plugin variant that did so -dropped clockless sports (baseball) from the FINAL_PERIOD-th period onward. -That regression is pinned by -`test_missing_clock_at_late_period_is_not_over`. -""" - -import logging -import sys -import time -from datetime import datetime, timezone -from unittest.mock import MagicMock - -import pytest -import requests -from freezegun import freeze_time -from PIL import Image - -# src.base_classes.sports transitively imports the hardware matrix driver; -# stub it so these tests can import the sports base classes off-device. -sys.modules.setdefault("rgbmatrix", MagicMock()) - -from src.base_classes.hockey import Hockey, HockeyLive -from src.base_classes.sports import ( - SportsCore, - SportsLive, - SportsRecent, - SportsUpcoming, -) - - -# --------------------------------------------------------------------------- -# Harnesses -# --------------------------------------------------------------------------- - -class _UpcomingHarness(Hockey, SportsUpcoming): - """Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data. - - `_favorite_key` is inherited from SportsCore — these harnesses - deliberately do NOT define it, so the selection tests exercise the real - seam rather than a local stand-in. - """ - - def _fetch_data(self): - return None - - -class _RecentHarness(Hockey, SportsRecent): - def _fetch_data(self): - return None - - -class _LiveHarness(HockeyLive): - def _fetch_data(self): - return None - - -class _ThreePeriodLiveHarness(_LiveHarness): - """Hockey-shaped: regulation ends after period 3.""" - - FINAL_PERIOD = 3 - - -class _CountUpLiveHarness(_LiveHarness): - """Soccer/AFL/NRL-shaped: the clock counts up, so 0:00 is kickoff.""" - - CLOCK_COUNTS_DOWN = False - - -class _IdFavoriteUpcomingHarness(_UpcomingHarness): - """NRL-shaped: abbreviations are ambiguous, so favorites match on team id.""" - - def _favorite_key(self, game, side): - team_id = game.get(f"{side}_id") - return str(team_id) if team_id is not None else None - - -class _IdFavoriteRecentHarness(_RecentHarness): - def _favorite_key(self, game, side): - team_id = game.get(f"{side}_id") - return str(team_id) if team_id is not None else None - - -@pytest.fixture -def build_manager(monkeypatch, tmp_path): - """Factory for concrete sports managers: mocked display/cache managers, - logo dir redirected to tmp, background service stubbed, and the requests - session rigged to prove nothing hits the network.""" - monkeypatch.setattr( - SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path) - monkeypatch.setattr( - "src.base_classes.sports.core.get_background_service", - lambda *args, **kwargs: MagicMock()) - - def build(cls, **mode_cfg): - config = { - "timezone": "UTC", - "display": {}, - "nhl_scoreboard": {"enabled": True, **mode_cfg}, - } - display_manager = MagicMock() - display_manager.matrix.width = 128 - display_manager.matrix.height = 32 - display_manager.width = 128 - display_manager.height = 32 - display_manager.image = Image.new("RGB", (128, 32)) - cache_manager = MagicMock() - cache_manager.get.return_value = None - cache_manager.cache_dir = str(tmp_path) - manager = cls(config, display_manager, cache_manager, - logging.getLogger("test_sports_modes_promotions"), - "nhl") - manager.session = MagicMock() - manager.session.get.side_effect = requests.exceptions.ConnectionError( - "promotion tests are offline") - return manager - - return build - - -def game(game_id="1", home="BOS", away="TOR", start=None, home_id=None, - away_id=None, **extra): - g = { - "id": game_id, - "home_abbr": home, - "away_abbr": away, - "home_id": home_id, - "away_id": away_id, - "start_time_utc": start, - } - g.update(extra) - return g - - -def at(day, hour=12): - return datetime(2026, 1, day, hour, tzinfo=timezone.utc) - - -def _ids(games): - return [g["id"] for g in games] - - -# --------------------------------------------------------------------------- -# Tier 1 — zero-clock tracking (SportsRecent) -# --------------------------------------------------------------------------- - -class TestZeroClockTracking: - def test_initializer_present_and_empty(self, build_manager): - manager = build_manager(_RecentHarness) - assert manager._zero_clock_timestamps == {} - - def test_first_call_returns_zero_and_starts_tracking(self, build_manager): - manager = build_manager(_RecentHarness) - assert manager._get_zero_clock_duration("g1") == 0.0 - assert "g1" in manager._zero_clock_timestamps - - def test_subsequent_call_returns_elapsed_seconds(self, build_manager): - manager = build_manager(_RecentHarness) - with freeze_time("2026-01-20 12:00:00") as frozen: - assert manager._get_zero_clock_duration("g1") == 0.0 - frozen.tick(45) - assert manager._get_zero_clock_duration("g1") == pytest.approx(45.0) - frozen.tick(15) - assert manager._get_zero_clock_duration("g1") == pytest.approx(60.0) - - def test_tracking_is_per_game(self, build_manager): - manager = build_manager(_RecentHarness) - with freeze_time("2026-01-20 12:00:00") as frozen: - manager._get_zero_clock_duration("g1") - frozen.tick(30) - assert manager._get_zero_clock_duration("g2") == 0.0 - assert manager._get_zero_clock_duration("g1") == pytest.approx(30.0) - - def test_clear_resets_tracking(self, build_manager): - manager = build_manager(_RecentHarness) - with freeze_time("2026-01-20 12:00:00") as frozen: - manager._get_zero_clock_duration("g1") - frozen.tick(30) - manager._clear_zero_clock_tracking("g1") - assert "g1" not in manager._zero_clock_timestamps - # Restarts from zero after clearing. - assert manager._get_zero_clock_duration("g1") == 0.0 - - def test_clear_unknown_game_is_a_noop(self, build_manager): - manager = build_manager(_RecentHarness) - manager._clear_zero_clock_tracking("never-seen") # must not raise - assert manager._zero_clock_timestamps == {} - - -# --------------------------------------------------------------------------- -# Tier 2a — _is_game_really_over (SportsLive) -# --------------------------------------------------------------------------- - -class TestIsGameReallyOver: - def test_missing_clock_at_late_period_is_not_over(self, build_manager): - """THE baseball regression: no `clock` key at all, period 7. - - The rejected variant coerced a missing clock to the literal "0:00" and - declared the game over — dropping every MLB game from the 5th inning - onward, since baseball has no game clock and `period` is the inning. - A missing clock must fail safe. - """ - manager = build_manager(_LiveHarness) - g = game(period=7, period_text="Top 7th") - assert "clock" not in g - assert manager._is_game_really_over(g) is False - - def test_none_clock_at_late_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=None, period=7, period_text="Top 7th")) is False - - def test_non_string_clock_at_late_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=0, period=9, period_text="Bot 9th")) is False - - def test_blank_clock_at_late_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=" ", period=5, period_text="5th")) is False - - @pytest.mark.parametrize("period_text", ["Final", "final", "Final/OT", - "FINAL", "Final - SO"]) - def test_final_period_text_is_over(self, build_manager, period_text): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="12:00", period=2, period_text=period_text)) is True - - def test_none_period_text_does_not_raise(self, build_manager): - """All nine plugin copies called `.lower()` on `game.get("period_text", "")`, - which is None when the key is present-but-None; `_detect_stale_games` - has no try/except around the call.""" - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="12:00", period=2, period_text=None)) is False - - def test_missing_period_text_does_not_raise(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over(game(clock="12:00", period=2)) is False - - @pytest.mark.parametrize( - "clock", ["0:00", ":00", "00", "000", " 0:00 ", "00:00", "0", "0000"]) - def test_expired_clock_at_final_period_is_over(self, build_manager, clock): - """Every spelling of a zeroed clock counts, not a hand-listed few. - - "00:00" is the one that motivated comparing numerically: it normalizes - to "0000", which matched none of the literals the plugin copies listed, - so a two-digit-minute expired clock kept the game on screen forever. - """ - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=clock, period=4, period_text="Q4")) is True - - def test_none_period_at_expired_clock_does_not_raise(self, build_manager): - """`period` present-but-None: `None >= FINAL_PERIOD` is a TypeError, and - `_detect_stale_games` has no try/except — the same failure shape as the - `period_text` case above.""" - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=None, period_text="Q4")) is False - - def test_none_period_with_running_clock_does_not_raise(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="8:12", period=None, period_text="Q2")) is False - - def test_expired_clock_after_final_period_is_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=5, period_text="OT")) is True - - def test_expired_clock_before_final_period_is_not_over(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=3, period_text="Q3")) is False - - @pytest.mark.parametrize("clock", [":40", "0:40", "1:00"]) - def test_running_clock_is_not_over(self, build_manager, clock): - """Sub-minute clocks like ':40' are legitimate, not expired.""" - manager = build_manager(_LiveHarness) - assert manager._is_game_really_over( - game(clock=clock, period=4, period_text="Q4")) is False - - def test_defaults_are_four_period_countdown(self): - assert SportsLive.FINAL_PERIOD == 4 - assert SportsLive.CLOCK_COUNTS_DOWN is True - - def test_final_period_override_three(self, build_manager): - """Hockey-shaped subclass: regulation ends after period 3.""" - manager = build_manager(_ThreePeriodLiveHarness) - assert manager.FINAL_PERIOD == 3 - assert manager._is_game_really_over( - game(clock="0:00", period=3, period_text="P3")) is True - assert manager._is_game_really_over( - game(clock="0:00", period=2, period_text="P2")) is False - # And the unmodified default still requires period 4. - assert build_manager(_LiveHarness)._is_game_really_over( - game(clock="0:00", period=3, period_text="P3")) is False - - def test_count_up_clock_never_expires(self, build_manager): - """Soccer/AFL/NRL: 0:00 means kickoff, so the clock branch must not run.""" - manager = build_manager(_CountUpLiveHarness) - assert manager.CLOCK_COUNTS_DOWN is False - for period in (1, 2, 4, 9): - assert manager._is_game_really_over( - game(clock="0:00", period=period, period_text="1st Half")) is False - - def test_count_up_clock_still_honors_final_text(self, build_manager): - manager = build_manager(_CountUpLiveHarness) - assert manager._is_game_really_over( - game(clock="0:00", period=2, period_text="Final")) is True - - -# --------------------------------------------------------------------------- -# Tier 2b — _detect_stale_games (SportsLive) -# --------------------------------------------------------------------------- - -class TestDetectStaleGames: - def test_initializer_defaults(self, build_manager): - manager = build_manager(_LiveHarness) - assert manager.game_update_timestamps == {} - assert manager.stale_game_timeout == 300 - - def test_stale_timeout_is_configurable(self, build_manager): - manager = build_manager(_LiveHarness, stale_game_timeout=42) - assert manager.stale_game_timeout == 42 - - def test_mutates_caller_list_in_place_and_returns_none(self, build_manager): - manager = build_manager(_LiveHarness) - fresh = game("1", period_text="P2", clock="10:00", period=2) - over = game("2", period_text="Final", clock="0:00", period=3) - games = [fresh, over] - original = games - - result = manager._detect_stale_games(games) - - assert result is None - assert games is original # same object, mutated in place - assert _ids(games) == ["1"] - - def test_evicts_only_past_timeout_games(self, build_manager): - manager = build_manager(_LiveHarness) - with freeze_time("2026-01-20 12:00:00"): - now = time.time() - manager.game_update_timestamps = { - "1": {"last_seen": now - 10}, # fresh - "2": {"last_seen": now - 299}, # just inside the timeout - "3": {"last_seen": now - 301}, # past the timeout - } - games = [game("1", period_text="P1", clock="10:00", period=1), - game("2", period_text="P1", clock="10:00", period=1), - game("3", period_text="P1", clock="10:00", period=1)] - manager._detect_stale_games(games) - - assert _ids(games) == ["1", "2"] - assert "3" not in manager.game_update_timestamps - assert set(manager.game_update_timestamps) == {"1", "2"} - - def test_unknown_last_seen_is_never_stale(self, build_manager): - """last_seen == 0 (or no entry) means 'never recorded', not 'ancient'.""" - manager = build_manager(_LiveHarness) - games = [game("1", period_text="P1", clock="10:00", period=1), - game("2", period_text="P1", clock="10:00", period=1)] - manager.game_update_timestamps = {"1": {"last_seen": 0}} - manager._detect_stale_games(games) - assert _ids(games) == ["1", "2"] - - def test_removes_games_that_are_really_over(self, build_manager): - manager = build_manager(_LiveHarness) - manager.game_update_timestamps = {"2": {"last_seen": 0}} - games = [game("1", period_text="Q2", clock="5:00", period=2), - game("2", period_text="Final", clock="0:00", period=4)] - manager._detect_stale_games(games) - assert _ids(games) == ["1"] - assert "2" not in manager.game_update_timestamps - - def test_keeps_clockless_late_game(self, build_manager): - """The end-to-end form of the baseball regression: a clockless game in - the 7th must survive the removal path.""" - manager = build_manager(_LiveHarness) - games = [game("mlb-1", period=7, period_text="Top 7th")] - manager._detect_stale_games(games) - assert _ids(games) == ["mlb-1"] - - def test_games_without_id_are_skipped(self, build_manager): - manager = build_manager(_LiveHarness) - no_id = {"home_abbr": "BOS", "away_abbr": "TOR", - "period_text": "Final", "clock": "0:00", "period": 4} - games = [no_id] - manager._detect_stale_games(games) - # `continue` fires before the "really over" check, so it stays. - assert games == [no_id] - - def test_empty_list_is_tolerated(self, build_manager): - manager = build_manager(_LiveHarness) - games = [] - assert manager._detect_stale_games(games) is None - assert games == [] - - def test_removal_is_by_value_not_identity(self, build_manager): - """Sharp edge worth pinning: `list.remove` compares with `dict.__eq__`, - so two structurally-equal dicts drop the FIRST occurrence.""" - manager = build_manager(_LiveHarness) - first = game("1", period_text="Final", clock="0:00", period=4) - twin = dict(first) - games = [first, twin] - manager._detect_stale_games(games) - # Both entries are removed here (two iterations, two removals), but the - # first removal deletes `first`, not the dict being iterated. - assert games == [] - - -# --------------------------------------------------------------------------- -# Tier 3a — _select_games_for_display (SportsUpcoming) -# --------------------------------------------------------------------------- - -class TestSelectGamesForDisplay: - def test_no_favorites_returns_all_sorted_ascending(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("late", start=at(20)), game("early", start=at(10)), - game("mid", start=at(15))] - assert _ids(manager._select_games_for_display(games, [])) == [ - "early", "mid", "late"] - - def test_missing_start_time_sorts_last(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("none", start=None), game("early", start=at(10))] - assert _ids(manager._select_games_for_display(games, [])) == [ - "early", "none"] - - def test_filters_to_favorite_teams(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("1", home="BOS", away="TOR", start=at(10)), - game("2", home="NYR", away="PIT", start=at(11)), - game("3", home="MTL", away="BOS", start=at(12))] - assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["1", "3"] - - def test_respects_upcoming_games_to_show_per_team(self, build_manager): - manager = build_manager(_UpcomingHarness, upcoming_games_to_show=2) - games = [game(str(i), home="BOS", away="TOR", start=at(10 + i)) - for i in range(5)] - assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["0", "1"] - - def test_game_between_two_favorites_counts_for_both(self, build_manager): - manager = build_manager(_UpcomingHarness, upcoming_games_to_show=1) - games = [game("shared", home="BOS", away="TOR", start=at(10)), - game("bos2", home="BOS", away="NYR", start=at(11)), - game("tor2", home="TOR", away="PIT", start=at(12))] - # "shared" fills both BOS's and TOR's single slot, so nothing else fits. - assert _ids(manager._select_games_for_display( - games, ["BOS", "TOR"])) == ["shared"] - - def test_deduplicates_by_game_id(self, build_manager): - manager = build_manager(_UpcomingHarness, upcoming_games_to_show=5) - g = game("dupe", home="BOS", away="TOR", start=at(10)) - assert _ids(manager._select_games_for_display( - [g, dict(g)], ["BOS"])) == ["dupe"] - - def test_non_favorite_games_excluded(self, build_manager): - manager = build_manager(_UpcomingHarness) - games = [game("1", home="NYR", away="PIT", start=at(10))] - assert manager._select_games_for_display(games, ["BOS"]) == [] - - def test_favorite_key_seam_supports_id_matching(self, build_manager): - """The NRL case: two clubs share the abbreviation 'NEW', so favorites - must be matched on team id. Only the seam changes — the promoted - method is identical.""" - games = [ - game("knights", home="NEW", away="SYD", home_id=1, away_id=2, - start=at(10)), - game("warriors", home="NEW", away="SYD", home_id=99, away_id=2, - start=at(11)), - ] - abbr_manager = build_manager(_UpcomingHarness) - # Abbreviation matching cannot tell the two "NEW" clubs apart. - assert _ids(abbr_manager._select_games_for_display(games, ["NEW"])) == [ - "knights", "warriors"] - - id_manager = build_manager(_IdFavoriteUpcomingHarness) - assert _ids(id_manager._select_games_for_display(games, ["99"])) == [ - "warriors"] - - def test_favorite_key_none_never_matches(self, build_manager): - """`_favorite_key` returning None (missing id) must not match, even - against a favorites list holding the string 'None'.""" - manager = build_manager(_IdFavoriteUpcomingHarness) - games = [game("1", home="BOS", away="TOR", start=at(10))] # no ids - assert manager._select_games_for_display(games, ["None"]) == [] - - -# --------------------------------------------------------------------------- -# Tier 3b — _select_recent_games_for_display (SportsRecent) -# --------------------------------------------------------------------------- - -class TestSelectRecentGamesForDisplay: - def test_no_favorites_returns_all_sorted_descending(self, build_manager): - manager = build_manager(_RecentHarness) - games = [game("early", start=at(10)), game("late", start=at(20)), - game("mid", start=at(15))] - assert _ids(manager._select_recent_games_for_display(games, [])) == [ - "late", "mid", "early"] - - def test_missing_start_time_sorts_last(self, build_manager): - manager = build_manager(_RecentHarness) - games = [game("none", start=None), game("late", start=at(20))] - assert _ids(manager._select_recent_games_for_display(games, [])) == [ - "late", "none"] - - def test_respects_recent_games_to_show_per_team(self, build_manager): - manager = build_manager(_RecentHarness, recent_games_to_show=2) - games = [game(str(i), home="BOS", away="TOR", start=at(10 + i)) - for i in range(5)] - # Most recent first. - assert _ids(manager._select_recent_games_for_display( - games, ["BOS"])) == ["4", "3"] - - def test_game_between_two_favorites_counts_for_both(self, build_manager): - manager = build_manager(_RecentHarness, recent_games_to_show=1) - games = [game("shared", home="BOS", away="TOR", start=at(20)), - game("bos2", home="BOS", away="NYR", start=at(19)), - game("tor2", home="TOR", away="PIT", start=at(18))] - assert _ids(manager._select_recent_games_for_display( - games, ["BOS", "TOR"])) == ["shared"] - - def test_deduplicates_by_game_id(self, build_manager): - manager = build_manager(_RecentHarness, recent_games_to_show=5) - g = game("dupe", home="BOS", away="TOR", start=at(10)) - assert _ids(manager._select_recent_games_for_display( - [g, dict(g)], ["BOS"])) == ["dupe"] - - def test_non_favorite_games_excluded(self, build_manager): - manager = build_manager(_RecentHarness) - games = [game("1", home="NYR", away="PIT", start=at(10))] - assert manager._select_recent_games_for_display(games, ["BOS"]) == [] - - def test_favorite_key_seam_supports_id_matching(self, build_manager): - games = [ - game("knights", home="NEW", away="SYD", home_id=1, away_id=2, - start=at(10)), - game("warriors", home="NEW", away="SYD", home_id=99, away_id=2, - start=at(11)), - ] - id_manager = build_manager(_IdFavoriteRecentHarness) - assert _ids(id_manager._select_recent_games_for_display( - games, ["99"])) == ["warriors"] - - -# --------------------------------------------------------------------------- -# Seam / inertness guards -# --------------------------------------------------------------------------- - -class TestPromotionShape: - def test_promoted_methods_live_on_the_right_classes(self): - assert hasattr(SportsRecent, "_get_zero_clock_duration") - assert hasattr(SportsRecent, "_clear_zero_clock_tracking") - assert hasattr(SportsRecent, "_select_recent_games_for_display") - assert hasattr(SportsUpcoming, "_select_games_for_display") - assert hasattr(SportsLive, "_is_game_really_over") - assert hasattr(SportsLive, "_detect_stale_games") - - def test_modes_does_not_define_the_favorite_key_seam(self): - """`_favorite_key` is a SportsCore override point. modes.py must call - it, never define it — this test fails if the seam is added in the - wrong file.""" - import src.base_classes.sports.modes as modes - - for cls in (SportsUpcoming, SportsRecent, SportsLive): - assert "_favorite_key" not in vars(cls) - assert "_favorite_key" not in modes.__dict__ diff --git a/test/test_sports_odds_fanout.py b/test/test_sports_odds_fanout.py deleted file mode 100644 index c33930e9..00000000 --- a/test/test_sports_odds_fanout.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Odds must be fetched for the games shown, not every game in the window. - -SportsUpcoming.update() collected every upcoming game in the schedule window -and called _fetch_odds() on each one *inside* that collection loop, narrowing -to upcoming_games_to_show only afterwards. The comment there said odds were -fetched "only for games that will be displayed", but the sole narrowing it -applied was show_favorite_teams_only, which is not the default -- so in the -usual configuration nothing narrowed it at all. - -Measured on a live rig: a college league produced 946 upcoming games in one -cycle and displayed 1 of them. The same shape on the football plugin produced -a burst of 467 sequential ESPN requests that ran for 35s and blew that -plugin's 30s update budget, and it repeats every time the 1h odds TTL expires. - -SportsLive is deliberately different: it walks the raw event list because it -has to find which games are live, but only fetches odds for a game that has -already passed the is_live/is_halftime test, so the fan-out is bounded by how -many games are actually in progress. -""" -import ast -from pathlib import Path - -import pytest - -MODES = (Path(__file__).resolve().parent.parent - / "src" / "base_classes" / "sports" / "modes.py") -TREE = ast.parse(MODES.read_text(encoding="utf-8")) - - -def _fetch_sites(): - """(class name, method name, lineno) for every self._fetch_odds(...) call.""" - calls = [n.lineno for n in ast.walk(TREE) - if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) - and n.func.attr == "_fetch_odds"] - sites = [] - for cls in [n for n in ast.walk(TREE) if isinstance(n, ast.ClassDef)]: - for fn in [n for n in cls.body if isinstance(n, ast.FunctionDef)]: - for lineno in calls: - if fn.lineno <= lineno <= (fn.end_lineno or fn.lineno): - sites.append((cls.name, fn.name, lineno)) - assert len(sites) == len(calls), "a _fetch_odds call sits outside any method" - return sites - - -def _innermost_loop_iterable(lineno): - best = None - for node in ast.walk(TREE): - if isinstance(node, ast.For) and \ - node.lineno <= lineno <= (node.end_lineno or node.lineno): - if best is None or node.lineno > best.lineno: - best = node - return None if best is None else ast.unparse(best.iter) - - -def _spans(body, lineno): - """True when `lineno` falls inside this list of statements.""" - return any(n.lineno <= lineno <= (n.end_lineno or n.lineno) for n in body) - - -def _parents(tree): - table = {} - for node in ast.walk(tree): - for child in ast.iter_child_nodes(node): - table[child] = node - return table - - -PARENTS = _parents(TREE) - - -def _mentions_positively(test, names): - """True when `test` references every name, none of them under a `not`. - - Structural, not textual. Matching the unparsed source would accept - `not (details["is_live"] or details["is_halftime"])` -- which selects - exactly the non-live games this guard exists to exclude -- because the - names still appear in the text. - """ - found = set() - for node in ast.walk(test): - if not (isinstance(node, ast.Constant) and node.value in names): - continue - negated = False - cursor = node - while cursor is not test and cursor in PARENTS: - cursor = PARENTS[cursor] - if isinstance(cursor, ast.UnaryOp) and isinstance(cursor.op, ast.Not): - negated = True - break - if not negated: - found.add(node.value) - return found >= set(names) - - -def _guarded_by_positive(lineno, names): - """True when some enclosing `if` runs this line only if `names` hold. - - Only the TRUE branch counts: an `if` whose `else` contains the call would - otherwise look like a guard while doing the opposite. - """ - for node in ast.walk(TREE): - if isinstance(node, ast.If) and _spans(node.body, lineno) \ - and _mentions_positively(node.test, names): - return True - return False - - -def test_every_fetch_site_is_accounted_for(): - """A new call site must be classified deliberately, not inherited silently.""" - found = {(cls, fn) for cls, fn, _ in _fetch_sites()} - assert found == {("SportsUpcoming", "update"), ("SportsLive", "update")}, ( - f"unexpected _fetch_odds call sites: {sorted(found)}. Each one is a " - "sequential ESPN request per game -- classify it here on purpose.") - - -def test_upcoming_fetches_only_the_selected_games(): - for cls, _fn, lineno in _fetch_sites(): - if cls != "SportsUpcoming": - continue - iterable = _innermost_loop_iterable(lineno) - assert iterable == "team_games", ( - f"SportsUpcoming._fetch_odds at line {lineno} iterates over " - f"{iterable!r}. It must run over team_games -- already narrowed to " - "upcoming_games_to_show -- not over every event in the schedule " - "window. Each item costs one sequential ESPN request.") - - -def test_live_only_fetches_for_games_actually_in_progress(): - for cls, _fn, lineno in _fetch_sites(): - if cls != "SportsLive": - continue - assert _guarded_by_positive(lineno, {"is_live", "is_halftime"}), ( - f"SportsLive._fetch_odds at line {lineno} does not sit in the true " - "branch of a test requiring the game to be in progress. Without " - "that, it fans out across the whole event list -- one sequential " - "ESPN request per game.") diff --git a/test/test_store_non_plugin_entries.py b/test/test_store_non_plugin_entries.py new file mode 100644 index 00000000..af2195a6 --- /dev/null +++ b/test/test_store_non_plugin_entries.py @@ -0,0 +1,79 @@ +"""Registry entries that aren't plugins are hidden and refused. + +The official registry no longer lists skins, but a custom registry (added +from the Plugin Store) can still carry ``"type": "skin"`` entries. With the +skin system removed, installing one as a plugin would unpack it into the +plugins directory, so the store hides such entries and refuses them at install. +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from src.plugin_system.store_manager import PluginStoreManager +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401 + +PLUGIN = {"id": "clock", "name": "Clock", "repo": "https://github.com/x/clock"} +UNTYPED = {"id": "news", "name": "News", "repo": "https://github.com/x/news"} +SKIN = {"id": "retro", "name": "Retro", "type": "skin", "repo": "https://github.com/x/retro"} + + +@pytest.mark.parametrize("entry,expected", [ + (PLUGIN, True), + ({**PLUGIN, "type": "plugin"}, True), + ({**PLUGIN, "type": None}, True), + (SKIN, False), + ({**PLUGIN, "type": "theme"}, False), + (None, False), + ("clock", False), +]) +def test_is_plugin_entry(entry, expected): + assert PluginStoreManager.is_plugin_entry(entry) is expected + + +def test_install_refuses_a_non_plugin_entry(tmp_path): + store = PluginStoreManager(plugins_dir=str(tmp_path / "plugins")) + store.get_plugin_info = MagicMock(return_value=dict(SKIN)) + store._install_from_monorepo_zip = MagicMock() + store._install_via_git = MagicMock() + assert store._install_plugin_impl("retro") is False + assert not (tmp_path / "plugins" / "retro").exists() + store._install_from_monorepo_zip.assert_not_called() + store._install_via_git.assert_not_called() + + +@pytest.fixture +def store(api_v3_module): + mock = MagicMock() + mock.is_plugin_entry = PluginStoreManager.is_plugin_entry + api_v3_module.api_v3.plugin_store_manager = mock + return mock + + +def test_custom_registry_listing_hides_non_plugins(api_v3_client, store): + store.fetch_registry_from_url.return_value = {"plugins": [PLUGIN, SKIN, UNTYPED]} + resp = api_v3_client.post("/api/v3/plugins/registry-from-url", + data=json.dumps({"repo_url": "https://github.com/x/registry"}), + content_type="application/json") + assert resp.status_code == 200 + assert [p["id"] for p in resp.get_json()["plugins"]] == ["clock", "news"] + + +def test_store_listing_hides_non_plugins(api_v3_client, store): + store.search_plugins.return_value = [PLUGIN, SKIN, UNTYPED] + resp = api_v3_client.get("/api/v3/plugins/store/list") + assert resp.status_code == 200 + body = resp.get_json() + listed = body.get("data", body).get("plugins", []) + assert [p["id"] for p in listed] == ["clock", "news"] + + +def test_install_route_refuses_a_non_plugin_entry(api_v3_client, store): + store.get_registry_info.return_value = dict(SKIN) + resp = api_v3_client.post("/api/v3/plugins/install", + data=json.dumps({"plugin_id": "retro"}), + content_type="application/json") + assert resp.status_code == 400 + assert "not a plugin" in resp.get_json()["message"] + store.install_plugin.assert_not_called() diff --git a/test/test_version_comparison_consistency.py b/test/test_version_comparison_consistency.py index 9a27b59c..d111c4de 100644 --- a/test/test_version_comparison_consistency.py +++ b/test/test_version_comparison_consistency.py @@ -9,9 +9,9 @@ can never disagree. (Historically the store used raw string equality, which reinstalled over cosmetic differences like "v1.2.0" vs "1.2.0" and even DOWNGRADED locally-ahead plugins; this file's tests killed that.) -Two other version parsers legitimately remain and are pinned here so they -don't drift: `compatibility.parse_semver` (the install-compatibility gate, -range-spec oriented) and `skin_runtime._major` (skin API major gate). +One other version parser legitimately remains and is pinned here so it +doesn't drift: `compatibility.parse_semver` (the install-compatibility gate, +range-spec oriented). """ import json @@ -21,7 +21,6 @@ import pytest from packaging.version import parse as pkg_parse from src.plugin_system.compatibility import is_update_available, parse_semver -from src.skin_system.skin_runtime import _major from src.plugin_system.store_manager import PluginStoreManager from web_interface.blueprints.api_v3 import _is_plugin_update_available @@ -142,25 +141,6 @@ class TestStoreManagerUsesSharedComparator: reinstall.assert_not_called() -class TestSkinRuntimeMajor: - def test_plain_versions(self): - assert _major("1.0.0") == 1 - assert _major("2.1") == 2 - - def test_int_input_tolerated(self): - assert _major(2) == 2 - - def test_garbage_returns_none(self): - assert _major("garbage") is None - assert _major(None) is None - - def test_v_prefix_not_tolerated(self): - # Unlike parse_semver, _major does NOT strip a leading 'v' — - # a skin.json declaring "v1.0.0" fails the API gate. Characterized - # so a manifest-format loosening elsewhere doesn't silently diverge. - assert _major("v1.0.0") is None - - class TestParseSemverAgreesWithPackaging: """parse_semver and packaging must agree on ordering for plain X.Y.Z — the region where the two ecosystems overlap and must never diverge.""" diff --git a/test/web_interface/test_plugin_config_json_saves.py b/test/web_interface/test_plugin_config_json_saves.py index 5d0c50f6..6e3e9792 100644 --- a/test/web_interface/test_plugin_config_json_saves.py +++ b/test/web_interface/test_plugin_config_json_saves.py @@ -8,7 +8,9 @@ test_api_v3_secret_roundtrip.py, so assertions are on config.json itself. - Legacy booleans (#588) were normalized only at load, so posting back what GET /plugins/config returned failed validation. - The JSON save's filter kept only enabled/display_duration/live_priority, so - a submitted skin, skin_options or vegas_* tuning key was silently dropped. + a submitted vegas_* tuning key was silently dropped. +- ``skin`` and ``skin_options`` outlived the skin system in stored configs: + every save path must accept a section carrying them, and drop them. - Plugin sections posted to /config/main skipped all of that and were stored verbatim, including values /plugins/config rejects. """ @@ -65,7 +67,9 @@ STORED = { "display": {"brightness": 80, "show_icons": False}, "global": {"dynamic_duration": {"enabled": False, "min_duration_seconds": 45}}, "vegas_width_pct": 60, + # Written by a release that still had the skin system "skin": "neon", + "skin_options": {"accent": "#00ff00"}, } _ATTRS = ('config_manager', 'plugin_manager', 'plugin_store_manager', @@ -174,28 +178,64 @@ class TestCoreOwnedKeysSurviveTheFilter: env.store({"enabled": True, "city": "Paris"}) resp = env.save({ "vegas_width_pct": 50, "vegas_overflow": "truncate", - "vegas_max_width_screens": 2, "skin": "retro", - "skin_options": {"accent": "#ff0000"}, "live_priority": True, + "vegas_max_width_screens": 2, "live_priority": True, }) assert resp.status_code == 200, resp.get_json() stored = env.stored() assert stored["vegas_width_pct"] == 50 assert stored["vegas_overflow"] == "truncate" assert stored["vegas_max_width_screens"] == 2 - assert stored["skin"] == "retro" - assert stored["skin_options"] == {"accent": "#ff0000"} assert stored["live_priority"] is True def test_stored_core_keys_survive_an_unrelated_save(self, env): assert env.save({"city": "Nice"}).status_code == 200 stored = env.stored() - assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon" + assert stored["vegas_width_pct"] == 60 def test_a_non_core_unknown_key_is_still_filtered(self, env): assert env.save({"not_in_schema": 1}).status_code == 200 assert "not_in_schema" not in env.stored() +class TestRetiredSkinKeys: + """STORED carries skin/skin_options, and SCHEMA sets + additionalProperties: false without declaring them.""" + + @staticmethod + def _assert_dropped(stored): + assert "skin" not in stored and "skin_options" not in stored + assert stored["vegas_width_pct"] == 60 # still a core-owned key + + def test_json_save_accepts_and_drops_them(self, env): + resp = env.save({"city": "Nice"}) + assert resp.status_code == 200, resp.get_json() + assert env.stored()["city"] == "Nice" + self._assert_dropped(env.stored()) + + def test_form_save_accepts_and_drops_them(self, env): + resp = env.client.post(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}", + data={"city": "Nice"}) + assert resp.status_code == 200, resp.get_json() + assert env.stored()["city"] == "Nice" + self._assert_dropped(env.stored()) + + def test_config_main_accepts_and_drops_them(self, env): + resp = env.client.post("/api/v3/config/main", json={PLUGIN_ID: {"city": "Nice"}}) + assert resp.status_code == 200, resp.get_json() + self._assert_dropped(env.stored()) + + def test_submitted_ones_are_not_stored(self, env): + env.store({"enabled": True, "city": "Paris", "vegas_width_pct": 60}) + resp = env.save({"skin": "retro", "skin_options": {"accent": "#ff0000"}}) + assert resp.status_code == 200, resp.get_json() + self._assert_dropped(env.stored()) + + def test_get_config_leaves_them_out(self, env): + data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"] + assert data["city"] == "Paris" + assert "skin" not in data and "skin_options" not in data + + class TestLegacyBooleans: LEGACY = {"enabled": True, "city": "Paris", "global": {"dynamic_duration": True}} @@ -246,7 +286,7 @@ class TestPluginSectionsInConfigMain: assert stored["city"] == "Lyon" assert stored["display"] == {"brightness": 80, "show_icons": False} assert stored["vegas_overflow"] == "rotate" - assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon" + assert stored["vegas_width_pct"] == 60 def test_secrets_still_go_to_the_secrets_file(self, env): resp = self._post(env, {PLUGIN_ID: {"api_key": "s3cret"}}) diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index 4119a1d8..aa7f2d03 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -1261,9 +1261,8 @@ def _enhance_schema_with_core_properties(schema): """ Enhance schema with the core-owned per-plugin properties. - ``enabled``, ``display_duration``, ``live_priority``, ``skin``, - ``skin_options`` and the ``vegas_*`` tuning keys are system-managed and - always allowed, even when the plugin's schema doesn't declare them. The + ``enabled``, ``display_duration``, ``live_priority`` and the ``vegas_*`` + tuning keys are system-managed and always allowed, even when the plugin's schema doesn't declare them. The list is ``schema_manager.CORE_PLUGIN_PROPERTIES``, the one validation uses, so the save filter keeps exactly what validation accepts. diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index 23458f24..0cc3eeda 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -1056,10 +1056,14 @@ def save_main_config(): if error: return error - # Deep merge regular config into main config + # Deep merge regular config into main config, dropping + # retired core keys (skin, skin_options) from the stored section + from src.plugin_system.schema_manager import drop_retired_plugin_keys stored_section = current_config.get(plugin_id) current_config[plugin_id] = deep_merge( - stored_section if isinstance(stored_section, dict) else {}, regular_config) + drop_retired_plugin_keys( + stored_section if isinstance(stored_section, dict) else {}, schema), + regular_config) if secrets_config: plugin_secrets_updates[plugin_id] = secrets_config diff --git a/web_interface/blueprints/api_v3/misc.py b/web_interface/blueprints/api_v3/misc.py index ddcb8790..8e49b7e7 100644 --- a/web_interface/blueprints/api_v3/misc.py +++ b/web_interface/blueprints/api_v3/misc.py @@ -1,5 +1,5 @@ """Routes with no larger group of their own: errors, integrations, -cache, sync, skins, logs, health and hardware. +cache, sync, logs, health and hardware. Routes decorate the shared `api_v3` Blueprint from ._common, so their endpoint names are unchanged by living here. @@ -150,51 +150,6 @@ def get_hardware_status(): except Exception: logger.error("Unexpected error reading hardware status", exc_info=True) return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500 -@api_v3.route('/skins', methods=['GET']) -def list_skins(): - """List installed visual skins (docs/SKIN_SYSTEM.md). - - Optional ?plugin_id=... filters to skins matching that plugin. - - The response carries ``supported: false`` and a ``message``: the current - scoreboard plugins don't render skins, so a client must not present - these as selectable. - """ - try: - from src.skin_system import ( - SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE, skin_runtime, - ) - - plugin_id = request.args.get('plugin_id') - if plugin_id: - skins = skin_runtime.skins_for_plugin(plugin_id) - else: - # The discovery cache self-invalidates on directory/manifest - # mtime changes, so no force_refresh — keeps Pi disk I/O down. - skins = skin_runtime.discover_skins() - - payload = [] - for skin_id, manifest in sorted(skins.items()): - skin_dir = Path(manifest['_skin_dir']) - preview = manifest.get('preview') - payload.append({ - 'id': skin_id, - 'name': manifest.get('name', skin_id), - 'version': manifest.get('version'), - 'author': manifest.get('author'), - 'description': manifest.get('description', ''), - 'skin_api_version': manifest.get('skin_api_version'), - 'targets': manifest.get('targets', {}), - 'modes': manifest.get('modes', []), - 'has_preview': bool(preview and (skin_dir / preview).is_file()), - }) - data = {'skins': payload, 'supported': SKINS_RENDER_SUPPORTED} - if not SKINS_RENDER_SUPPORTED: - data['message'] = SKINS_UNSUPPORTED_MESSAGE - return jsonify({'status': 'success', 'data': data}) - except Exception as e: - logger.error('Error in list_skins', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/logs', methods=['GET']) def get_logs(): """Get system logs from journalctl""" diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index eba6b56e..b293d321 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -1296,15 +1296,16 @@ def install_plugin(): plugin_id = data['plugin_id'] branch = data.get('branch') # Optional branch parameter - # A registry skin would install but never render with the current - # scoreboards; refuse it with the reason rather than a generic failure + # A registry entry that isn't a plugin (a custom registry can still + # list old "type": "skin" entries) gets a clear refusal, not a failed + # install. try: registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id) except Exception: registry_entry = None - if isinstance(registry_entry, dict) and (registry_entry.get('type') or 'plugin') == 'skin': - from src.skin_system import SKINS_UNSUPPORTED_MESSAGE - return jsonify({'status': 'error', 'message': SKINS_UNSUPPORTED_MESSAGE}), 400 + if isinstance(registry_entry, dict) and not api_v3.plugin_store_manager.is_plugin_entry(registry_entry): + return jsonify({'status': 'error', + 'message': f"{plugin_id} is a {registry_entry.get('type')!r} entry, not a plugin"}), 400 # Install the plugin # Log the plugins directory being used for debugging @@ -1506,13 +1507,10 @@ def get_registry_from_url(): registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url) if registry: - # Skins aren't offered: current scoreboards don't render them return jsonify({ 'status': 'success', - 'plugins': [ - p for p in registry.get('plugins', []) - if not (isinstance(p, dict) and (p.get('type') or 'plugin') == 'skin') - ], + 'plugins': [p for p in registry.get('plugins', []) + if api_v3.plugin_store_manager.is_plugin_entry(p)], 'registry_url': repo_url }) else: @@ -1627,9 +1625,7 @@ def list_plugin_store(): # Format plugins for the web interface formatted_plugins = [] for plugin in plugins: - # Registry skins install but never render with the current - # scoreboards, so the store doesn't offer them - if (plugin.get('type') or 'plugin') == 'skin': + if not api_v3.plugin_store_manager.is_plugin_entry(plugin): continue formatted_plugins.append({ 'id': plugin.get('id'), @@ -2210,7 +2206,10 @@ def save_plugin_config(): if plugin_id not in current_config: current_config[plugin_id] = {} - current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config) + # Retired core keys (skin, skin_options) leave the stored section here + from src.plugin_system.schema_manager import drop_retired_plugin_keys + current_config[plugin_id] = deep_merge( + drop_retired_plugin_keys(current_config[plugin_id], schema), regular_config) # Deep merge plugin secrets in secrets config if secrets_config: @@ -2755,11 +2754,6 @@ def get_plugin_schema(): schema = schema_mgr.load_schema(plugin_id, use_cache=True) if schema: - # No "Visual Skin" dropdown: the current scoreboard plugins don't - # render skins (src.skin_system.SKINS_UNSUPPORTED_MESSAGE), so - # schema_mgr.inject_skin_selector is deliberately not called. A - # stored "skin" value is unaffected: validation still allows it - # and a form save deep-merges over the stored section, keeping it. return jsonify({'status': 'success', 'data': {'schema': schema}}) # Return a simple default schema if file not found