From 8c00df2e13bc7bfdf62ab586a6230e3cf367bb73 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 12 Aug 2026 16:29:01 -0400 Subject: [PATCH] feat(vegas): let live content keep its place in the ticker Live content used to preempt Vegas outright: while any plugin reported live priority the display controller refused to run the ticker at all and showed a full-screen scoreboard instead. Keeping the marquee meant not seeing live scores; seeing live scores meant losing the marquee. Two changes, both off by default. vegas_scroll.live_in_ticker keeps the ticker running through a live game. Three places assumed the takeover and all three now honour it: the controller's gate, the coordinator's per-frame pause, and the rotation switch that would otherwise move current_mode_index underneath a ticker that never yields. And the rotation is no longer a strict round robin. It was one slot per plugin per cycle, so with a dozen plugins enabled a live score came round once a lap and could be minutes old on screen. A plugin can now hold several slots, placed by Smooth Weighted Round-Robin -- the same scheduler the sports plugins already use to rotate their own games. The property that matters is that repeats are spread through the cycle rather than clumped: three in a row and then silence would be worse than no boost at all. Weight comes from the plugin first, via a new optional get_vegas_priority_weight(), then from the core: live content earns live_weight, everything else 1. So existing plugins gain the behaviour without changes, and the hook exists for the one thing the core cannot work out -- the core can see that a game is live but not whose, so only the plugin can say a favorite is playing. Documented in ADVANCED_FEATURES (worked example, why weights are per plugin not per game, and that frequency is not freshness), CONFIG_REFERENCE, PLUGIN_API_REFERENCE, and the config template. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- config/config.template.json | 3 + docs/ADVANCED_FEATURES.md | 84 ++++++++++++++- docs/CONFIG_REFERENCE.md | 6 +- docs/PLUGIN_API_REFERENCE.md | 39 +++++++ src/display_controller.py | 20 +++- src/plugin_system/base_plugin.py | 38 +++++++ src/vegas_mode/config.py | 32 ++++++ src/vegas_mode/coordinator.py | 6 ++ src/vegas_mode/stream_manager.py | 71 +++++++++++++ test/test_vegas_live_weighting.py | 171 ++++++++++++++++++++++++++++++ 10 files changed, 466 insertions(+), 4 deletions(-) create mode 100644 test/test_vegas_live_weighting.py diff --git a/config/config.template.json b/config/config.template.json index 9edbec18..2049c2f4 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -129,6 +129,9 @@ "plugin_rotation_order": [], "use_short_date_format": true, "vegas_scroll": { + "live_in_ticker": false, + "live_weight": 3, + "favorite_live_weight": 5, "enabled": false, "scroll_speed": 50, "separator_width": 32, diff --git a/docs/ADVANCED_FEATURES.md b/docs/ADVANCED_FEATURES.md index 8fab4b57..f01d79e5 100644 --- a/docs/ADVANCED_FEATURES.md +++ b/docs/ADVANCED_FEATURES.md @@ -64,10 +64,92 @@ JSON is optional. | `target_fps` | `125` | Target frame rate | | `buffer_ahead` | `2` | Number of plugins buffered ahead | -This table is a subset — `display.vegas_scroll` supports 26 keys in +This table is a subset — `display.vegas_scroll` supports 29 keys in total. See the full list in [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode). +### Live Content in the Ticker + +By default, live content **preempts** Vegas mode: while any plugin reports +live priority, the display controller refuses to run the ticker and shows +that plugin's full-screen display instead. You get a big readable scoreboard, +but the marquee stops entirely for the duration of the game. + +Set `live_in_ticker` to keep the ticker running and let live content take +**extra turns inside it** instead: + +```json +"vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 +} +``` + +#### Why weights exist + +The rotation is otherwise a strict round robin — every plugin appears exactly +once per cycle. With a dozen plugins enabled, a live score comes round once a +lap and can be minutes old by the time you see it. A weight of *N* gives a +plugin *N* slots per cycle. + +The slots are placed by **Smooth Weighted Round-Robin**, the same scheduler +the sports plugins use internally to rotate their own games. The important +property is that repeats are *spread through the cycle* rather than clumped: +three appearances in a row followed by a long silence would be worse than not +boosting at all. + +Twelve plugins, with a favorite's baseball game and an ordinary live hockey +game (`live_weight: 3`, `favorite_live_weight: 5`): + +``` +baseball > hockey > weather > clock > baseball +stocks > news > flights > baseball > hockey +calendar > f1 > music > baseball > tides +birds > hockey > baseball +``` + +18 slots for 12 plugins. Baseball appears 5 times, hockey 3, everything else +once, and no plugin ever appears twice in a row. + +#### Where the weight comes from + +For each plugin in the rotation, in order: + +1. **The plugin's own answer.** If it implements + `get_vegas_priority_weight()` and returns a number, that wins. This is the + only route for favorite-team awareness — the core can see *that* a game is + live, but not *whose*, so a scoreboard has to say so itself. +2. **The core's default.** When the plugin returns `None` (the base-class + default), a plugin where both `has_live_priority()` and `has_live_content()` + are true gets `live_weight`. +3. **Everything else** gets 1. + +Because of step 2, **existing plugins need no changes** — any scoreboard with +`live_priority` enabled already gets extra turns. Step 1 is opt-in, for +plugins that want to distinguish a favorite's game from any other live game. + +Weights are clamped to 1–10. A weight of 1 is no boost; a weight below 1 would +drop the plugin from the rotation entirely, which is never what is meant. + +#### Things worth knowing + +- **Weights are per plugin, not per game.** A scoreboard showing four live + games still occupies one slot at a time, rotating its own games within that + slot using its own `favorite_live_boost`. This controls how often the + *plugin* comes round. +- **The ticker is zero-sum.** Giving baseball 5 slots does not make the cycle + faster; it makes the cycle *longer* and everything else proportionally + rarer. If you want live scores sooner in wall-clock terms, pair this with a + smaller `plugins_per_cycle`. +- **Frequency is not freshness.** Each appearance redraws from the plugin's + current data (`refresh_updated_plugins()` drops cached content when a + plugin's data changes), but how current that data is depends on the + plugin's own `live_update_interval`. Showing a stale score five times a lap + is no better than showing it once. +- **Everything still appears.** A boost never starves another plugin out of + the cycle; low-weight plugins keep their single slot. + ### Per-Plugin Configuration Override Vegas behavior for specific plugins: diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index ff623439..6f68fbc9 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -103,7 +103,8 @@ logical image to multiple chained physical panels. ## `display.vegas_scroll` — continuous scroll mode Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See -[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details. +[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details, including +[live content in the ticker](ADVANCED_FEATURES.md#live-content-in-the-ticker). | Key | Type / default | |---|---| @@ -134,6 +135,9 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See | `max_cycle_duration` | int, `240` | | `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping | | `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) | +| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard | +| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content | +| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live | ## `sync` — multi-display synchronization diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index 9c9d919b..a8036f27 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -170,6 +170,45 @@ Default returns `False`. List of display modes to show during a live takeover. Default returns the plugin's `display_modes` from its manifest. +#### `get_vegas_priority_weight() -> Optional[int]` + +How many slots per Vegas cycle this plugin should get. Default returns +`None`, which defers to the core. + +The Vegas ticker is otherwise a strict round robin — every plugin appears +exactly once per cycle — so with a dozen plugins enabled a live score can be +minutes stale by the time it comes round. A weight of *N* gives the plugin +*N* slots per cycle, spread evenly through it rather than clumped. + +**You usually do not need this.** When the hook returns `None`, the core +already gives a plugin `vegas_scroll.live_weight` whenever +`has_live_priority()` and `has_live_content()` are both true. Live sports get +extra turns with no code at all. + +Implement it only when the plugin knows something the core cannot. The +motivating case is favorite teams — the core can see *that* a game is live, +but not *whose*: + +```python +def get_vegas_priority_weight(self): + if not (self.has_live_priority() and self.has_live_content()): + return None # let the core decide + vegas = self.global_config.get('display', {}).get('vegas_scroll', {}) + if self._favorite_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) +``` + +The weight is per *plugin*, not per game: a scoreboard showing four live games +still occupies one slot at a time and rotates its own games within it. Values +are clamped to 1–10 by the caller, and an exception here is caught and +treated as weight 1 rather than breaking the rotation. + +Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the +default (`false`) live content preempts Vegas entirely and there is no ticker +to be weighted within. See +[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker). + ### Vegas scroll hooks Vegas mode shows multiple plugins as a single continuous scroll instead of diff --git a/src/display_controller.py b/src/display_controller.py index 01877279..e1d0e13b 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -1638,6 +1638,12 @@ class DisplayController: logger.warning("Error checking live priority for %s: %s", mode_name, e) return live + def _vegas_keeps_live_in_ticker(self) -> bool: + """Whether live content should stay in the ticker instead of preempting it.""" + coordinator = getattr(self, 'vegas_coordinator', None) + config = getattr(coordinator, 'vegas_config', None) + return bool(getattr(config, 'live_in_ticker', False)) + def _check_live_priority(self, advance=False): """Return the live-priority mode to display, or None if nothing is live. @@ -1851,14 +1857,24 @@ class DisplayController: # Check for live priority content and switch to it immediately. # advance=True so multiple simultaneously-live games take turns # (round-robin) instead of pinning to the first plugin. - if not self.on_demand_active and not wifi_status_data: + # Skipped when the ticker is keeping live content: switching + # the rotation underneath Vegas would move current_mode_index + # and stash a resume point for a takeover that never happens. + if (not self.on_demand_active and not wifi_status_data + and not (self._is_vegas_mode_active() + and self._vegas_keeps_live_in_ticker())): live_priority_mode = self._check_live_priority(advance=True) self._apply_live_priority(live_priority_mode) # Vegas scroll mode - continuous ticker across all plugins # Priority: on-demand > wifi-status > live-priority > vegas > normal rotation if self._is_vegas_mode_active() and not wifi_status_data: - live_mode = self._check_live_priority() + # Live content normally preempts the ticker entirely. With + # vegas_scroll.live_in_ticker the marquee keeps running and + # the live plugin takes extra turns inside it instead -- + # see StreamManager._apply_priority_weights. + live_mode = (None if self._vegas_keeps_live_in_ticker() + else self._check_live_priority()) if not live_mode: try: # Run Vegas mode iteration diff --git a/src/plugin_system/base_plugin.py b/src/plugin_system/base_plugin.py index ceee88bf..bd51f820 100644 --- a/src/plugin_system/base_plugin.py +++ b/src/plugin_system/base_plugin.py @@ -555,6 +555,44 @@ class BasePlugin(ABC): """ return False + def get_vegas_priority_weight(self) -> Optional[int]: + """How many slots per Vegas cycle this plugin should get, or None. + + The Vegas ticker is otherwise a strict round robin: every plugin + appears exactly once per cycle. With a dozen plugins enabled that puts + minutes between a live score and its next appearance. A weight of N + gives the plugin N slots per cycle, spread evenly through it rather + than clumped together. + + Return ``None`` (the default) to let the core decide. It gives a + plugin ``vegas_scroll.live_weight`` when ``has_live_priority()`` and + ``has_live_content()`` are both true, and 1 otherwise -- so live sports + already get extra turns without implementing this at all. + + Implement it only when the plugin knows something the core cannot. The + motivating case is favorite teams: the core can see *that* a game is + live but not *whose*, so a scoreboard that wants its favorite's game + shown more often than other live games has to say so:: + + def get_vegas_priority_weight(self): + if not (self.has_live_priority() and self.has_live_content()): + return None # let the core decide + cfg = self.global_config.get('display', {}).get('vegas_scroll', {}) + if self._favorite_is_live(): + return cfg.get('favorite_live_weight', 5) + return cfg.get('live_weight', 3) + + The weight is per *plugin*, not per game. A scoreboard showing four + live games still occupies one slot at a time and rotates its own games + within that slot; this controls how often the plugin itself comes + round. + + Returns: + Slots per cycle (clamped to 1..10 by the caller), or None to + defer to the core's own live-content weighting. + """ + return None + def get_live_modes(self) -> List[str]: """ Get list of display modes that should be used during live priority takeover. diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index 551da4d8..415908fb 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -125,6 +125,32 @@ class VegasModeConfig: plugin_order: List[str] = field(default_factory=list) excluded_plugins: Set[str] = field(default_factory=set) + # --- Live content in the ticker ------------------------------------- + # + # By default a live game preempts Vegas entirely: the display controller + # refuses to run the ticker while any plugin reports live priority, and you + # get the full-screen scoreboard instead. Set live_in_ticker to keep the + # marquee running and let live content take extra turns within it. + # + # The rotation is otherwise a strict round robin -- every plugin appears + # exactly once per cycle -- so with a dozen plugins enabled a live score + # comes round once a lap and can be minutes old on screen. Weighting lets a + # plugin claim several slots per cycle instead. + # + # Weights are per plugin, not per game: a scoreboard showing four live + # games still occupies one slot at a time, and rotates its own games within + # that slot using its own favorite_live_boost. + live_in_ticker: bool = False + + # Slots per cycle for a plugin reporting live content. 1 disables the boost + # and restores the plain round robin. + live_weight: int = 3 + + # Slots per cycle for a plugin whose live content involves a favorite team. + # Only plugins implementing get_vegas_priority_weight() can claim this -- + # the core cannot tell whose game is on, so the plugin reports it. + favorite_live_weight: int = 5 + # Performance settings target_fps: int = 125 # Target frame rate buffer_ahead: int = 2 # Number of plugins to buffer ahead @@ -175,6 +201,12 @@ class VegasModeConfig: overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')), plugin_order=list(vegas_config.get('plugin_order', [])), excluded_plugins=set(vegas_config.get('excluded_plugins', [])), + live_in_ticker=bool(vegas_config.get('live_in_ticker', False)), + # Clamped: a weight below 1 would drop the plugin from the rotation + # entirely, and a very large one starves everything else. + live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))), + favorite_live_weight=max( + 1, min(10, int(vegas_config.get('favorite_live_weight', 5)))), target_fps=int(vegas_config.get('target_fps', 125)), buffer_ahead=int(vegas_config.get('buffer_ahead', 2)), frame_based_scrolling=vegas_config.get('frame_based_scrolling', True), diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index e75e8c51..cfaaeb31 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -497,6 +497,12 @@ class VegasModeCoordinator: if not self._live_priority_check: return False + if self.vegas_config.live_in_ticker: + # The ticker keeps live content rather than yielding to it; the + # extra turns are arranged in the rotation itself, so there is + # nothing to pause for. + return False + try: live_mode = self._live_priority_check() if live_mode: diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index a97420c9..87c8e6d0 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -406,6 +406,8 @@ class StreamManager: ) logger.info("Ordered plugins: %s", ordered_plugins) + ordered_plugins = self._apply_priority_weights(ordered_plugins) + # Atomically update shared state under lock to avoid races with prefetchers with self._buffer_lock: self._ordered_plugins = ordered_plugins @@ -417,6 +419,75 @@ class StreamManager: logger.info("=" * 60) + def _plugin_weight(self, plugin_id: str) -> int: + """Slots per cycle for one plugin. + + A plugin may answer for itself via get_vegas_priority_weight() -- the + only way favorite-team awareness can reach here, since the core can see + that a game is live but not whose. When it declines (returns None, the + default), live content earns ``live_weight`` and everything else 1. + """ + plugin = None + try: + plugin = self.plugin_manager.plugins.get(plugin_id) + except (AttributeError, TypeError): + return 1 + if plugin is None: + return 1 + + try: + if hasattr(plugin, 'get_vegas_priority_weight'): + declared = plugin.get_vegas_priority_weight() + if declared is not None: + return max(1, min(10, int(declared))) + except Exception: + logger.exception("[%s] get_vegas_priority_weight() failed", plugin_id) + + try: + if (hasattr(plugin, 'has_live_priority') + and hasattr(plugin, 'has_live_content') + and plugin.has_live_priority() + and plugin.has_live_content()): + return self.config.live_weight + except Exception: + logger.exception("[%s] live-content check failed", plugin_id) + return 1 + + def _apply_priority_weights(self, ordered: List[str]) -> List[str]: + """Expand the rotation so weighted plugins take several turns per cycle. + + Smooth Weighted Round-Robin, the same scheduler the sports plugins use + to rotate their own games: a plugin of weight N appears N times per + cycle, and the repeats are spaced through the cycle rather than + clumped, so a live score is never three-in-a-row followed by a long + silence. + + Returns the input unchanged when nothing is weighted, which is both the + common case and the pre-existing behaviour. + """ + if not ordered or not self.config.live_in_ticker: + return ordered + + weights = {pid: self._plugin_weight(pid) for pid in ordered} + total = sum(weights.values()) + if total <= len(ordered): + return ordered # nothing boosted; plain round robin + + current = {pid: 0 for pid in ordered} + schedule: List[str] = [] + for _ in range(total): + for pid in ordered: + current[pid] += weights[pid] + picked = max(current, key=lambda p: current[p]) + current[picked] -= total + schedule.append(picked) + + boosted = {p: w for p, w in weights.items() if w > 1} + logger.info( + "Vegas rotation weighted: %d slots for %d plugins (boosted: %s)", + len(schedule), len(ordered), boosted) + return schedule + def _prefetch_content(self, count: int = 1) -> None: """ Prefetch content for upcoming plugins. diff --git a/test/test_vegas_live_weighting.py b/test/test_vegas_live_weighting.py new file mode 100644 index 00000000..3c4dac49 --- /dev/null +++ b/test/test_vegas_live_weighting.py @@ -0,0 +1,171 @@ +"""Tests that live content can take extra turns inside the Vegas ticker. + +Vegas was a strict round robin -- every plugin exactly once per cycle -- and +live content did not appear in it at all, because the display controller +refused to run the ticker while anything was live. With a dozen plugins +enabled that left a live score either absent or minutes stale. + +Two things change, both off by default. `live_in_ticker` keeps the marquee +running instead of yielding to a full-screen takeover, and the rotation is +expanded by Smooth Weighted Round-Robin so a weighted plugin gets several +slots per cycle, spaced through it rather than clumped. + +Weights are per plugin, not per game: a scoreboard showing four live games +still occupies one slot at a time and rotates its own games within it. +""" + +from unittest.mock import Mock + +import pytest + +from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.stream_manager import StreamManager + + +class FakePlugin: + def __init__(self, live=False, declared=None, raises=False): + self._live = live + self._declared = declared + self._raises = raises + self.enabled = True + + def has_live_priority(self): + if self._raises: + raise RuntimeError("plugin blew up") + return self._live + + def has_live_content(self): + return self._live + + def get_vegas_priority_weight(self): + if self._raises: + raise RuntimeError("plugin blew up") + return self._declared + + +def _manager(plugins, **cfg): + config = VegasModeConfig(live_in_ticker=cfg.pop('live_in_ticker', True), **cfg) + pm = Mock() + pm.plugins = plugins + sm = StreamManager.__new__(StreamManager) + sm.config = config + sm.plugin_manager = pm + return sm + + +def _counts(schedule): + return {p: schedule.count(p) for p in set(schedule)} + + +def _max_gap(schedule, plugin_id): + """Largest gap between consecutive appearances, wrapping around.""" + at = [i for i, p in enumerate(schedule) if p == plugin_id] + if len(at) < 2: + return len(schedule) + gaps = [b - a for a, b in zip(at, at[1:])] + gaps.append(len(schedule) - at[-1] + at[0]) + return max(gaps) + + +class TestWeightsComeFromTheRightPlace: + def test_a_quiet_plugin_gets_one_slot(self): + sm = _manager({'clock': FakePlugin()}) + assert sm._plugin_weight('clock') == 1 + + def test_live_content_earns_the_configured_weight(self): + sm = _manager({'mlb': FakePlugin(live=True)}, live_weight=4) + assert sm._plugin_weight('mlb') == 4 + + def test_a_plugin_may_answer_for_itself(self): + # The only route for favorite-team awareness: the core can see that a + # game is live, not whose. + sm = _manager({'mlb': FakePlugin(live=True, declared=7)}, live_weight=3) + assert sm._plugin_weight('mlb') == 7 + + def test_declaring_none_defers_to_the_core(self): + sm = _manager({'mlb': FakePlugin(live=True, declared=None)}, live_weight=3) + assert sm._plugin_weight('mlb') == 3 + + def test_a_declared_weight_is_clamped(self): + sm = _manager({'a': FakePlugin(declared=99), 'b': FakePlugin(declared=0)}) + assert sm._plugin_weight('a') == 10 + assert sm._plugin_weight('b') == 1 + + def test_a_plugin_that_raises_does_not_break_the_rotation(self): + sm = _manager({'bad': FakePlugin(raises=True)}) + assert sm._plugin_weight('bad') == 1 + + def test_an_unknown_plugin_weighs_one(self): + assert _manager({})._plugin_weight('ghost') == 1 + + +class TestTheSchedule: + def test_nothing_weighted_leaves_the_order_untouched(self): + order = ['weather', 'clock', 'news'] + sm = _manager({p: FakePlugin() for p in order}) + assert sm._apply_priority_weights(order) == order + + def test_off_by_default_the_order_is_untouched(self): + order = ['weather', 'mlb', 'news'] + sm = _manager({'weather': FakePlugin(), 'mlb': FakePlugin(live=True), + 'news': FakePlugin()}, live_in_ticker=False, live_weight=3) + assert sm._apply_priority_weights(order) == order + + def test_a_live_plugin_takes_its_share_of_slots(self): + order = ['weather', 'mlb', 'news', 'clock'] + sm = _manager({'weather': FakePlugin(), 'mlb': FakePlugin(live=True), + 'news': FakePlugin(), 'clock': FakePlugin()}, + live_weight=3) + schedule = sm._apply_priority_weights(order) + counts = _counts(schedule) + assert counts['mlb'] == 3, counts + assert counts['weather'] == counts['news'] == counts['clock'] == 1, counts + assert len(schedule) == 6 + + def test_every_plugin_still_appears(self): + # A boost must not starve anything out of the cycle. + order = ['a', 'b', 'c', 'd', 'e', 'f'] + plugins = {p: FakePlugin() for p in order} + plugins['a'] = FakePlugin(live=True, declared=10) + sm = _manager(plugins) + schedule = sm._apply_priority_weights(order) + assert set(schedule) == set(order), set(order) - set(schedule) + + def test_repeats_are_spread_not_clumped(self): + # The point of Smooth Weighted Round-Robin. Three-in-a-row followed by + # a long silence would be worse than not boosting at all. + order = ['weather', 'mlb', 'news', 'clock', 'stocks', 'f1'] + plugins = {p: FakePlugin() for p in order} + plugins['mlb'] = FakePlugin(live=True) + sm = _manager(plugins, live_weight=3) + schedule = sm._apply_priority_weights(order) + + assert _counts(schedule)['mlb'] == 3 + # Evenly spread over 8 slots means a gap of about 3, never 6. + assert _max_gap(schedule, 'mlb') <= 4, schedule + # And never twice running. + assert not any(a == b == 'mlb' for a, b in zip(schedule, schedule[1:])), schedule + + def test_a_favorite_outranks_another_live_game(self): + order = ['weather', 'mlb', 'nhl'] + sm = _manager({'weather': FakePlugin(), + 'mlb': FakePlugin(live=True, declared=5), + 'nhl': FakePlugin(live=True)}, live_weight=2) + counts = _counts(sm._apply_priority_weights(order)) + assert counts['mlb'] == 5 and counts['nhl'] == 2 and counts['weather'] == 1, counts + + def test_an_empty_rotation_is_harmless(self): + assert _manager({})._apply_priority_weights([]) == [] + + +class TestConfigParsing: + def test_defaults_preserve_todays_behaviour(self): + cfg = VegasModeConfig.from_config({}) + assert cfg.live_in_ticker is False + assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5 + + @pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)]) + def test_weights_are_clamped(self, given, expected): + cfg = VegasModeConfig.from_config( + {'display': {'vegas_scroll': {'live_weight': given}}}) + assert cfg.live_weight == expected