mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 22:58:06 +00:00
feat(vegas): let live content keep its place in the ticker (#457)
* 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 * fix(vegas): carry the new keys through config, and correct two docs Three findings from CodeRabbit, all valid. to_dict() and update() enumerate keys explicitly and had not learned the three new ones, so get_status() never reported them and a live config change never applied -- turning live_in_ticker on in the web UI would have done nothing until a restart. update() clamps the weights exactly as from_config does. The vegas_scroll key count in ADVANCED_FEATURES said 29; the template has 30. My arithmetic, not the reviewer's. The third was a documentation error rather than a code one, and I have fixed it the other way round. The docs claimed a raising get_vegas_priority_weight() is treated as weight 1. The code instead falls through to the core's own live-content check, and that is the better behaviour: the hook is only how a plugin asks for *more* than live_weight, and has_live_priority/has_live_content are separate methods guarded separately, so a plugin with a broken weight calculation should lose the favorite distinction and keep the live boost. Said so in the code, the base-plugin docstring and the API reference. The test fake now fails in each place independently, because the two failures mean different things: a broken hook still earns live_weight, a plugin that cannot say whether it is live has nothing to fall back on and weighs 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ui/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 * fix(vegas): stop the heaviest plugin doubling across the cycle seam Smooth Weighted Round-Robin spaces repeats well within a pass, but it schedules the heaviest item first and usually last as well. The strip loops, so those two are neighbours: the marquee showed the same plugin twice running at exactly the one join a within-cycle check cannot see. Observed on a live rig at 28 slots -- gaps of 6, 7, 7, 7 and then 1. Rotating the list does not fix it. Rotation preserves the cyclic order exactly, so it moves where the seam is drawn rather than the adjacency itself; the trailing entry has to be swapped with one from the middle. The first version swapped with the first slot that merely fitted, which undid the spacing this exists to protect -- it moved a repeat from a gap of 7 into a gap of 2, more clumped than the seam had ever been. It now picks the candidate furthest from any other appearance, so the repeat lands in the widest gap. Left alone when no candidate exists. A plugin holding most of the slots has to neighbour itself, and scheduling it is better than refusing to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 * fix(vegas): stop the seam repair creating the duplicate it removes Swapping the trailing repeat with a middle slot moves two elements, and the candidate filter only guarded one of them. It checked the neighbours `repeated` would acquire at j, but not what the displaced element would sit beside at the end -- so ['a','b','c','d','x','y','x','a'] came back as [...,'x','x'], the seam duplicate traded for a fresh one. Reported by CodeRabbit with that exact case. Adding the missing condition fixed it and immediately broke something else: schedule[j] is schedule[-2] when j is the second-to-last slot, so that candidate was always excluded, and ['a','b','c','a'] lost the only repair it has. The same class of mistake twice, from reasoning about which neighbours two moved elements end up with. So it no longer reasons. It performs each candidate swap, counts the cyclic duplicates in the result, and keeps the best one that has none -- preferring whichever leaves the boosted plugin most evenly spread. When no such swap exists the schedule is returned untouched, which is the unavoidable case: a plugin holding most of the slots has to neighbour itself. Fuzzed across 6,956 seam schedules: none made worse, none lost an entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,9 @@
|
|||||||
"plugin_rotation_order": [],
|
"plugin_rotation_order": [],
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
|
"live_in_ticker": false,
|
||||||
|
"live_weight": 3,
|
||||||
|
"favorite_live_weight": 5,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"scroll_speed": 50,
|
"scroll_speed": 50,
|
||||||
"separator_width": 32,
|
"separator_width": 32,
|
||||||
|
|||||||
@@ -64,10 +64,98 @@ JSON is optional.
|
|||||||
| `target_fps` | `125` | Target frame rate |
|
| `target_fps` | `125` | Target frame rate |
|
||||||
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
| `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 30 keys in
|
||||||
total. See the full list in
|
total. See the full list in
|
||||||
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
[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 — **including across the seam**
|
||||||
|
where the cycle loops back on itself. Smooth Weighted Round-Robin schedules the
|
||||||
|
heaviest item first and usually last as well, so the strip would otherwise show
|
||||||
|
it twice running at exactly the one join a within-cycle check cannot see. The
|
||||||
|
trailing repeat is moved into the widest remaining gap. Where a double is
|
||||||
|
unavoidable — a plugin holding most of the slots has to neighbour itself — the
|
||||||
|
schedule is left as it is.
|
||||||
|
|
||||||
|
#### 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
|
### Per-Plugin Configuration
|
||||||
|
|
||||||
Override Vegas behavior for specific plugins:
|
Override Vegas behavior for specific plugins:
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ logical image to multiple chained physical panels.
|
|||||||
## `display.vegas_scroll` — continuous scroll mode
|
## `display.vegas_scroll` — continuous scroll mode
|
||||||
|
|
||||||
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
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 |
|
| Key | Type / default |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -135,6 +136,9 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
|||||||
| `max_cycle_duration` | int, `240` |
|
| `max_cycle_duration` | int, `240` |
|
||||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
| `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
|
## `sync` — multi-display synchronization
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,47 @@ Default returns `False`.
|
|||||||
List of display modes to show during a live takeover. Default returns the
|
List of display modes to show during a live takeover. Default returns the
|
||||||
plugin's `display_modes` from its manifest.
|
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. An exception here is caught and logged, and
|
||||||
|
the core then falls back to its own live-content check — so a plugin whose
|
||||||
|
weight calculation is broken still gets `live_weight` for a game that really
|
||||||
|
is live, rather than being demoted to 1.
|
||||||
|
|
||||||
|
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 scroll hooks
|
||||||
|
|
||||||
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
||||||
|
|||||||
@@ -1694,6 +1694,12 @@ class DisplayController:
|
|||||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||||
return live
|
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):
|
def _check_live_priority(self, advance=False):
|
||||||
"""Return the live-priority mode to display, or None if nothing is live.
|
"""Return the live-priority mode to display, or None if nothing is live.
|
||||||
|
|
||||||
@@ -1907,14 +1913,24 @@ class DisplayController:
|
|||||||
# Check for live priority content and switch to it immediately.
|
# Check for live priority content and switch to it immediately.
|
||||||
# advance=True so multiple simultaneously-live games take turns
|
# advance=True so multiple simultaneously-live games take turns
|
||||||
# (round-robin) instead of pinning to the first plugin.
|
# (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)
|
live_priority_mode = self._check_live_priority(advance=True)
|
||||||
self._apply_live_priority(live_priority_mode)
|
self._apply_live_priority(live_priority_mode)
|
||||||
|
|
||||||
# Vegas scroll mode - continuous ticker across all plugins
|
# Vegas scroll mode - continuous ticker across all plugins
|
||||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||||
if self._is_vegas_mode_active() and not wifi_status_data:
|
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:
|
if not live_mode:
|
||||||
try:
|
try:
|
||||||
# Run Vegas mode iteration
|
# Run Vegas mode iteration
|
||||||
|
|||||||
@@ -555,6 +555,48 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
return False
|
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.
|
||||||
|
|
||||||
|
Raising is safe: the core logs it and falls back to its own
|
||||||
|
live-content check, so a broken weight calculation costs the plugin
|
||||||
|
the favorite distinction but not the live boost.
|
||||||
|
|
||||||
|
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]:
|
def get_live_modes(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of display modes that should be used during live priority takeover.
|
Get list of display modes that should be used during live priority takeover.
|
||||||
|
|||||||
@@ -125,6 +125,32 @@ class VegasModeConfig:
|
|||||||
plugin_order: List[str] = field(default_factory=list)
|
plugin_order: List[str] = field(default_factory=list)
|
||||||
excluded_plugins: Set[str] = field(default_factory=set)
|
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
|
# Performance settings
|
||||||
target_fps: int = 125 # Target frame rate
|
target_fps: int = 125 # Target frame rate
|
||||||
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
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')),
|
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
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)),
|
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||||
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
||||||
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
||||||
@@ -204,6 +236,9 @@ class VegasModeConfig:
|
|||||||
'lead_in_width': self.lead_in_width,
|
'lead_in_width': self.lead_in_width,
|
||||||
'plugins_per_cycle': self.plugins_per_cycle,
|
'plugins_per_cycle': self.plugins_per_cycle,
|
||||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||||
|
'live_in_ticker': self.live_in_ticker,
|
||||||
|
'live_weight': self.live_weight,
|
||||||
|
'favorite_live_weight': self.favorite_live_weight,
|
||||||
'overflow_mode': self.overflow_mode,
|
'overflow_mode': self.overflow_mode,
|
||||||
'plugin_order': self.plugin_order,
|
'plugin_order': self.plugin_order,
|
||||||
'excluded_plugins': list(self.excluded_plugins),
|
'excluded_plugins': list(self.excluded_plugins),
|
||||||
@@ -371,6 +406,15 @@ class VegasModeConfig:
|
|||||||
|
|
||||||
if 'enabled' in vegas_config:
|
if 'enabled' in vegas_config:
|
||||||
self.enabled = vegas_config['enabled']
|
self.enabled = vegas_config['enabled']
|
||||||
|
if 'live_in_ticker' in vegas_config:
|
||||||
|
self.live_in_ticker = bool(vegas_config['live_in_ticker'])
|
||||||
|
# Clamped exactly as from_config does: a weight below 1 would drop the
|
||||||
|
# plugin from the rotation, and a huge one starves everything else.
|
||||||
|
if 'live_weight' in vegas_config:
|
||||||
|
self.live_weight = max(1, min(10, int(vegas_config['live_weight'])))
|
||||||
|
if 'favorite_live_weight' in vegas_config:
|
||||||
|
self.favorite_live_weight = max(
|
||||||
|
1, min(10, int(vegas_config['favorite_live_weight'])))
|
||||||
if 'scroll_speed' in vegas_config:
|
if 'scroll_speed' in vegas_config:
|
||||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||||
if 'separator_width' in vegas_config:
|
if 'separator_width' in vegas_config:
|
||||||
|
|||||||
@@ -528,6 +528,12 @@ class VegasModeCoordinator:
|
|||||||
if not self._live_priority_check:
|
if not self._live_priority_check:
|
||||||
return False
|
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:
|
try:
|
||||||
live_mode = self._live_priority_check()
|
live_mode = self._live_priority_check()
|
||||||
if live_mode:
|
if live_mode:
|
||||||
|
|||||||
@@ -406,6 +406,8 @@ class StreamManager:
|
|||||||
)
|
)
|
||||||
logger.info("Ordered plugins: %s", ordered_plugins)
|
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
|
# Atomically update shared state under lock to avoid races with prefetchers
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
self._ordered_plugins = ordered_plugins
|
self._ordered_plugins = ordered_plugins
|
||||||
@@ -417,6 +419,143 @@ class StreamManager:
|
|||||||
|
|
||||||
logger.info("=" * 60)
|
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:
|
||||||
|
# Deliberately falls through to the core's own live check rather
|
||||||
|
# than demoting to 1. The plugin's weight calculation is broken,
|
||||||
|
# but has_live_priority() and has_live_content() are separate
|
||||||
|
# methods guarded separately below -- a plugin that genuinely has
|
||||||
|
# a live game should still get live_weight for it.
|
||||||
|
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)
|
||||||
|
|
||||||
|
schedule = self._unclump_seam(schedule)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unclump_seam(schedule: List[str]) -> List[str]:
|
||||||
|
"""Stop the heaviest plugin sitting on both ends of the cycle.
|
||||||
|
|
||||||
|
Smooth Weighted Round-Robin spaces repeats well *within* a pass, but
|
||||||
|
it schedules the heaviest item first and often last too. The strip
|
||||||
|
loops, so those two are neighbours: the one place the marquee shows
|
||||||
|
the same plugin twice running is the seam between cycles.
|
||||||
|
|
||||||
|
Rotating the list cannot fix this. Rotation preserves the cyclic order
|
||||||
|
exactly, so it only moves where the seam is drawn, not the adjacency
|
||||||
|
itself. The trailing entry has to be swapped with one from the middle
|
||||||
|
whose neighbours differ from it, which breaks the pair without
|
||||||
|
creating another.
|
||||||
|
|
||||||
|
Left alone when no such position exists -- a rotation short enough or
|
||||||
|
lopsided enough to have none is one where the plugin is unavoidably
|
||||||
|
adjacent to itself anyway.
|
||||||
|
"""
|
||||||
|
if len(schedule) < 3 or schedule[0] != schedule[-1]:
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
repeated = schedule[-1]
|
||||||
|
size = len(schedule)
|
||||||
|
|
||||||
|
def cyclic_doubles(seq) -> int:
|
||||||
|
return sum(1 for i in range(size) if seq[i] == seq[(i + 1) % size])
|
||||||
|
|
||||||
|
def clearance(seq, value) -> int:
|
||||||
|
"""Smallest cyclic gap between appearances of `value`."""
|
||||||
|
at = [i for i, v in enumerate(seq) if v == value]
|
||||||
|
if len(at) < 2:
|
||||||
|
return size
|
||||||
|
return min(min((b - a) % size, (a - b) % size)
|
||||||
|
for i, a in enumerate(at) for b in at[i + 1:])
|
||||||
|
|
||||||
|
# Try each swap and judge the result, rather than reasoning about which
|
||||||
|
# neighbours the two moved elements will end up with. That reasoning is
|
||||||
|
# where the first version went wrong: it guarded the slot `repeated`
|
||||||
|
# moves into but not the one the displaced element lands in, so
|
||||||
|
# ['a','b','c','d','x','y','x','a'] came back ending ['x','x'] -- the
|
||||||
|
# seam duplicate traded for a fresh one.
|
||||||
|
best = None
|
||||||
|
best_clearance = -1
|
||||||
|
for j in range(1, size - 1):
|
||||||
|
candidate = list(schedule)
|
||||||
|
candidate[j], candidate[-1] = candidate[-1], candidate[j]
|
||||||
|
if cyclic_doubles(candidate):
|
||||||
|
continue
|
||||||
|
# Among the repairs that work, prefer the one that leaves the
|
||||||
|
# boosted plugin most evenly spread; taking the first that merely
|
||||||
|
# fits moved a repeat from a gap of 7 into a gap of 2.
|
||||||
|
spread = clearance(candidate, repeated)
|
||||||
|
if spread > best_clearance:
|
||||||
|
best, best_clearance = candidate, spread
|
||||||
|
|
||||||
|
# None exists when the value is unavoidably adjacent to itself -- a
|
||||||
|
# plugin holding most of the slots has to be. Schedule it as it is
|
||||||
|
# rather than refuse.
|
||||||
|
return best if best is not None else schedule
|
||||||
|
|
||||||
def _prefetch_content(self, count: int = 1) -> None:
|
def _prefetch_content(self, count: int = 1) -> None:
|
||||||
"""
|
"""
|
||||||
Prefetch content for upcoming plugins.
|
Prefetch content for upcoming plugins.
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""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:
|
||||||
|
"""A plugin that can fail in each place independently.
|
||||||
|
|
||||||
|
hook_raises and live_raises are separate because they mean different
|
||||||
|
things: a broken weight calculation should still leave the core's own
|
||||||
|
live-content check usable, while a plugin that cannot answer whether it is
|
||||||
|
live at all has nothing left to fall back on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, live=False, declared=None, raises=False,
|
||||||
|
hook_raises=False, live_raises=False):
|
||||||
|
self._live = live
|
||||||
|
self._declared = declared
|
||||||
|
self._hook_raises = hook_raises or raises
|
||||||
|
self._live_raises = live_raises or raises
|
||||||
|
self.enabled = True
|
||||||
|
|
||||||
|
def has_live_priority(self):
|
||||||
|
if self._live_raises:
|
||||||
|
raise RuntimeError("cannot say whether I am live")
|
||||||
|
return self._live
|
||||||
|
|
||||||
|
def has_live_content(self):
|
||||||
|
return self._live
|
||||||
|
|
||||||
|
def get_vegas_priority_weight(self):
|
||||||
|
if self._hook_raises:
|
||||||
|
raise RuntimeError("weight calculation 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_everywhere_weighs_one(self):
|
||||||
|
sm = _manager({'bad': FakePlugin(raises=True)})
|
||||||
|
assert sm._plugin_weight('bad') == 1
|
||||||
|
|
||||||
|
def test_a_broken_hook_still_earns_the_live_boost(self):
|
||||||
|
# The hook is only how a plugin asks for *more* than live_weight.
|
||||||
|
# Losing it should cost the favorite distinction, not the live boost:
|
||||||
|
# has_live_priority/has_live_content are separate and still work.
|
||||||
|
sm = _manager({'mlb': FakePlugin(live=True, hook_raises=True)},
|
||||||
|
live_weight=4)
|
||||||
|
assert sm._plugin_weight('mlb') == 4
|
||||||
|
|
||||||
|
def test_a_broken_hook_on_a_quiet_plugin_weighs_one(self):
|
||||||
|
sm = _manager({'clock': FakePlugin(live=False, hook_raises=True)},
|
||||||
|
live_weight=4)
|
||||||
|
assert sm._plugin_weight('clock') == 1
|
||||||
|
|
||||||
|
def test_a_plugin_that_cannot_say_whether_it_is_live_weighs_one(self):
|
||||||
|
# Nothing left to fall back on, so no boost.
|
||||||
|
sm = _manager({'mlb': FakePlugin(live=True, live_raises=True)},
|
||||||
|
live_weight=4)
|
||||||
|
assert sm._plugin_weight('mlb') == 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_nothing_doubles_across_the_cycle_seam(self):
|
||||||
|
# The strip loops, so the last slot neighbours the first. Smooth
|
||||||
|
# Weighted Round-Robin schedules the heaviest item first and often
|
||||||
|
# last too, which put the one clump the algorithm exists to avoid at
|
||||||
|
# the one place a within-cycle check cannot see.
|
||||||
|
order = ['baseball', 'weather', 'geochron', 'flights', 'stocks',
|
||||||
|
'oftheday', 'youtube', 'stocknews', 'leaderboard',
|
||||||
|
'countdown', 'odds', 'f1', 'football', 'music']
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['baseball'] = FakePlugin(live=True, declared=5)
|
||||||
|
plugins['football'] = FakePlugin(live=True, declared=3)
|
||||||
|
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||||
|
|
||||||
|
n = len(schedule)
|
||||||
|
doubles = [schedule[i] for i in range(n)
|
||||||
|
if schedule[i] == schedule[(i + 1) % n]]
|
||||||
|
assert not doubles, "%r repeats across the seam in %r" % (doubles, schedule)
|
||||||
|
|
||||||
|
def test_the_seam_repair_keeps_every_slot(self):
|
||||||
|
order = ['a', 'b', 'c', 'd', 'e', 'f']
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['a'] = FakePlugin(live=True, declared=4)
|
||||||
|
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||||
|
assert _counts(schedule)['a'] == 4, _counts(schedule)
|
||||||
|
assert sorted(schedule) == sorted(
|
||||||
|
['a'] * 4 + ['b', 'c', 'd', 'e', 'f']), schedule
|
||||||
|
|
||||||
|
def test_the_repair_uses_the_widest_gap(self):
|
||||||
|
# Moving the trailing repeat into the first slot that merely fits
|
||||||
|
# undoes the spacing: on a 28-slot rotation that turned a gap of 7
|
||||||
|
# into a gap of 2, which is more clumped than the seam ever was.
|
||||||
|
order = ['a'] + ['p%d' % i for i in range(13)]
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['a'] = FakePlugin(live=True, declared=4)
|
||||||
|
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||||
|
at = [i for i, p in enumerate(schedule) if p == 'a']
|
||||||
|
gaps = [b - a for a, b in zip(at, at[1:])]
|
||||||
|
gaps.append(len(schedule) - at[-1] + at[0])
|
||||||
|
ideal = len(schedule) / len(at)
|
||||||
|
assert min(gaps) >= ideal / 2, "gaps %r for ideal %.1f" % (gaps, ideal)
|
||||||
|
|
||||||
|
def test_an_unavoidable_double_is_left_alone(self):
|
||||||
|
# Five of seven slots are the same plugin, so it must neighbour
|
||||||
|
# itself. Better to schedule it than to refuse or loop forever.
|
||||||
|
order = ['a', 'b', 'c']
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['a'] = FakePlugin(live=True, declared=5)
|
||||||
|
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||||
|
assert _counts(schedule) == {'a': 5, 'b': 1, 'c': 1}, _counts(schedule)
|
||||||
|
assert set(schedule) == {'a', 'b', 'c'}
|
||||||
|
|
||||||
|
def test_the_repair_never_creates_a_new_double(self):
|
||||||
|
# The first version guarded the slot the repeated value moves *into*
|
||||||
|
# but not the one the displaced element lands in, so this traded the
|
||||||
|
# seam duplicate for a fresh one and came back ending ['x', 'x'].
|
||||||
|
sm = _manager({})
|
||||||
|
out = sm._unclump_seam(['a', 'b', 'c', 'd', 'x', 'y', 'x', 'a'])
|
||||||
|
n = len(out)
|
||||||
|
doubles = [out[i] for i in range(n) if out[i] == out[(i + 1) % n]]
|
||||||
|
assert not doubles, "%r in %r" % (doubles, out)
|
||||||
|
assert sorted(out) == sorted(['a', 'b', 'c', 'd', 'x', 'y', 'x', 'a'])
|
||||||
|
|
||||||
|
def test_the_last_two_slots_are_a_usable_swap(self):
|
||||||
|
# Reasoning about indices said this candidate was unsafe because
|
||||||
|
# schedule[j] is schedule[-2]; after the swap its neighbour is the
|
||||||
|
# repeated value, not itself. Refusing it left the only repair this
|
||||||
|
# schedule has on the table.
|
||||||
|
assert _manager({})._unclump_seam(['a', 'b', 'c', 'a']) == ['a', 'b', 'a', 'c']
|
||||||
|
|
||||||
|
def test_no_seam_schedule_is_ever_made_worse(self):
|
||||||
|
import random
|
||||||
|
sm = _manager({})
|
||||||
|
random.seed(11)
|
||||||
|
checked = 0
|
||||||
|
for size in range(3, 10):
|
||||||
|
for _ in range(400):
|
||||||
|
original = [random.choice('abcd') for _ in range(size)]
|
||||||
|
if original[0] != original[-1]:
|
||||||
|
continue
|
||||||
|
checked += 1
|
||||||
|
out = sm._unclump_seam(list(original))
|
||||||
|
n = len(out)
|
||||||
|
before = sum(1 for i in range(n)
|
||||||
|
if original[i] == original[(i + 1) % n])
|
||||||
|
after = sum(1 for i in range(n) if out[i] == out[(i + 1) % n])
|
||||||
|
assert after <= before, (original, out)
|
||||||
|
assert sorted(out) == sorted(original), (original, out)
|
||||||
|
assert checked > 100, "the generator stopped producing seam cases"
|
||||||
|
|
||||||
|
def test_a_schedule_too_short_to_repair_is_returned_as_is(self):
|
||||||
|
sm = _manager({})
|
||||||
|
assert sm._unclump_seam(['a', 'a']) == ['a', 'a']
|
||||||
|
assert sm._unclump_seam(['a']) == ['a']
|
||||||
|
assert sm._unclump_seam([]) == []
|
||||||
|
|
||||||
|
def test_a_schedule_with_no_seam_clash_is_untouched(self):
|
||||||
|
sm = _manager({})
|
||||||
|
plain = ['a', 'b', 'c', 'a', 'd']
|
||||||
|
assert sm._unclump_seam(plain) == plain
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user