feat(vegas): keep live games in the ticker by default

display.vegas_scroll.live_in_ticker now defaults to true: through a live
game the marquee keeps running and the live scoreboard takes extra turns in
it -- its cards updating in place while they scroll -- instead of the ticker
giving way to the full-screen scoreboard.

The new default would reach nobody on its own: every existing config holds
an explicit false copied from the template (there was no control for it),
and the template merge only adds missing keys. ConfigManager therefore turns
a stored false on once, with a backup, and records live_in_ticker_migrated
so a false chosen afterwards stays. The marker is never in the template.

A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests
that pin the full-screen takeover now say live_in_ticker=false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 18:36:39 -04:00
co-authored by Claude Opus 5.5
parent 8cce532ea9
commit 559ef14a5c
16 changed files with 246 additions and 30 deletions
+14
View File
@@ -134,6 +134,20 @@ class TestJsonPartialSaves:
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['live_refresh'] is True
def test_live_games_can_be_kept_in_the_ticker_or_not(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'vegas_live_in_ticker': False})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
# An unticked box is absent from a submitted form: that is false too.
resp = _post_json(api_v3_client, {'__form_section': 'display',
'vegas_scroll_speed': '50',
'vegas_live_in_ticker': 'on'})
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is True
resp = _post_json(api_v3_client, {'__form_section': 'display',
'vegas_scroll_speed': '50'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
assert resp.status_code == 200, resp.get_json()
@@ -0,0 +1,108 @@
"""live_in_ticker's new default reaches existing installs, once (src/config_manager.py).
3.8.0 makes display.vegas_scroll.live_in_ticker true. Every existing config
holds an explicit false copied from the template, which the template merge
never touches (it only adds missing keys), so ConfigManager turns that false
on once and marks the config. A false chosen after that -- the new checkbox,
or by hand -- must stay false.
"""
import json
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.config_manager import ConfigManager # noqa: E402
REPO = Path(__file__).resolve().parent.parent
MARKER = ConfigManager.LIVE_IN_TICKER_MARKER
@pytest.fixture
def files(tmp_path):
template = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": False, "live_in_ticker": True}}}
paths = {name: tmp_path / f"{name}.json" for name in ("config", "secrets", "template")}
paths["template"].write_text(json.dumps(template))
paths["secrets"].write_text("{}")
return paths
def _load(files, config):
files["config"].write_text(json.dumps(config))
manager = ConfigManager(config_path=str(files["config"]),
secrets_path=str(files["secrets"]))
manager.template_path = str(files["template"])
manager.load_config()
return manager, json.loads(files["config"].read_text())
def _vegas(saved):
return saved["display"]["vegas_scroll"]
def test_the_old_default_is_turned_on_once_and_marked(files):
old = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": False}}}
manager, saved = _load(files, old)
assert _vegas(saved)["live_in_ticker"] is True
assert _vegas(saved)[MARKER] is True
assert _vegas(manager.config)["live_in_ticker"] is True
# The config as it was is kept beside it.
backup = json.loads(Path(f"{files['config']}.backup").read_text())
assert _vegas(backup)["live_in_ticker"] is False
def test_false_chosen_after_the_migration_stays_false(files):
chosen = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": False,
MARKER: True}}}
_manager, saved = _load(files, chosen)
assert _vegas(saved)["live_in_ticker"] is False
def test_a_marked_config_is_not_written_again(files):
marked = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": True,
MARKER: True}}}
files["config"].write_text(json.dumps(marked))
before = files["config"].stat().st_mtime_ns
manager = ConfigManager(config_path=str(files["config"]),
secrets_path=str(files["secrets"]))
manager.template_path = str(files["template"])
manager.load_config()
assert files["config"].stat().st_mtime_ns == before
assert not Path(f"{files['config']}.backup").exists()
def test_a_config_without_vegas_settings_gets_the_new_default(files):
_manager, saved = _load(files, {"display": {"brightness": 90}})
assert _vegas(saved)["live_in_ticker"] is True
assert _vegas(saved)[MARKER] is True
def test_true_already_is_only_marked(files):
on = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": True}}}
_manager, saved = _load(files, on)
assert _vegas(saved)["live_in_ticker"] is True and _vegas(saved)[MARKER] is True
def test_a_second_load_keeps_a_false_set_in_between(files):
old = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": False}}}
_manager, saved = _load(files, old)
_vegas(saved)["live_in_ticker"] = False # the checkbox, unticked
_manager, saved = _load(files, saved)
assert _vegas(saved)["live_in_ticker"] is False
def test_the_shipped_template_has_the_new_default_and_never_the_marker():
template = json.loads((REPO / "config" / "config.template.json").read_text(encoding="utf-8"))
vegas = template["display"]["vegas_scroll"]
assert vegas["live_in_ticker"] is True
assert MARKER not in vegas
+4
View File
@@ -67,10 +67,13 @@ class TestConfigWhileStopped:
class TestLivePriorityThrottle:
"""The full-screen takeover's live scan: live_in_ticker off, no longer the default."""
def test_scan_runs_at_most_once_per_interval(self, monkeypatch):
now = [1000.0]
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0])
c = _coordinator()
c.vegas_config.live_in_ticker = False
c._live_priority_check = MagicMock(return_value=None)
for _ in range(10):
@@ -84,6 +87,7 @@ class TestLivePriorityThrottle:
def test_live_content_still_pauses_vegas(self, monkeypatch):
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0)
c = _coordinator()
c.vegas_config.live_in_ticker = False
c._live_priority_check = MagicMock(return_value='nfl_live')
c.pause = MagicMock()
assert c.run_frame() is False
+7 -2
View File
@@ -58,11 +58,16 @@ def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
def _live_coordinator(live):
"""A coordinator running the real run_frame(), with a switchable live check."""
"""A coordinator running the real run_frame(), with a switchable live check.
live_in_ticker off: these pin the full-screen takeover, which is no longer
the default.
"""
coord = _coordinator({})
del coord.run_frame # the real one: it is what refuses frames while paused
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True}}})
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True,
'live_in_ticker': False}}})
coord.render_pipeline.has_deferred.return_value = False
coord.render_pipeline.needs_extension.return_value = False
coord.render_pipeline.render_frame.return_value = True
+6 -6
View File
@@ -5,10 +5,10 @@ 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.
Two things change. `live_in_ticker` keeps the marquee running instead of
yielding to a full-screen takeover (on by default since 3.8.0), 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.
@@ -288,9 +288,9 @@ class TestTheSchedule:
class TestConfigParsing:
def test_defaults_preserve_todays_behaviour(self):
def test_live_games_stay_in_the_ticker_by_default(self):
cfg = VegasModeConfig.from_config({})
assert cfg.live_in_ticker is False
assert cfg.live_in_ticker is True
assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5
@pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)])