Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/display_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,10 @@ def _vegas_interrupt():

# Run plugin updates inside the Vegas loop so the inter-iteration
# gap is <1 ms (nothing left for _tick_plugin_updates() to do).
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates)
# Use the Vegas-aware variant so plugins that got fresh data are
# hot-swapped into the scroll promptly instead of waiting for the
# next full cycle.
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates_for_vegas)

# Wire multi-display sync into Vegas render pipeline
follower_pos = self.config.get("sync", {}).get("follower_position", "left")
Expand Down Expand Up @@ -828,6 +831,42 @@ def _update_modules(self):
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)

def _tick_plugin_updates_for_vegas(self):
"""Run scheduled plugin updates and tell Vegas mode which plugins
actually got fresh data, so it can hot-swap them into the scroll
without waiting for a full cycle to complete.

Used as the Vegas coordinator's update callback instead of the plain
_tick_plugin_updates() so that a live score change is reflected in
the ticker within a few seconds rather than at the next cycle
boundary (which, depending on min/max_cycle_duration, can be
minutes away). Restores wiring that PR #299 added and PR #330's
sync-mode refactor inadvertently dropped: coordinator.mark_plugin_updated()
has been unreachable dead code since.
"""
if not self.plugin_manager or not hasattr(self.plugin_manager, "plugin_last_update"):
self._tick_plugin_updates()
return

old_times = dict(self.plugin_manager.plugin_last_update)
self._tick_plugin_updates()

vc = getattr(self, "vegas_coordinator", None)
if vc is None:
return

updated = [
plugin_id for plugin_id, new_time in self.plugin_manager.plugin_last_update.items()
if new_time > old_times.get(plugin_id, 0.0)
]
Comment thread
ChuckBuilds marked this conversation as resolved.
if updated:
logger.info("Vegas update tick: %d plugin(s) updated: %s", len(updated), updated)
for plugin_id in updated:
try:
vc.mark_plugin_updated(plugin_id)
except Exception: # pylint: disable=broad-except
logger.exception("Error marking plugin %s updated for Vegas", plugin_id)

def _tick_plugin_updates(self):
"""Run scheduled plugin updates if the plugin manager supports them."""
if not self.plugin_manager:
Expand Down
8 changes: 8 additions & 0 deletions src/vegas_mode/render_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ def should_recompose(self) -> bool:
Returns True when:
- Cycle is complete and we should start fresh
- Staging buffer has new content
- A plugin currently visible in the scroll has pending updated data
(e.g. a live score changed) — standalone (non-sync) mode only
"""
if self._cycle_complete:
return True
Expand All @@ -314,6 +316,12 @@ def should_recompose(self) -> bool:
if buffer_status['staging_count'] > 0:
return True

# Trigger recompose when pending updates affect visible segments, so
# live score/status changes reach the display within a few seconds
# instead of waiting for the next full cycle.
if self.stream_manager.has_pending_updates_for_visible_segments():
return True

return False

def hot_swap_content(self) -> bool:
Expand Down
76 changes: 76 additions & 0 deletions test/test_display_controller_vegas_tick.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""
Regression tests for DisplayController._tick_plugin_updates_for_vegas().

PR #299 added logic to detect which plugins actually got fresh data on a
scheduled-update tick and notify Vegas mode via
vegas_coordinator.mark_plugin_updated(), so a live score change reaches the
scroll within seconds instead of waiting for a full cycle. PR #330's
multi-display sync refactor deleted this method (folding the callback back
to the plain _tick_plugin_updates(), which reports nothing), silently
orphaning VegasModeCoordinator.mark_plugin_updated() -- it has had zero
callers since.
"""

from unittest.mock import MagicMock

from src.display_controller import DisplayController


def _make_controller(plugin_last_update, vegas_coordinator=None):
dc = object.__new__(DisplayController)
dc.plugin_manager = MagicMock()
dc.plugin_manager.plugin_last_update = dict(plugin_last_update)
dc.vegas_coordinator = vegas_coordinator
return dc


class TestTickPluginUpdatesForVegas:
def test_marks_only_plugins_whose_timestamp_advanced(self):
dc = _make_controller({"stock-news": 100.0, "odds-ticker": 100.0})
vc = MagicMock()
dc.vegas_coordinator = vc

