From a12be7c3c596842d31878397df3c9b260a91d7ec Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:32:07 -0400 Subject: [PATCH] feat(sports): live Vegas cards for the scoreboards (shared layer) (#698) * perf(timing): say which render-thread work a late frame followed The soak already says how often a moving frame reached the panel late, but not what the render thread was doing just before it. Vegas does two kinds of work there between frames -- building its strip (compose, extend) and, with live elements, patching changed pixels into it -- and deciding whether either is affordable needs their own numbers. - FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame. Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind; aggregate() still takes frames without ops. The file schema is unchanged. - Vegas tags compose and every strip extension (with the bytes it copied). - frame_soak prints an "after work" table: frames, late %, freezes and MB moved per kind, only when something tagged its work. - render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes / --patch-every / --patch-where (in-place column writes, as a live element update does) and --extend-every-screens / --extend-width (append + trim on a fixed cadence that holds the strip's width). No runtime behaviour changes: this is the measurement gate for live Vegas elements. Co-Authored-By: Claude Opus 5.5 * docs(changelog): note the frame-op attribution and bench modes Co-Authored-By: Claude Opus 5.5 * perf(scroll): build the strip's PIL image only when something reads it Every Vegas strip extension rebuilt ScrollHelper.cached_image from cached_array in full, twice (append, then trim), on the render thread: Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a Pi 4 (measured on ledpi), about two thirds of an extension's render-thread cost. Nothing on the frame path reads the image's pixels; every frame is cut from the array. cached_image is now a property. append_content and drop_scrolled_prefix defer it; the first read builds it from the array it started with and keeps it only if the strip has not changed meanwhile, so a sync push racing an extension cannot leave a stale image cached. Assigning cached_image stores exactly what was assigned, as before. has_strip() says whether there is a strip without building its image; the helper's frame path, Vegas and the adapter's scroll-cache invalidation use it. The strip is also no longer held in memory twice. In Vegas the image is now built only by a multi-display sync push. Co-Authored-By: Claude Opus 5.5 * feat(vegas): live elements -- a plugin API for content that changes while it scrolls Vegas bakes each plugin's pictures into one strip, so a card already on its way across the panel keeps what it showed when it was drawn. This adds the API and bookkeeping for content that can be updated in place; the worker that redraws and swaps it follows separately. No shipped plugin implements the hook yet, so nothing changes for users. Plugin API (core 3.8.0), all no-ops by default: - BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version, live, refresh_hz)]: named, fixed-width pieces of Vegas content. - BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free redraw for content that changes with time. - BasePlugin.notify_vegas_data_changed(): data that lands outside update(). - src/plugin_system/vegas_elements.py (VegasElement, re-exported from base_plugin). Core: - PluginAdapter asks a plugin that implements the hook for elements on the background fetch only (under its lock, on its own canvas); every other path keeps get_vegas_content(). Live elements are pinned (padded with content_padding, never trimmed), tagged with their key, digest and data epoch in Image.info so the existing cache and group plumbing carry them unchanged, and untagged if a width budget crops them. - RenderPipeline records where each live element lands (ElementRecord), in absolute strip columns a trim does not move; the block-start arithmetic is shared with the STATIC markers. - PluginManager update listeners (add/remove_update_listener, notify_data_changed): told the moment update() completes, not at the next ~4s Vegas poll. The coordinator uses one to move each plugin's data epoch on. - vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval, live_lead_screens; per-plugin core-owned vegas_live. Live elements are off under multi-display sync, in swap mode and with offscreen_prefetch off. - scripts/check_plugin.py checks the element contract (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub is a working example. Co-Authored-By: Claude Opus 5.5 * feat(vegas): live elements update in place while they scroll One background worker (src/vegas_mode/live_worker.py) redraws a plugin's live elements when its data epoch moves on (update listener) or on their refresh_hz, nearest the screen first, and hands changed pixels lock-free to the render thread, which copies them into the strip between frames (RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most four patches or two screens of bytes a frame, no drawing or locks there. The worker takes over group prefetch once a live element is placed, runs inside the render gate, and is supervised. Update tick 1s while live elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md describes what was built and why SegmentStrip was not needed. Co-Authored-By: Claude Opus 5.5 * feat(sports): live Vegas cards for the scoreboards (shared layer) One live element per game, drawn only when what the card shows changes, so a score changes on a card already crossing the panel. The shared part, so each scoreboard adopts it in a few lines: - src/common/sports_vegas.py: game_key, game_fingerprint (the whole game dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache, StickyOdds (odds a live poll left out stay drawn), finished_games / with_finished_games (a game that just went final keeps its card, after its league's live games; one a heuristic only judged over keeps its live state, so a tied end of regulation never shows FINAL early). - SportsScrollDisplay.make_vegas_renderer() is the override point; build_vegas_elements() and SportsScrollDisplayManager .get_vegas_elements_for() do the rest. A card's version includes its teams' ranks, which the renderer draws from the rankings cache. - SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot(): held for FINISHED_GAME_TTL after it leaves the live list. A sport that does not implement make_vegas_renderer keeps its ordinary Vegas content, so no scoreboard changes until it opts in. scripts/render_plugin.py --vegas renders a plugin's Vegas block as the ticker lays it out, and --timeline stacks it at successive moments as the ticker would update it in place; the join is now render_pipeline.join_plugin_rows(). Co-Authored-By: Claude Opus 5.5 * refactor(sports): a default _determine_game_type on SportsScrollDisplay render_vegas_card looked the method up with getattr and a None default, which static analysis (Codacy) reports as calling something that may not be callable. The base class now has the default -- the card type from the game's state -- and the plugins that define their own override it as before. Co-Authored-By: Claude Opus 5.5 * fix: review follow-ups on the shared live-card layer - The reused Vegas renderer always gets the current rankings, empty included, so ranks cleared since are not kept drawn. - render_plugin.py: --timeline refuses --no-live (a timeline shows live elements changing), --timeline/--no-live need --vegas, and the Vegas paths create the output's directory like the display path does. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 22 ++ mypy-clean.txt | 1 + scripts/render_plugin.py | 57 ++++ src/common/README.md | 12 + src/common/sports_scroll.py | 186 ++++++++++++- src/common/sports_shared.py | 49 ++++ src/common/sports_vegas.py | 250 +++++++++++++++++ src/plugin_system/testing/vegas.py | 85 ++++++ src/vegas_mode/render_pipeline.py | 82 +++--- test/test_harness_vegas_elements.py | 28 ++ test/test_sports_vegas_elements.py | 415 ++++++++++++++++++++++++++++ 11 files changed, 1150 insertions(+), 37 deletions(-) create mode 100644 src/common/sports_vegas.py create mode 100644 test/test_sports_vegas_elements.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e966511a..97238819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -384,6 +384,28 @@ read any of them: the Vegas update tick runs every second instead of every four. - Web UI: "Update live content while it scrolls" under Vegas mode's Cycle Pacing (`display.vegas_scroll.live_refresh`). +- **Live cards for the scoreboards (shared code).** New module + `src/common/sports_vegas.py`: `game_key()`, `dedupe_games()`, + `VegasCardCache` (draws a card only when its fingerprint changes) and + `StickyOdds` (keeps a card's odds through a live poll that left them out), + `finished_games()` and `with_finished_games()` (a game that just went final + keeps its card, showing FINAL, where its live card was). + `SportsScrollDisplay` gains `make_vegas_renderer()` (the override point; a + sport that does not implement it keeps its ordinary Vegas content), + `render_vegas_card()`, `vegas_separator()` and `build_vegas_elements()`, + and `SportsScrollDisplayManager` gains `get_vegas_elements_for()`. + `SportsLiveSharedMixin` gains `_record_finished_game()` / + `finished_games_snapshot()`, so a game that goes final keeps a card to show + FINAL on until the hourly recent list takes it over. +- `scripts/render_plugin.py --vegas` renders a plugin's block of the Vegas + strip as the ticker lays it out (live elements, or with `--no-live` its + ordinary content) and writes the live elements' keys and columns beside + it. `--timeline ROWS` stacks the block at successive moments as the + ticker would update it in place (`--timeline-step`, and + `--timeline-update` to run `update()` between rows). + `render_vegas_strip()` and `render_vegas_timeline()` in + `src/plugin_system/testing/vegas.py`; the join is now + `render_pipeline.join_plugin_rows()`. ### Scrolling diff --git a/mypy-clean.txt b/mypy-clean.txt index 9564f28e..c7a16f8e 100644 --- a/mypy-clean.txt +++ b/mypy-clean.txt @@ -37,6 +37,7 @@ src/common/sports_celebration.py src/common/sports_fetch.py src/common/sports_scroll.py src/common/sports_timezone.py +src/common/sports_vegas.py src/config_service.py src/core_config_keys.py src/deprecation.py diff --git a/scripts/render_plugin.py b/scripts/render_plugin.py index 6db85c1b..fc92c811 100644 --- a/scripts/render_plugin.py +++ b/scripts/render_plugin.py @@ -56,9 +56,33 @@ def main() -> int: help='Display mode to render, for plugins that declare ' 'more than one in their manifest (e.g. nrl_live). ' 'Omitted, the plugin picks its own default.') + parser.add_argument('--vegas', action='store_true', + help="Render the plugin's block of the Vegas ticker strip " + "instead of display(): its live elements if it has " + "them, else its Vegas content, laid out as the " + "ticker lays them out. Also writes the live " + "elements' keys and columns to .json") + parser.add_argument('--no-live', action='store_true', + help="With --vegas: ignore live elements and render the " + "plugin's ordinary Vegas content (for before/after)") + parser.add_argument('--timeline', type=int, default=0, metavar='ROWS', + help="With --vegas: render ROWS rows, each the block a " + "--timeline-step later as the ticker would update it " + "in place (animated elements redrawn for that moment)") + parser.add_argument('--timeline-step', type=float, default=0.25, metavar='SECONDS', + help="Seconds between --timeline rows (default 0.25)") + parser.add_argument('--timeline-update', action='store_true', + help="With --timeline: run update() before each row and " + "redraw every live element from the new data") args = parser.parse_args() + if args.timeline > 1 and args.no_live: + # A timeline shows live elements changing; plain content never does. + parser.error("--timeline shows live elements; it cannot be combined with --no-live") + if (args.timeline or args.no_live) and not args.vegas: + parser.error("--timeline and --no-live need --vegas") + if not (MIN_DIMENSION <= args.width <= MAX_DIMENSION): print(f"Error: --width must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.width})") raise SystemExit(1) @@ -145,6 +169,39 @@ def main() -> int: except Exception as e: logger.warning("update() raised: %s — continuing to display()", e) + if args.vegas: + Path(args.output).parent.mkdir(parents=True, exist_ok=True) + + if args.vegas and args.timeline > 1: + from src.plugin_system.testing.vegas import render_vegas_timeline + image, rows = render_vegas_timeline( + plugin_instance, args.plugin, display_manager, steps=args.timeline, + step_seconds=args.timeline_step, run_update=args.timeline_update) + if image is None: + logger.error("Plugin '%s' has no Vegas content", args.plugin) + return 1 + image.save(args.output) + logger.info("Saved a %d-row Vegas timeline (%dx%d) to %s", + rows, image.width, image.height, args.output) + return 0 + + if args.vegas: + from src.plugin_system.testing.vegas import render_vegas_strip + block, layout = render_vegas_strip( + plugin_instance, args.plugin, display_manager, live=not args.no_live) + if block is None: + logger.error("Plugin '%s' has no Vegas content", args.plugin) + return 1 + block.save(args.output) + sidecar = Path(args.output).with_suffix('.json') + sidecar.write_text(json.dumps( + {"width": block.width, "height": block.height, + "live_elements": [{"key": k, "x": x, "width": w} for x, k, w in layout]}, + indent=2) + "\n", encoding="utf-8") + logger.info("Saved Vegas strip %dx%d (%d live element(s)) to %s and %s", + block.width, block.height, len(layout), args.output, sidecar) + return 0 + # A plugin that declares several display modes usually renders nothing # useful without being told which one to draw: the scoreboards keep their # state on per-mode sub-managers and their no-argument path returns False. diff --git a/src/common/README.md b/src/common/README.md index 6eef122b..30341e39 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -45,6 +45,7 @@ Rules for the package: | [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 | | [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 | | [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 | +| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 | | [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 | | [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a | | [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — | @@ -280,6 +281,17 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists the attributes the host class must have and the three methods deliberately left out. +### sports_vegas + +[`sports_vegas.py`](sports_vegas.py). What a scoreboard needs for live Vegas +cards (one element per game, swapped in place while it scrolls): +`game_key()`, `game_fingerprint()`, `dedupe_games()`, `VegasCardCache` (draws +a card only when its fingerprint changes), `StickyOdds` (keeps a card's odds +through a live poll that left them out), and `finished_games()` / +`with_finished_games()` (a game that just went final keeps its card, showing +FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts +them together; a scoreboard not built on it (UFC) uses them directly. + ### sports_timezone [`sports_timezone.py`](sports_timezone.py). diff --git a/src/common/sports_scroll.py b/src/common/sports_scroll.py index 85e8bd6a..1f95c54b 100644 --- a/src/common/sports_scroll.py +++ b/src/common/sports_scroll.py @@ -42,17 +42,19 @@ Usage:: from __future__ import annotations +import functools import logging import time -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Tuple from PIL import Image -from src.common import scroll_config +from src.common import scroll_config, sports_vegas from src.common.scroll_helper import ScrollHelper logger = logging.getLogger(__name__) + #: Defaults every copy agreed on. A subclass overrides #: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them — #: the soccer lineage uses a 24px gap and min/max duration keys instead. @@ -413,6 +415,160 @@ class SportsScrollDisplay: """Whether content is prepared and ready to scroll.""" return bool(self.scroll_helper.cached_image) + # ------------------------------------------------------------------ + # Live Vegas cards + # ------------------------------------------------------------------ + # + # One live element per game (src/plugin_system/vegas_elements.py): the + # ticker swaps a card in place when its game changes. A sport opts in by + # implementing make_vegas_renderer(); everything else is here. + + def make_vegas_renderer(self, card_width: int, + rankings_cache: Optional[Dict[str, int]] = None) -> Any: + """The renderer this sport draws one game card with, at ``card_width``. + + **Override point.** Return the object whose ``render_game_card(game, + game_type)`` draws one card exactly ``card_width`` wide at the display's + height -- the one prepare_scroll_content already builds -- without the + black padding prepare_scroll_content adds around each card (the ticker + adds its own). Raising NotImplementedError, the default, keeps the + plugin on its ordinary Vegas content. + """ + raise NotImplementedError( + f"{type(self).__name__} has no live Vegas cards (make_vegas_renderer)") + + def _determine_game_type(self, game: Dict[str, Any]) -> str: + """The card a game is drawn as: 'live', 'recent' or 'upcoming'. + + From the game's state; a sport whose scroll display decides it + differently (most define their own) overrides this. + """ + return {'in': 'live', 'post': 'recent'}.get(sports_vegas._state(game), 'upcoming') + + def render_vegas_card(self, renderer: Any, game: Dict[str, Any]) -> Image.Image: + """Draw one game's card. Override only if the renderer is called differently.""" + card: Image.Image = renderer.render_game_card(game, self._determine_game_type(game)) + return card + + def vegas_separator(self, league: str) -> Optional[Image.Image]: + """The league separator shown before a league's cards, if there is an icon.""" + icon = self._separator_icons.get(league) + if icon is None: + return None + gap = self._vegas_settings(league).get("gap_between_games", 48) + pad = max(4, int(gap) // 2) + image = Image.new('RGB', (icon.width + pad * 2, self.display_height), (0, 0, 0)) + mask = icon if icon.mode == 'RGBA' else None + image.paste(icon, (pad, (self.display_height - icon.height) // 2), mask) + return image + + def _vegas_memo(self) -> Dict[Any, Any]: + """Per-size, per-config memo for the live path; emptied when either changes.""" + stamp = (self.display_width, self.display_height, id(self.config)) + memo: Optional[Tuple[Any, Dict[Any, Any]]] = getattr(self, '_vegas_memo_store', None) + if memo is None or memo[0] != stamp: + memo = (stamp, {}) + self._vegas_memo_store = memo + store: Dict[Any, Any] = memo[1] + return store + + def _vegas_settings(self, league: Optional[str]) -> Dict[str, Any]: + """A league's scroll settings, looked up once per size and config. + + The live path asks after every update; a sport's settings lookup can + be expensive (sizing the default card width builds probe renderers). + """ + memo = self._vegas_memo() + key = ('settings', league) + if key not in memo: + memo[key] = dict(self._get_scroll_settings(league)) + settings: Dict[str, Any] = memo[key] + return settings + + def _vegas_renderer(self, card_width: int, + rankings_cache: Optional[Dict[str, int]]) -> Any: + """The sport's renderer for one card width, built once rather than per slate. + + Building one loads fonts and, for the default card width, probes the + layout; the scroll path pays that on every prepare, which the live + path would repeat on every update. + """ + memo = self._vegas_memo() + key = ('renderer', card_width) + if key not in memo: + memo[key] = self.make_vegas_renderer(card_width, rankings_cache) + renderer = memo[key] + if hasattr(renderer, 'set_rankings_cache'): + # Every time, empty included: the renderer is reused across + # slates, and ranks cleared since must not stay drawn. + renderer.set_rankings_cache(rankings_cache or {}) + return renderer + + def build_vegas_elements( + self, + games: List[Dict[str, Any]], + leagues: List[str], + rankings_cache: Optional[Dict[str, int]] = None, + fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None, + now: Optional[float] = None, + ) -> Optional[List[Any]]: + """The slate as live Vegas elements: one card per game, separators between leagues. + + Only cards whose fingerprint changed are drawn; the rest come from the + cache. ``fingerprint(game)`` should return what the card draws (the + plugin's own signature fields, the clock included for live games); by + default the whole game dict is used, which redraws on any change. The + teams' ranks from ``rankings_cache`` count too: the renderer draws + them from there, not from the game. + + Raises NotImplementedError when the sport has no make_vegas_renderer. + """ + from src.plugin_system.vegas_elements import VegasElement + + games = sports_vegas.dedupe_games(games) + if not games: + return None + # Settings follow each game's own league, not the slate's first one: + # a card's width must not change because another league has no games + # today (the ticker refuses a redraw of another width). + first = self._vegas_settings(leagues[0] if leagues else None) + + cards = getattr(self, '_vegas_cards', None) + if cards is None: + cards = self._vegas_cards = sports_vegas.VegasCardCache() + odds = getattr(self, '_vegas_odds', None) + if odds is None: + odds = self._vegas_odds = sports_vegas.StickyOdds() + fingerprint = fingerprint or sports_vegas.game_fingerprint + + elements: List[Any] = [] + keys: List[str] = [] + current_league = None + separators = 0 + for game in games: + league = game.get("league") + settings = self._vegas_settings(league) if league else first + card_width = int(settings.get("game_card_width", self.display_width)) + if settings.get("show_league_separators", True) and league != current_league: + separator = self.vegas_separator(league) if league else None + if separator is not None: + elements.append(VegasElement( + key=f"sep:{separators}:{league}", image=separator, live=False)) + separators += 1 + current_league = league + key = sports_vegas.game_key(game) + drawn = odds.apply(key, game, now) + ranks = (rankings_cache.get(str(drawn.get("home_abbr"))), + rankings_cache.get(str(drawn.get("away_abbr")))) if rankings_cache else None + renderer = self._vegas_renderer(card_width, rankings_cache) + elements.append(cards.element( + key, (fingerprint(drawn), ranks, card_width, self.display_height), + functools.partial(self.render_vegas_card, renderer, drawn))) + keys.append(key) + cards.retain(keys) + odds.retain(keys) + return elements + def get_current_game_count(self) -> int: return len(self._current_games) @@ -525,6 +681,32 @@ class SportsScrollDisplayManager: scroll_display.clear() self._current_game_type = "" + def get_vegas_elements_for( + self, + game_type: str, + games: List[Dict[str, Any]], + leagues: List[str], + rankings_cache: Optional[Dict[str, int]] = None, + fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None, + ) -> Optional[List[Any]]: + """Live Vegas cards for a slate, built on the ``game_type`` display. + + None when the sport has no live cards (it does not implement + make_vegas_renderer) or building them failed, so the plugin's + get_vegas_elements() can return it and the ticker falls back to the + plugin's ordinary Vegas content. + """ + scroll_display = self.get_scroll_display(game_type) + try: + return scroll_display.build_vegas_elements( + games, leagues, rankings_cache, fingerprint) + except NotImplementedError: + return None + except Exception: + # Built straight from feed data, like prepare_scroll_content. + self.logger.exception("Error building live Vegas cards") + return None + def get_all_vegas_content_items(self) -> List[Image.Image]: """Every display's Vegas items, for splicing into the marquee.""" items: List[Image.Image] = [] diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index af025a73..23436957 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -1348,6 +1348,55 @@ class SportsLiveSharedMixin: or candidate < current): self._next_scheduled_start_ts = candidate + #: How long a game that finished live is still reported by + #: finished_games_snapshot(): long enough for the recent-games list, which + #: refreshes about hourly, to take it over well before most slates would. + FINISHED_GAME_TTL = 900.0 + + def _record_finished_game(self, details: Dict) -> None: + """Remember a game that was live and has just gone final (or looks over). + + A finished game leaves ``live_games`` at the next poll, and the recent + list that will show it refreshes about hourly, so in between nothing + holds the game's final score -- and a live Vegas card for it would keep + its last live score. Call this wherever a poll drops a game as final + or over. Only a game this manager had as live is taken; one already + held takes the newer details (a game dropped by an "is it over" + heuristic, then marked final by the feed) but keeps its expiry, so a + feed that lists finals all day cannot keep one here all day. + """ + game_id = details.get("id") if isinstance(details, dict) else None + if not game_id: + return + finished = self.__dict__.setdefault("_finished_games", {}) + held = finished.get(game_id) + if held is not None: + finished[game_id] = (held[0], dict(details)) + return + if not any(g.get("id") == game_id for g in getattr(self, "live_games", ()) or ()): + return + finished[game_id] = (time.monotonic(), dict(details)) + + def finished_games_snapshot(self) -> List[Dict]: + """Games that went final here within FINISHED_GAME_TTL, newest data first. + + Copies, safe to decorate. The caller dedupes them against its other + lists (src/common/sports_vegas.dedupe_games keeps the liveliest copy, + and a final beats nothing but a live one). + """ + finished = self.__dict__.get("_finished_games") + if not finished: + return [] + now = time.monotonic() + # A copy first: a manager finishing its update in the background (off + # the plugin's lock) may record a game while the ticker reads these. + held = list(finished.items()) + for game_id, (seen, _game) in held: + if now - seen > self.FINISHED_GAME_TTL: + finished.pop(game_id, None) + return [dict(game) for _id, (seen, game) in held + if now - seen <= self.FINISHED_GAME_TTL] + def _note_live_fetch(self, found_live: bool) -> None: """Record whether a look for live games found any.""" if found_live: diff --git a/src/common/sports_vegas.py b/src/common/sports_vegas.py new file mode 100644 index 00000000..d1e6788a --- /dev/null +++ b/src/common/sports_vegas.py @@ -0,0 +1,250 @@ +"""Live Vegas cards for the sports scoreboards. + +A scoreboard hands the Vegas ticker one card per game. As live elements +(src/plugin_system/vegas_elements.py) those cards change on the panel while +they scroll: a goal redraws its game's card and the ticker swaps it in place. +This module is what every scoreboard needs for that and would otherwise write +nine times: + +- :func:`game_key` -- a stable key per game, so the ticker can tell which card + a redraw belongs to however the slate is re-sorted. +- :class:`VegasCardCache` -- draws a card only when what it shows changed + (its fingerprint), so an unchanged slate costs a dictionary lookup per game + and a changed one only the cards that changed. +- :class:`StickyOdds` -- live odds are fetched only for games near the front + of the rotation, so a card's odds come and go between polls; this keeps the + last odds for a while instead of redrawing the card without them. +- :func:`dedupe_games` -- a game present in two managers' lists (live and + recent, around the final whistle) appears once, its liveliest copy. +- :func:`finished_games` / :func:`with_finished_games` -- a game that has just + gone final keeps its card, now showing FINAL, where its live card was, + until the recent list (refreshed about hourly) takes it over. +- :func:`game_fingerprint` -- what a card is redrawn on by default: the whole + game dict, frozen hashable. + +SportsScrollDisplay.build_vegas_elements (src/common/sports_scroll.py) puts +them together; a plugin adopts it by implementing make_vegas_renderer(). +""" + +from __future__ import annotations + +import time +from collections import OrderedDict +from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Tuple + +from PIL import Image + +#: Which copy of a duplicated game wins: the liveliest. +_STATE_PRIORITY = {'in': 3, 'post': 2, 'pre': 1} + + +def _state(game: Dict[str, Any]) -> str: + status = game.get('status') + state = status.get('state') if isinstance(status, dict) else status + if isinstance(state, str): + return state + if game.get('is_live'): + return 'in' + if game.get('is_final'): + return 'post' + return 'pre' + + +def _freeze(value: Any) -> Any: + """A hashable, order-stable copy of feed data.""" + if isinstance(value, dict): + return tuple(sorted((str(k), _freeze(v)) for k, v in value.items())) + if isinstance(value, (list, tuple)): + return tuple(_freeze(v) for v in value) + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return repr(value) + + +def game_fingerprint(game: Dict[str, Any]) -> Hashable: + """Everything in a game dict, hashable: a card drawn from it changes only if this does. + + The default card version. Nothing a card could draw is left out, so no + field is ever frozen on the panel; the cost is a redraw when a field the + card does not draw changes too, which feed data rarely does between polls. + """ + frozen: Hashable = _freeze(game) + return frozen + + +def game_key(game: Dict[str, Any]) -> str: + """A key that names this game and nothing else, across polls. + + ``game::`` from the feed's own id. A game without one falls + back to its teams and start time, which is stable for the life of a game. + """ + league = game.get('league') or 'game' + game_id = game.get('id') or game.get('game_id') + if game_id not in (None, ''): + return f"game:{league}:{game_id}" + away = game.get('away_abbr') or game.get('away_team') or '?' + home = game.get('home_abbr') or game.get('home_team') or '?' + start = game.get('start_time_utc') or game.get('start_time') or '' + return f"game:{league}:{away}@{home}:{start}" + + +def dedupe_games(games: Iterable[Dict[str, Any]], + key_fn: Callable[[Dict[str, Any]], str] = game_key) -> List[Dict[str, Any]]: + """Each game once, in first-seen order, keeping its liveliest copy. + + Around a final whistle a game can be in the live list (last poll) and the + recent list (next poll) at once; two cards with one key would be refused + by the ticker, and showing the game twice is wrong anyway. + """ + chosen: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() + for game in games: + key = key_fn(game) + current = chosen.get(key) + if current is None or _STATE_PRIORITY.get(_state(game), 0) > \ + _STATE_PRIORITY.get(_state(current), 0): + chosen[key] = game + return list(chosen.values()) + + +def finished_games( + live_managers: Iterable[Tuple[str, Any]]) -> List[Dict[str, Any]]: + """Games that just left these live managers' lists, final ones as recent games. + + ``live_managers`` pairs each league with its live manager (None is + skipped). Each manager reports what SportsLiveSharedMixin recorded + (finished_games_snapshot, copies), with its league. A final game is + drawn as a recent card. One a poll only judged over -- a tied end of + regulation looks like that too -- keeps its last live state, so its card + never says FINAL early; if play resumes the live list has it again, and + dedupe_games keeps that copy. + """ + finished: List[Dict[str, Any]] = [] + for league, manager in live_managers: + snapshot = getattr(manager, 'finished_games_snapshot', None) + if not callable(snapshot): + continue + for game in snapshot(): + game['league'] = league + if game.get('is_final'): + status = game.get('status') + status = dict(status) if isinstance(status, dict) else {} + status['state'] = 'post' + game.update(status=status, is_live=False) + finished.append(game) + return finished + + +def with_finished_games( + games: List[Dict[str, Any]], leagues: List[str], + finished: List[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], List[str]]: + """The slate with games that just went final where their live cards were. + + A slate lists each league's games together, live ones first. Each + finished game goes after its league's live games, ahead of the rest; a + league with no games left in the slate is added at the end. A finished + game the slate also has (the recent list caught up) is left for + dedupe_games, which keeps one copy. + """ + if not finished: + return list(games), list(leagues) + pending: "OrderedDict[Any, List[Dict[str, Any]]]" = OrderedDict() + for game in finished: + pending.setdefault(game.get('league'), []).append(game) + merged: List[Dict[str, Any]] = [] + for index, game in enumerate(games): + league = game.get('league') + if league in pending and _state(game) != 'in': + merged.extend(pending.pop(league)) + merged.append(game) + following = games[index + 1] if index + 1 < len(games) else None + if league in pending and (following is None or following.get('league') != league): + merged.extend(pending.pop(league)) # the league's games were all live + leagues = list(leagues) + for league, rest in pending.items(): + merged.extend(rest) + if league not in leagues: + leagues.append(league) + return merged, leagues + + +class VegasCardCache: + """Cards drawn once per fingerprint, kept for as long as their game is. + + ``element(key, fingerprint, render)`` returns a VegasElement whose image is + ``render()``'s -- called only when the fingerprint differs from the one the + cached card was drawn for. The fingerprint is also the element's version, + so the ticker skips unchanged cards without comparing pixels. + + Bounded: keys not passed to :meth:`retain` after a slate are dropped, and + at most ``max_entries`` are ever held (oldest first). + """ + + def __init__(self, max_entries: int = 96) -> None: + self.max_entries = max(1, int(max_entries)) + self._cards: "OrderedDict[str, Tuple[Hashable, Image.Image]]" = OrderedDict() + self.renders = 0 + + def element(self, key: str, fingerprint: Hashable, + render: Callable[[], Image.Image], live: bool = True) -> Any: + from src.plugin_system.vegas_elements import VegasElement + + cached = self._cards.get(key) + if cached is not None and cached[0] == fingerprint: + self._cards.move_to_end(key) + image = cached[1] + else: + image = render() + self.renders += 1 + self._cards[key] = (fingerprint, image) + self._cards.move_to_end(key) + while len(self._cards) > self.max_entries: + self._cards.popitem(last=False) + return VegasElement(key=key, image=image, version=fingerprint, live=live) + + def retain(self, keys: Iterable[str]) -> None: + """Forget every card whose key is not in ``keys``.""" + keep = set(keys) + for key in [k for k in self._cards if k not in keep]: + self._cards.pop(key, None) + + def clear(self) -> None: + self._cards.clear() + + def __len__(self) -> int: + return len(self._cards) + + +class StickyOdds: + """Keep a game's last odds on its card while a live poll leaves them out. + + Live odds are fetched only for games near the front of the rotation + (src/common/sports_fetch.py), so the same game's dict has odds on one poll + and none on the next. Drawn as-is that redraws the card every poll with + the odds flickering in and out. ``apply`` returns the game with its last + non-empty odds put back, for up to ``ttl_s`` seconds after they were seen. + """ + + def __init__(self, ttl_s: float = 600.0) -> None: + self.ttl_s = float(ttl_s) + self._seen: Dict[str, Tuple[float, Any]] = {} + + def apply(self, key: str, game: Dict[str, Any], + now: Optional[float] = None) -> Dict[str, Any]: + now = time.monotonic() if now is None else now + odds = game.get('odds') + if odds: + self._seen[key] = (now, odds) + return game + seen = self._seen.get(key) + if seen is None or now - seen[0] > self.ttl_s: + self._seen.pop(key, None) + return game + refilled = dict(game) + refilled['odds'] = seen[1] + return refilled + + def retain(self, keys: Iterable[str]) -> None: + keep = set(keys) + for key in [k for k in self._seen if k not in keep]: + self._seen.pop(key, None) diff --git a/src/plugin_system/testing/vegas.py b/src/plugin_system/testing/vegas.py index d9286b4f..6ff2a87e 100644 --- a/src/plugin_system/testing/vegas.py +++ b/src/plugin_system/testing/vegas.py @@ -266,6 +266,91 @@ def _base_redraw(): return BasePlugin.redraw_vegas_element +def render_vegas_strip(plugin: Any, plugin_id: str, display_manager: Any, + live: bool = True) -> Any: + """The plugin's block of the Vegas strip, laid out exactly as the ticker would. + + Fetched through the ticker's own adapter (trimming, pinning, width + budget) and joined with its own spacing, so what this draws is what + scrolls. ``live=False`` shows the ordinary get_vegas_content() instead. + + Returns ``(block, layout)`` -- layout a list of ``(x, key, width)`` for the + live elements in the block -- or ``(None, [])`` when there is nothing. + """ + _adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, live) + return block, [(x, meta.key, width) for x, meta, width in layout] + + +def _vegas_block(plugin: Any, plugin_id: str, display_manager: Any, live: bool) -> Any: + """(adapter, block, layout) for a plugin's Vegas block, through the ticker's own code.""" + from src.vegas_mode.config import VegasModeConfig + from src.vegas_mode.elements import LiveEpochs + from src.vegas_mode.plugin_adapter import PluginAdapter + from src.vegas_mode.render_pipeline import join_plugin_rows + + config = VegasModeConfig() + adapter = PluginAdapter(display_manager, config) + adapter.live_elements_enabled = live + adapter.live_epochs = LiveEpochs() + images = adapter.get_content(plugin, plugin_id, offscreen_only=True) + if not images: + return adapter, None, [] + block, layout = join_plugin_rows(images, config) + return adapter, block, layout + + +def render_vegas_timeline(plugin: Any, plugin_id: str, display_manager: Any, + steps: int = 8, step_seconds: float = 0.25, + run_update: bool = False) -> Any: + """The plugin's Vegas block at successive moments, one row per step. + + Row 0 is the block as placed (render_vegas_strip). Each later row is the + same block ``step_seconds`` later, changed the way the ticker would change + it in place: every live element with ``refresh_hz`` redrawn for that + moment through redraw_vegas_element(), and -- with ``run_update`` -- the + plugin's update() run first and every live element redrawn from the new + data. A redraw of another width is left out, as the ticker refuses it. + Rows are separated by a grey line. + + Returns ``(image, rows)``, or ``(None, 0)`` when there is nothing. + """ + import time + + import numpy as np + + adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, True) + if block is None: + return None, 0 + base = np.array(block.convert('RGB')) + rows = [base.copy()] + height = base.shape[0] + start = time.monotonic() + for step in range(1, max(1, int(steps))): + frame = rows[-1].copy() + if run_update: + plugin.update() + adapter.live_epochs.bump(plugin_id) + batch = adapter.render_live_elements(plugin, plugin_id, lock_timeout=5.0) + rendered = batch[1] if batch is not None else {} + for x, meta, width in layout: + element = rendered.get(meta.key) + if element is not None and element.width == width: + frame[:, x:x + width] = element.pixels + at = start + step * float(step_seconds) + for x, meta, width in layout: + if meta.refresh_hz > 0: + element = adapter.redraw_live_element(plugin, plugin_id, meta.key, + width, height, at) + if element is not None and element.width == width: + frame[:, x:x + width] = element.pixels + rows.append(frame) + divider = np.full((1, base.shape[1], 3), 60, dtype=np.uint8) + stacked = [rows[0]] + for row in rows[1:]: + stacked.extend([divider, row]) + return Image.fromarray(np.concatenate(stacked, axis=0)), len(rows) + + def check_plugin_vegas_elements(plugin_id: str, plugin_dir: Any, config: dict, mock_data: dict, width: int, height: int, run_update: bool = True) -> VegasElementReport: diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index a0ac0e50..ba72cb97 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -32,6 +32,51 @@ logger = logging.getLogger(__name__) SYNC_SEND_INTERVAL = 1.0 / 90 +def join_plugin_rows( + images: List[Image.Image], config: VegasModeConfig +) -> Tuple[Image.Image, List[Tuple[int, ElementMeta, int]]]: + """Join one plugin's images into the block the strip will hold. + + Returns ``(block, layout)``, layout being ``(x, meta, width)`` for every + image tagged as a live element (src/vegas_mode/elements.py), x measured + from the block's left edge. A single image is returned as it is. + + A module function so tooling (scripts/render_plugin.py --vegas) lays a + plugin out exactly as the ticker does. + """ + if len(images) == 1: + meta = meta_of(images[0]) + layout = [(0, meta, images[0].width)] if meta is not None else [] + return images[0], layout + + floor = max(0, config.intra_plugin_gap) + target = max(0, config.min_content_separation) + threshold = config.trim_threshold + + # Space by measured separation, not a flat gap. Rows drawn flush to their + # own edges (sports score cards) would otherwise end up nearly touching, + # while rows that already carry wide margins would be pushed needlessly + # further apart. + gaps = [ + separation_gap(images[i], images[i + 1], target, floor, threshold) + for i in range(len(images) - 1) + ] + + width = sum(img.width for img in images) + sum(gaps) + height = max(img.height for img in images) + + block = Image.new('RGB', (width, height), (0, 0, 0)) + layout: List[Tuple[int, ElementMeta, int]] = [] + x = 0 + for i, img in enumerate(images): + block.paste(img, (x, 0)) + meta = meta_of(img) + if meta is not None: + layout.append((x, meta, img.width)) + x += img.width + (gaps[i] if i < len(gaps) else 0) + return block, layout + + class RenderPipeline: """ High-performance render pipeline for Vegas scroll mode. @@ -787,42 +832,9 @@ class RenderPipeline: ) -> Tuple[Image.Image, List[Tuple[int, ElementMeta, int]]]: """_join_plugin_rows, plus where each live element landed in the block. - Returns ``(block, layout)``, layout being ``(x, meta, width)`` for - every image tagged as a live element (src/vegas_mode/elements.py), x - measured from the block's left edge. The offsets were always computed - here; they used to be thrown away. + See join_plugin_rows. """ - if len(images) == 1: - meta = meta_of(images[0]) - layout = [(0, meta, images[0].width)] if meta is not None else [] - return images[0], layout - - floor = max(0, self.config.intra_plugin_gap) - target = max(0, self.config.min_content_separation) - threshold = self.config.trim_threshold - - # Space by measured separation, not a flat gap. Rows drawn flush to - # their own edges (sports score cards) would otherwise end up nearly - # touching, while rows that already carry wide margins would be pushed - # needlessly further apart. - gaps = [ - separation_gap(images[i], images[i + 1], target, floor, threshold) - for i in range(len(images) - 1) - ] - - width = sum(img.width for img in images) + sum(gaps) - height = max(img.height for img in images) - - block = Image.new('RGB', (width, height), (0, 0, 0)) - layout: List[Tuple[int, ElementMeta, int]] = [] - x = 0 - for i, img in enumerate(images): - block.paste(img, (x, 0)) - meta = meta_of(img) - if meta is not None: - layout.append((x, meta, img.width)) - x += img.width + (gaps[i] if i < len(gaps) else 0) - return block, layout + return join_plugin_rows(images, self.config) # -- live element records --------------------------------------------- # diff --git a/test/test_harness_vegas_elements.py b/test/test_harness_vegas_elements.py index 338bdf01..3ba6a5e4 100644 --- a/test/test_harness_vegas_elements.py +++ b/test/test_harness_vegas_elements.py @@ -15,6 +15,7 @@ from src.plugin_system.testing.harness import _instantiate # noqa: E402 from src.plugin_system.testing.loading import build_full_config, load_harness_spec, load_manifest # noqa: E402 from src.plugin_system.testing.vegas import ( # noqa: E402 check_vegas_elements, implements_vegas_elements, render_vegas_elements, + render_vegas_strip, render_vegas_timeline, ) from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager # noqa: E402 from src.plugin_system.vegas_elements import VegasElement # noqa: E402 @@ -178,3 +179,30 @@ def test_check_plugin_reports_a_failing_element_check_and_carries_on(monkeypatch False, None, None) vegas = [r for r in results if r.mode == "vegas elements"] assert len(vegas) == 1 and "boom" in vegas[0].error + + +def test_a_timeline_moves_what_animates_and_nothing_else(): + import numpy as np + plugin, dm = _stub(dot_speed=200) + _block, layout = render_vegas_strip(plugin, "vegas-live-stub", dm) + image, rows = render_vegas_timeline(plugin, "vegas-live-stub", dm, steps=3, + step_seconds=0.5) + assert rows == 3 and image.height == 3 * H + 2 + pixels = np.asarray(image) + first, last = pixels[:H], pixels[2 * (H + 1):] + columns = {key: (x, width) for x, key, width in layout} + x, width = columns["map"] + assert (first[:, x:x + width] != last[:, x:x + width]).any() + x, width = columns["card:0"] + assert (first[:, x:x + width] == last[:, x:x + width]).all() + + +def test_a_timeline_with_updates_redraws_the_cards_in_place(): + import numpy as np + plugin, dm = _stub(map_hz=0) + _block, layout = render_vegas_strip(plugin, "vegas-live-stub", dm) + image, rows = render_vegas_timeline(plugin, "vegas-live-stub", dm, steps=2, + run_update=True) + pixels = np.asarray(image) + x, width = {key: (x, width) for x, key, width in layout}["card:0"] + assert (pixels[:H, x:x + width] != pixels[H + 1:, x:x + width]).any() diff --git a/test/test_sports_vegas_elements.py b/test/test_sports_vegas_elements.py new file mode 100644 index 00000000..6e07fd2b --- /dev/null +++ b/test/test_sports_vegas_elements.py @@ -0,0 +1,415 @@ +"""Live Vegas cards for the scoreboards (src/common/sports_vegas.py, sports_scroll). + +A scoreboard adopts live cards by implementing make_vegas_renderer(); the +shared code then draws one card per game, only when what the card shows +changed, keyed by the game so the ticker can swap it in place. +""" +import sys +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from src.common import sports_vegas # noqa: E402 +from src.common.sports_scroll import SportsScrollDisplay, SportsScrollDisplayManager # noqa: E402 +from src.common.sports_shared import SportsLiveSharedMixin # noqa: E402 +from src.plugin_system.vegas_elements import VegasElement # noqa: E402 + +W, H = 128, 32 + + +def _game(gid, league="nfl", home=0, away=0, state="in", clock="10:00", odds=None): + return {"id": gid, "league": league, "home_abbr": "HOM", "away_abbr": "AWY", + "home_score": home, "away_score": away, + "status": {"state": state}, "clock": clock, "odds": odds} + + +class _Renderer: + def __init__(self, width): + self.width = width + self.calls = [] + self.rankings = None + + def render_game_card(self, game, game_type): + self.calls.append((game["id"], game_type, game.get("odds"))) + return Image.new("RGB", (self.width, H), (game["home_score"] * 10 % 255, 0, 0)) + + def set_rankings_cache(self, rankings): + self.rankings = rankings + + +class _Display(SportsScrollDisplay): + made = 0 + + def make_vegas_renderer(self, card_width, rankings_cache=None): + type(self).made += 1 + self.renderer = _Renderer(card_width) + return self.renderer + + def _load_separator_icons(self): + self._separator_icons = {"nfl": Image.new("RGBA", (10, 10), (255, 255, 255, 255)), + "ncaa_fb": Image.new("RGBA", (10, 10), (0, 255, 0, 255))} + + +class _Plain(SportsScrollDisplay): + pass + + +def _dm(): + return SimpleNamespace(width=W, height=H, matrix=None, refresh_hz=100.0, + set_scrolling_state=lambda *a, **k: None) + + +def _display(cls=_Display, **config): + return cls(_dm(), config) + + +def _fp(game): + return (game["home_score"], game["away_score"], game["clock"], + bool(game.get("odds"))) + + +# -- building blocks ------------------------------------------------------------ + + +def test_game_keys_name_the_game(): + assert sports_vegas.game_key(_game("401")) == "game:nfl:401" + assert sports_vegas.game_key({"league": "mlb", "away_abbr": "A", "home_abbr": "B", + "start_time": "2026-09-30T19:00Z"}) \ + == "game:mlb:A@B:2026-09-30T19:00Z" + + +def test_dedupe_keeps_the_liveliest_copy_in_first_seen_order(): + games = [_game("1", state="post", home=3), _game("2", state="pre"), + _game("1", state="in", home=2)] + kept = sports_vegas.dedupe_games(games) + assert [g["id"] for g in kept] == ["1", "2"] + assert kept[0]["status"]["state"] == "in" + + +def test_the_card_cache_draws_only_on_a_new_fingerprint(): + cache = sports_vegas.VegasCardCache() + drawn = [] + + def render(value): + drawn.append(value) + return Image.new("RGB", (4, H)) + + first = cache.element("k", 1, lambda: render(1)) + again = cache.element("k", 1, lambda: render(2)) + changed = cache.element("k", 2, lambda: render(3)) + assert drawn == [1, 3] + assert isinstance(first, VegasElement) and first.version == 1 + assert again.image is first.image and changed.version == 2 + + +def test_the_card_cache_forgets_games_not_retained_and_is_bounded(): + cache = sports_vegas.VegasCardCache(max_entries=3) + for key in "abcde": + cache.element(key, 0, lambda: Image.new("RGB", (4, H))) + assert len(cache) == 3 + cache.retain(["e"]) + assert len(cache) == 1 + + +def test_sticky_odds_fill_a_gap_and_then_expire(): + odds = sports_vegas.StickyOdds(ttl_s=60) + game = _game("1", odds={"spread": -3}) + assert odds.apply("k", game, now=0)["odds"] == {"spread": -3} + later = _game("1", odds=None) + refilled = odds.apply("k", later, now=30) + assert refilled["odds"] == {"spread": -3} + assert later["odds"] is None # the feed's dict is untouched + assert odds.apply("k", later, now=100)["odds"] is None + + +# -- SportsScrollDisplay.build_vegas_elements --------------------------------------- + + +def test_a_slate_becomes_keyed_cards_with_league_separators(): + display = _display() + games = [_game("1"), _game("2"), _game("3", league="ncaa_fb")] + elements = display.build_vegas_elements(games, ["nfl", "ncaa_fb"], fingerprint=_fp) + keys = [e.key for e in elements] + assert keys == ["sep:0:nfl", "game:nfl:1", "game:nfl:2", + "sep:1:ncaa_fb", "game:ncaa_fb:3"] + assert [e.live for e in elements] == [False, True, True, False, True] + card = elements[1] + assert card.image.size == (display._get_scroll_settings()["game_card_width"], H) + + +def test_only_changed_games_are_redrawn(): + display = _display() + games = [_game("1"), _game("2"), _game("3")] + display.build_vegas_elements(games, ["nfl"], fingerprint=_fp) + renderer = display.renderer + assert len(renderer.calls) == 3 + display.build_vegas_elements(games, ["nfl"], fingerprint=_fp) + assert len(renderer.calls) == 3 # nothing changed + games[1] = _game("2", home=7) + display.build_vegas_elements(games, ["nfl"], fingerprint=_fp) + assert [c[0] for c in renderer.calls[3:]] == ["2"] + + +def test_a_clock_change_redraws_that_card_when_the_fingerprint_has_the_clock(): + display = _display() + display.build_vegas_elements([_game("1"), _game("2")], ["nfl"], fingerprint=_fp) + display.build_vegas_elements([_game("1", clock="9:41"), _game("2")], ["nfl"], + fingerprint=_fp) + assert [c[0] for c in display.renderer.calls[2:]] == ["1"] + + +def test_the_renderer_is_built_once_not_per_slate(): + _Display.made = 0 + display = _display() + for _ in range(3): + display.build_vegas_elements([_game("1")], ["nfl"], fingerprint=_fp) + assert _Display.made == 1 + + +def test_a_card_keeps_its_odds_through_a_poll_without_them(): + display = _display() + display.build_vegas_elements([_game("1", odds={"spread": -3})], ["nfl"], fingerprint=_fp) + display.build_vegas_elements([_game("1", odds=None)], ["nfl"], fingerprint=_fp) + assert len(display.renderer.calls) == 1 # no redraw without the odds + + +def test_game_dicts_are_never_mutated(): + display = _display() + game = _game("1", odds=None) + snapshot = dict(game) + display.build_vegas_elements([game], ["nfl"], fingerprint=_fp) + assert game == snapshot + + +def test_a_sport_without_a_renderer_stays_on_its_ordinary_content(): + manager = SportsScrollDisplayManager(_dm(), {}) + manager.display_class = _Plain + assert manager.get_vegas_elements_for("mixed", [_game("1")], ["nfl"]) is None + + +def test_a_renderer_that_fails_falls_back_instead_of_raising(): + class Broken(_Display): + def render_vegas_card(self, renderer, game): + raise KeyError("home_logo") + + manager = SportsScrollDisplayManager(_dm(), {}) + manager.display_class = Broken + assert manager.get_vegas_elements_for("mixed", [_game("1")], ["nfl"]) is None + + +def test_the_manager_hands_back_the_elements(): + manager = SportsScrollDisplayManager(_dm(), {}) + manager.display_class = _Display + elements = manager.get_vegas_elements_for("mixed", [_game("1")], ["nfl"], + fingerprint=_fp) + assert [e.key for e in elements] == ["sep:0:nfl", "game:nfl:1"] + + +# -- the finished-game capture ------------------------------------------------------ + + +class _Live(SportsLiveSharedMixin): + def __init__(self): + self.live_games = [_game("1"), _game("2")] + + +def test_a_game_that_was_live_is_remembered_when_it_ends(): + live = _Live() + live._record_finished_game(_game("1", state="post", home=24)) + live._record_finished_game(_game("99", state="post")) # never live here + finished = live.finished_games_snapshot() + assert [g["id"] for g in finished] == ["1"] + assert finished[0]["home_score"] == 24 + finished[0]["league"] = "changed" + assert live.finished_games_snapshot()[0]["league"] == "nfl" # copies + + +def test_a_finished_game_is_forgotten_after_its_ttl(monkeypatch): + live = _Live() + live._record_finished_game(_game("1", state="post")) + real = time.monotonic + monkeypatch.setattr(time, "monotonic", lambda: real() + live.FINISHED_GAME_TTL + 1) + assert live.finished_games_snapshot() == [] + + +def test_final_replaces_the_live_card_in_place(): + # The same key, so the ticker swaps the card rather than adding one. + display = _display() + before = display.build_vegas_elements([_game("1", home=7)], ["nfl"], fingerprint=_fp) + after = display.build_vegas_elements( + sports_vegas.dedupe_games([_game("1", state="post", home=14)]), ["nfl"], + fingerprint=_fp) + assert before[-1].key == after[-1].key + assert before[-1].version != after[-1].version + + +def test_settings_are_looked_up_once_per_league_not_per_update(monkeypatch): + display = _display() + calls = [] + real = display._get_scroll_settings + monkeypatch.setattr(display, "_get_scroll_settings", + lambda league=None: calls.append(league) or real(league)) + slate = [_game("1"), _game("2", league="ncaa_fb")] + for _ in range(3): + display.build_vegas_elements(slate, ["nfl", "ncaa_fb"], fingerprint=_fp) + assert sorted(calls) == ["ncaa_fb", "nfl"] + + +def test_each_card_takes_its_own_leagues_width(): + display = _display(nfl={"scroll_settings": {"game_card_width": 100}}, + ncaa_fb={"scroll_settings": {"game_card_width": 140}}) + display.SCROLL_LEAGUE_KEYS = ("nfl", "ncaa_fb") + slate = [_game("1"), _game("2", league="ncaa_fb")] + widths = {e.key: e.image.width + for e in display.build_vegas_elements(slate, ["nfl", "ncaa_fb"], fingerprint=_fp) + if e.live} + # ...and keeps it when the other league has nothing to show. + alone = display.build_vegas_elements(slate[1:], ["ncaa_fb"], fingerprint=_fp) + assert widths == {"game:nfl:1": 100, "game:ncaa_fb:2": 140} + assert [e.image.width for e in alone if e.live] == [140] + + +def test_a_rank_change_redraws_the_card(): + display = _display() + display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 5}, fingerprint=_fp) + drawn = display._vegas_cards.renders + display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 5}, fingerprint=_fp) + assert display._vegas_cards.renders == drawn + display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 3}, fingerprint=_fp) + assert display._vegas_cards.renders == drawn + 1 + + +def test_finished_games_come_back_as_recent_games_of_their_league(): + live = _Live() + live._record_finished_game(dict(_game("1", home=24), is_final=True)) + finished = sports_vegas.finished_games([("ncaa_fb", live), ("nfl", None)]) + assert [(g["id"], g["league"], g["status"]["state"], g["is_live"]) + for g in finished] == [("1", "ncaa_fb", "post", False)] + + +def test_a_game_only_judged_over_is_not_drawn_final(): + # A tied end of regulation looks over to the heuristics; overtime may follow. + live = _Live() + live._record_finished_game(_game("1", home=24, clock="0:00")) + finished = sports_vegas.finished_games([("nfl", live)]) + assert finished[0]["status"]["state"] == "in" + assert not finished[0].get("is_final") + # ...and when play resumes, the live list's copy is the one kept. + resumed = _game("1", home=24, clock="15:00") + merged, _ = sports_vegas.with_finished_games([resumed], ["nfl"], finished) + assert sports_vegas.dedupe_games(merged) == [resumed] + + +def test_the_snapshot_copes_with_games_recorded_while_it_reads(): + # A baseball league finishing its update in the background records games + # off the plugin's lock while the ticker takes snapshots. + import sys + import threading + live = _Live() + live.live_games = [_game(str(i)) for i in range(400)] + errors = [] + stop = threading.Event() + + def record(): + for i in range(400): + live._record_finished_game(dict(_game(str(i)), is_final=True)) + stop.set() + + def read(): + while not stop.is_set(): + try: + live.finished_games_snapshot() + except RuntimeError as exc: + errors.append(exc) + return + + previous = sys.getswitchinterval() + sys.setswitchinterval(1e-6) + try: + readers = [threading.Thread(target=read) for _ in range(2)] + for t in readers: + t.start() + record() + for t in readers: + t.join(10) + finally: + sys.setswitchinterval(previous) + assert not errors + assert len(live.finished_games_snapshot()) == 400 + + +def test_a_held_game_takes_the_final_details_but_keeps_its_expiry(monkeypatch): + live = _Live() + live._record_finished_game(_game("1", home=24, clock="0:00")) # judged over + live.live_games = [] # dropped + now = time.monotonic() + monkeypatch.setattr(time, "monotonic", lambda: now + 60) + live._record_finished_game(dict(_game("1", home=27), is_final=True)) + finished = sports_vegas.finished_games([("nfl", live)]) + assert finished[0]["home_score"] == 27 and finished[0]["status"]["state"] == "post" + monkeypatch.setattr(time, "monotonic", lambda: now + live.FINISHED_GAME_TTL + 1) + assert live.finished_games_snapshot() == [] + + +def _slate(*games): + return [_game(gid, league=league, state=state) for gid, league, state in games] + + +@pytest.mark.parametrize("games,leagues,expected_ids,expected_leagues", [ + # After the league's live games, ahead of its recent and upcoming ones. + (_slate(("2", "nfl", "in"), ("3", "nfl", "post"), ("4", "nfl", "pre"), + ("5", "ncaa_fb", "in")), + ["nfl", "ncaa_fb"], ["2", "F", "3", "4", "5"], ["nfl", "ncaa_fb"]), + # A league whose games were all live. + (_slate(("2", "nfl", "in"), ("5", "ncaa_fb", "in")), + ["nfl", "ncaa_fb"], ["2", "F", "5"], ["nfl", "ncaa_fb"]), + # No live games: first in its league. + (_slate(("5", "ncaa_fb", "pre"), ("3", "nfl", "post")), + ["ncaa_fb", "nfl"], ["5", "F", "3"], ["ncaa_fb", "nfl"]), + # Its league has nothing else to show. + (_slate(("5", "ncaa_fb", "pre")), ["ncaa_fb"], ["5", "F"], ["ncaa_fb", "nfl"]), +]) +def test_a_finished_game_goes_where_its_live_card_was(games, leagues, expected_ids, + expected_leagues): + final = _game("F", league="nfl", state="post") + merged, merged_leagues = sports_vegas.with_finished_games(games, leagues, [final]) + assert [g["id"] for g in merged] == expected_ids + assert merged_leagues == expected_leagues + + +def test_the_default_fingerprint_follows_every_field_and_ignores_key_order(): + game = _game("1", odds={"spread": -3, "details": ["a", "b"]}) + same = dict(reversed(list(game.items()))) + assert sports_vegas.game_fingerprint(game) == sports_vegas.game_fingerprint(same) + hash(sports_vegas.game_fingerprint(game)) + for field, value in [("clock", "9:59"), ("odds", {"spread": -3, "details": ["a"]}), + ("status", {"state": "post"})]: + assert sports_vegas.game_fingerprint(dict(game, **{field: value})) != \ + sports_vegas.game_fingerprint(game) + + +def test_no_finished_games_leaves_the_slate_alone(): + games = _slate(("2", "nfl", "in")) + merged, leagues = sports_vegas.with_finished_games(games, ["nfl"], []) + assert merged == games and merged is not games and leagues == ["nfl"] + + +@pytest.mark.parametrize("state,expected", [("in", "live"), ("post", "recent"), + ("pre", "upcoming")]) +def test_the_default_card_type_follows_the_game_state(state, expected): + display = _display() + display.build_vegas_elements([_game("1", state=state)], ["nfl"], fingerprint=_fp) + assert display.renderer.calls[0][1] == expected + + +def test_ranks_cleared_since_are_not_kept_by_the_reused_renderer(): + display = _display() + display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 5}, fingerprint=_fp) + assert display.renderer.rankings == {"HOM": 5} + display.build_vegas_elements([_game("1")], ["nfl"], None, fingerprint=_fp) + assert display.renderer.rankings == {}