# Simulate run_scheduled_updates() advancing only stock-news.
def fake_tick():
dc.plugin_manager.plugin_last_update["stock-news"] = 200.0
dc._tick_plugin_updates = fake_tick

dc._tick_plugin_updates_for_vegas()

vc.mark_plugin_updated.assert_called_once_with("stock-news")

def test_no_advance_marks_nothing(self):
dc = _make_controller({"stock-news": 100.0})
vc = MagicMock()
dc.vegas_coordinator = vc
dc._tick_plugin_updates = lambda: None

dc._tick_plugin_updates_for_vegas()

vc.mark_plugin_updated.assert_not_called()

def test_no_vegas_coordinator_does_not_raise(self):
dc = _make_controller({"stock-news": 100.0}, vegas_coordinator=None)

def fake_tick():
dc.plugin_manager.plugin_last_update["stock-news"] = 200.0
dc._tick_plugin_updates = fake_tick

dc._tick_plugin_updates_for_vegas() # must not raise

def test_mark_plugin_updated_exception_does_not_propagate(self):
"""One plugin's mark_plugin_updated failing must not stop the tick
or crash the update loop it runs in."""
dc = _make_controller({"a": 1.0, "b": 1.0})
vc = MagicMock()
vc.mark_plugin_updated.side_effect = [RuntimeError("boom"), None]
dc.vegas_coordinator = vc

def fake_tick():
dc.plugin_manager.plugin_last_update["a"] = 2.0
dc.plugin_manager.plugin_last_update["b"] = 2.0
dc._tick_plugin_updates = fake_tick

dc._tick_plugin_updates_for_vegas() # must not raise

assert vc.mark_plugin_updated.call_count == 2
72 changes: 72 additions & 0 deletions test/test_vegas_render_pipeline_recompose.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""
Regression tests for RenderPipeline.should_recompose()'s pending-updates check.

PR #299 added a check so a plugin's live score/status change (a "pending
update" in StreamManager) triggers a hot-swap within a few seconds instead
of waiting for a full scroll cycle to complete. PR #330 (multi-display sync)
refactored should_recompose() and dropped that check entirely -- not just
gated behind the new sync-mode deferral it added, but removed outright, so
even standalone (non-sync) installations silently lost live-refresh and fell
back to waiting for full cycle boundaries (which, depending on
min/max_cycle_duration, can be minutes).
"""

from unittest.mock import MagicMock

from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.render_pipeline import RenderPipeline


class FakeDisplayManager:
width = 64
height = 32


def _make_pipeline(sync_manager=None):
stream_manager = MagicMock()
stream_manager.get_buffer_status.return_value = {'staging_count': 0}
pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager)
pipeline.sync_manager = sync_manager
return pipeline, stream_manager


class TestShouldRecompose:
def test_cycle_complete_always_recomposes(self):
pipeline, stream_manager = _make_pipeline()
pipeline._cycle_complete = True
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True

def test_no_pending_updates_no_staging_does_not_recompose(self):
pipeline, stream_manager = _make_pipeline()
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is False

def test_pending_updates_on_visible_segment_triggers_recompose(self):
"""The actual regression: a live-updated plugin currently in view
must trigger a recompose instead of waiting for cycle end."""
pipeline, stream_manager = _make_pipeline()
stream_manager.has_pending_updates_for_visible_segments.return_value = True
assert pipeline.should_recompose() is True

def test_staging_buffer_content_triggers_recompose(self):
pipeline, stream_manager = _make_pipeline()
stream_manager.get_buffer_status.return_value = {'staging_count': 1}
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True

def test_sync_active_defers_pending_updates_to_cycle_boundary(self):
"""Sync-mode deferral (PR #330's actual intent) must still hold:
pending updates alone must NOT trigger a mid-cycle hot-swap when a
follower display is attached, since that causes a visible
freeze+jump on the follower. This must keep working after
restoring the non-sync pending-updates check above."""
pipeline, stream_manager = _make_pipeline(sync_manager=MagicMock())
stream_manager.has_pending_updates_for_visible_segments.return_value = True
assert pipeline.should_recompose() is False

def test_sync_active_still_recomposes_on_cycle_complete(self):
pipeline, stream_manager = _make_pipeline(sync_manager=MagicMock())
pipeline._cycle_complete = True
stream_manager.has_pending_updates_for_visible_segments.return_value = True
assert pipeline.should_recompose() is True
Loading