mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a413849891 | ||
|
|
7b6b7cd153 | ||
|
|
932e89ccb8 | ||
|
|
94032e2101 | ||
|
|
554fb858af | ||
|
|
a1c528a091 | ||
|
|
105c6df019 | ||
|
|
62919a13e3 | ||
|
|
616d21c6d3 | ||
|
|
d69dfbbaee | ||
|
|
ec96422803 | ||
|
|
8d57a748a7 |
@@ -88,7 +88,6 @@
|
||||
}
|
||||
},
|
||||
"timezone": "America/New_York",
|
||||
"target_fps": 100,
|
||||
"location": {
|
||||
"city": "Tampa",
|
||||
"state": "Florida",
|
||||
|
||||
@@ -145,77 +145,6 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
raise NotImplementedError("Plugins must implement display()")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Global (whole-device) configuration
|
||||
# -------------------------------------------------------------------------
|
||||
@property
|
||||
def global_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
The full LEDMatrix configuration, for reading device-wide settings.
|
||||
|
||||
``self.config`` is only this plugin's own slice, so cross-cutting
|
||||
settings — ``target_fps``, ``timezone``, ``location`` — were previously
|
||||
unreachable from a plugin without reaching into a manager by hand.
|
||||
|
||||
Resolution order mirrors the timezone helpers the sports plugins
|
||||
already ship: ``plugin_manager.config_manager`` first (the cores that
|
||||
hang it there), then ``cache_manager.config_manager``. Returns ``{}``
|
||||
when neither is available, so callers can use plain ``.get()`` without
|
||||
guarding, and a plugin on a core that predates this property still
|
||||
loads — ``getattr(self, 'global_config', {})`` simply yields the
|
||||
default.
|
||||
|
||||
Treat as read-only: the returned dict is the live config the core is
|
||||
using, so mutating it edits every other consumer's view and can be
|
||||
persisted back to disk.
|
||||
|
||||
Assignment is still allowed and wins over the resolved value. Several
|
||||
shipped plugins (news, stock-news, ledmatrix-stocks, ledmatrix-
|
||||
elections, ledmatrix-leaderboard, nfl-draft) set
|
||||
``self.global_config`` to their own ``config['global']`` sub-dict; a
|
||||
property without a setter would raise AttributeError and stop those
|
||||
plugins loading.
|
||||
|
||||
Example:
|
||||
fps = self.global_config.get('target_fps')
|
||||
"""
|
||||
override = getattr(self, '_global_config_override', None)
|
||||
if override is not None:
|
||||
return override
|
||||
for owner in (self.plugin_manager, self.cache_manager):
|
||||
config_manager = getattr(owner, 'config_manager', None)
|
||||
if config_manager is None:
|
||||
continue
|
||||
try:
|
||||
config = config_manager.get_config()
|
||||
except Exception:
|
||||
# A broken or unreadable config must never stop a plugin from
|
||||
# loading; fall through to the next source, then to {}.
|
||||
self.logger.debug(
|
||||
"Could not read global config from %s",
|
||||
type(owner).__name__, exc_info=True,
|
||||
)
|
||||
continue
|
||||
# Only a real mapping is usable: callers do .get() on this and feed
|
||||
# the result to numeric code, so handing back whatever a stub or a
|
||||
# half-built manager returned would fail later and further away.
|
||||
#
|
||||
# An empty dict is treated as "nothing here yet" rather than a
|
||||
# valid answer, so resolution continues to the next source. Both
|
||||
# managers default to the same config/config.json, so falling
|
||||
# through cannot pick up a different file's settings -- but it does
|
||||
# rescue the case where the first manager simply hasn't loaded yet,
|
||||
# which would otherwise return {} and silently disable every
|
||||
# setting read through this property.
|
||||
if isinstance(config, dict) and config:
|
||||
return config
|
||||
return {}
|
||||
|
||||
@global_config.setter
|
||||
def global_config(self, value: Dict[str, Any]) -> None:
|
||||
"""Let a plugin substitute its own view (see the getter's docstring)."""
|
||||
self._global_config_override = value
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Adaptive layout support (opt-in)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -307,16 +307,6 @@ class VegasModeCoordinator:
|
||||
self._apply_pending_config()
|
||||
|
||||
if self.vegas_config.continuous_scroll:
|
||||
# Drop cached content for plugins whose data just changed, so the
|
||||
# next time each comes round it is composed from current data. The
|
||||
# swap path's hot_swap_content() does this via process_updates(),
|
||||
# but it also rebuilds and repositions the whole strip, which is
|
||||
# the freeze-and-jump this mode exists to avoid. Without this the
|
||||
# pending-update flags are never consumed and a segment keeps
|
||||
# rendering whatever it was first built from — last night's live
|
||||
# game still shown as live the next morning.
|
||||
self.render_pipeline.refresh_updated_plugins()
|
||||
|
||||
# Extend the strip before the scroll can reach its end, so the next
|
||||
# group arrives from the right and motion never stops. No cycle
|
||||
# boundary, so no freeze, no substitution and no restart with the
|
||||
|
||||
@@ -1135,53 +1135,6 @@ class PluginAdapter:
|
||||
else:
|
||||
self._content_cache.clear()
|
||||
|
||||
def invalidate_plugin_scroll_cache(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
Drop a plugin's own cached scroll image so its visual is rebuilt.
|
||||
|
||||
Invalidating only this adapter's cache is not enough. A plugin that
|
||||
composes a scroll strip hands back the *same* image every time until its
|
||||
own cache is cleared — the sports plugins' ``get_vegas_content()``
|
||||
regenerates only "if the cache is empty" — so without this a segment
|
||||
keeps rendering whatever data it was first built from. That is how a
|
||||
game that was live last night can still be displayed as live the next
|
||||
morning.
|
||||
|
||||
Two layouts to cover: a helper directly on the plugin (stocks, news,
|
||||
odds-ticker) and one owned by a scroll-display manager (the sports
|
||||
scoreboards). ``cached_image`` and ``cached_array`` must be cleared
|
||||
together, since the array is the image's numpy mirror and code paths
|
||||
read whichever is convenient.
|
||||
|
||||
Returns:
|
||||
True if a cache was found and cleared.
|
||||
"""
|
||||
cleared = False
|
||||
for owner in (plugin, getattr(plugin, '_scroll_manager', None),
|
||||
getattr(plugin, 'scroll_manager', None)):
|
||||
if owner is None:
|
||||
continue
|
||||
helper = getattr(owner, 'scroll_helper', None)
|
||||
if helper is None:
|
||||
continue
|
||||
try:
|
||||
if getattr(helper, 'cached_image', None) is not None:
|
||||
helper.cached_image = None
|
||||
cleared = True
|
||||
if getattr(helper, 'cached_array', None) is not None:
|
||||
helper.cached_array = None
|
||||
cleared = True
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"[%s] Could not clear scroll cache on %s",
|
||||
plugin_id, type(owner).__name__
|
||||
)
|
||||
if cleared:
|
||||
logger.debug("[%s] Cleared plugin scroll cache", plugin_id)
|
||||
return cleared
|
||||
|
||||
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
||||
"""
|
||||
Get the type of content a plugin provides.
|
||||
|
||||
@@ -629,25 +629,6 @@ class RenderPipeline:
|
||||
|
||||
return False
|
||||
|
||||
def refresh_updated_plugins(self) -> bool:
|
||||
"""
|
||||
Let changed plugin data reach the strip without interrupting motion.
|
||||
|
||||
Used instead of :meth:`hot_swap_content` when scrolling continuously.
|
||||
The swap rebuilds the whole image and repositions the scroll, which is
|
||||
visible as a freeze and a jump; the strip is extended here rather than
|
||||
replaced, so it is enough to drop the stale caches and let the plugin
|
||||
recompose when it next comes round.
|
||||
|
||||
Returns:
|
||||
True if any plugin's cached content was dropped.
|
||||
"""
|
||||
try:
|
||||
return bool(self.stream_manager.invalidate_pending_updates())
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to refresh updated plugins")
|
||||
return False
|
||||
|
||||
def hot_swap_content(self) -> bool:
|
||||
"""
|
||||
Hot-swap to new composed content.
|
||||
|
||||
@@ -201,47 +201,6 @@ class StreamManager:
|
||||
|
||||
logger.debug("Plugin %s marked for update", plugin_id)
|
||||
|
||||
def invalidate_pending_updates(self) -> List[str]:
|
||||
"""
|
||||
Drop cached content for plugins whose data changed, without refetching.
|
||||
|
||||
The continuous-scroll counterpart to :meth:`process_updates`. That method
|
||||
belongs to the swap path: it refetches immediately and merges into the
|
||||
active buffer, which continuous mode bypasses entirely, and doing that
|
||||
work on the render thread would hitch the scroll.
|
||||
|
||||
Here it is enough to clear the caches and let the plugin come round in
|
||||
the rotation, which recomposes it from current data a moment later. Left
|
||||
uncalled, ``_pending_updates`` simply accumulates and no visual ever
|
||||
refreshes — a game that was live last night keeps being drawn as live.
|
||||
|
||||
Returns:
|
||||
The plugin ids whose caches were dropped.
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
if not self._pending_updates:
|
||||
return []
|
||||
updated = list(self._pending_updates.keys())
|
||||
self._pending_updates.clear()
|
||||
|
||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
||||
for plugin_id in updated:
|
||||
try:
|
||||
self.plugin_adapter.invalidate_cache(plugin_id)
|
||||
plugin = plugins.get(plugin_id)
|
||||
if plugin is not None:
|
||||
self.plugin_adapter.invalidate_plugin_scroll_cache(
|
||||
plugin, plugin_id)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"[%s] Could not invalidate cached content", plugin_id)
|
||||
|
||||
logger.info(
|
||||
"Vegas: dropped cached content for %d updated plugin(s): %s",
|
||||
len(updated), ', '.join(updated)
|
||||
)
|
||||
return updated
|
||||
|
||||
def has_pending_updates(self) -> bool:
|
||||
"""Check if any plugins have pending updates awaiting processing."""
|
||||
with self._buffer_lock:
|
||||
|
||||
+1
-121
@@ -250,125 +250,5 @@ class TestBasePlugin:
|
||||
|
||||
config = {"enabled": True, "live_priority": True}
|
||||
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
|
||||
|
||||
|
||||
assert plugin.has_live_priority() is True
|
||||
|
||||
|
||||
class TestBasePluginGlobalConfig:
|
||||
"""global_config exposes device-wide settings that self.config cannot.
|
||||
|
||||
The sports scoreboards read `getattr(self, 'global_config', {})` to find
|
||||
the shared target_fps; before this property existed nothing ever set that
|
||||
attribute, so the lookup silently returned {} and the setting could never
|
||||
take effect on any core.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _plugin(display_manager, cache_manager, plugin_manager=None):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
class ConcretePlugin(BasePlugin):
|
||||
def update(self): pass
|
||||
def display(self, force_clear=False): pass
|
||||
|
||||
return ConcretePlugin(
|
||||
"test", {"enabled": True}, display_manager, cache_manager, plugin_manager
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _manager_with(config):
|
||||
"""A stand-in manager exposing config_manager.get_config()."""
|
||||
manager = MagicMock()
|
||||
manager.config_manager.get_config.return_value = config
|
||||
return manager
|
||||
|
||||
def test_reads_config_from_plugin_manager(self, mock_display_manager, mock_cache_manager):
|
||||
plugin = self._plugin(
|
||||
mock_display_manager, mock_cache_manager,
|
||||
self._manager_with({"target_fps": 100}),
|
||||
)
|
||||
assert plugin.global_config["target_fps"] == 100
|
||||
|
||||
def test_falls_back_to_cache_manager(self, mock_display_manager):
|
||||
# The core that hangs config_manager off the cache manager instead.
|
||||
cache_manager = self._manager_with({"target_fps": 75})
|
||||
plugin = self._plugin(mock_display_manager, cache_manager, plugin_manager=None)
|
||||
assert plugin.global_config["target_fps"] == 75
|
||||
|
||||
def test_plugin_manager_wins_over_cache_manager(self, mock_display_manager):
|
||||
plugin = self._plugin(
|
||||
mock_display_manager,
|
||||
self._manager_with({"target_fps": 75}),
|
||||
self._manager_with({"target_fps": 100}),
|
||||
)
|
||||
assert plugin.global_config["target_fps"] == 100
|
||||
|
||||
def test_empty_plugin_manager_config_falls_through(self, mock_display_manager):
|
||||
"""An empty first source means "not loaded yet", not "the answer".
|
||||
|
||||
Both managers default to the same config/config.json, so falling
|
||||
through cannot pick up a different file. Returning {} here instead
|
||||
would silently disable every setting read through this property --
|
||||
the exact failure this property exists to fix.
|
||||
"""
|
||||
plugin = self._plugin(
|
||||
mock_display_manager,
|
||||
self._manager_with({"target_fps": 100}), # cache_manager
|
||||
self._manager_with({}), # plugin_manager: empty
|
||||
)
|
||||
assert plugin.global_config["target_fps"] == 100
|
||||
|
||||
def test_returns_empty_dict_when_no_config_manager(self, mock_display_manager):
|
||||
# Plain objects: no config_manager attribute at all.
|
||||
plugin = self._plugin(mock_display_manager, object(), object())
|
||||
assert plugin.global_config == {}
|
||||
|
||||
def test_unreadable_config_does_not_raise(self, mock_display_manager):
|
||||
# A plugin must still load when the config on disk is broken.
|
||||
broken = MagicMock()
|
||||
broken.config_manager.get_config.side_effect = OSError("unreadable")
|
||||
plugin = self._plugin(mock_display_manager, broken, broken)
|
||||
assert plugin.global_config == {}
|
||||
|
||||
def test_non_dict_config_is_rejected(self, mock_display_manager):
|
||||
# A stub or half-built manager can return a non-mapping; handing that
|
||||
# back would blow up later in numeric code, far from the cause.
|
||||
plugin = self._plugin(
|
||||
mock_display_manager, object(), self._manager_with("not-a-dict")
|
||||
)
|
||||
assert plugin.global_config == {}
|
||||
|
||||
def test_missing_property_degrades_to_default(self, mock_display_manager, mock_cache_manager):
|
||||
# How plugins actually call it, so a plugin written against this core
|
||||
# still loads on one that predates the property.
|
||||
plugin = self._plugin(mock_display_manager, mock_cache_manager, object())
|
||||
assert getattr(plugin, "global_config", {}).get("target_fps") is None
|
||||
|
||||
def test_plugin_may_still_assign_global_config(self, mock_display_manager, mock_cache_manager):
|
||||
# news, stock-news, ledmatrix-stocks, ledmatrix-elections,
|
||||
# ledmatrix-leaderboard and nfl-draft all do exactly this. Without a
|
||||
# setter the property raises "has no setter" and those plugins stop
|
||||
# loading entirely.
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
class AssigningPlugin(BasePlugin):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.global_config = self.config.get("global", {})
|
||||
|
||||
def update(self): pass
|
||||
def display(self, force_clear=False): pass
|
||||
|
||||
plugin = AssigningPlugin(
|
||||
"news", {"enabled": True, "global": {"scroll_speed": 2}},
|
||||
mock_display_manager, mock_cache_manager, self._manager_with({"target_fps": 100}),
|
||||
)
|
||||
# The plugin's own value wins over the resolved config.
|
||||
assert plugin.global_config == {"scroll_speed": 2}
|
||||
|
||||
def test_template_ships_a_global_target_fps(self):
|
||||
# The plumbing is useless if the setting isn't in the shipped config.
|
||||
import json
|
||||
with open("config/config.template.json") as fh:
|
||||
template = json.load(fh)
|
||||
assert template.get("target_fps") == 100
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
"""
|
||||
Regression tests: changed plugin data must reach the strip in continuous mode.
|
||||
|
||||
Two faults combined to freeze Vegas content indefinitely.
|
||||
|
||||
PR #291 added a call to ``plugin_adapter.invalidate_plugin_scroll_cache()`` so a
|
||||
plugin's *own* cached scroll image would be rebuilt from fresh data. The method
|
||||
was never implemented, and ``hot_swap_content()`` wraps the call in a broad
|
||||
except, so every hot swap raised AttributeError and was silently swallowed.
|
||||
|
||||
Continuous scrolling then removed the only path that reached it at all:
|
||||
``should_recompose()``/``hot_swap_content()`` are called from the non-continuous
|
||||
branch, while ``continuous_scroll`` defaults to True.
|
||||
|
||||
Together, a plugin composed its scroll image once and handed back the same
|
||||
picture forever, because the sports plugins' ``get_vegas_content()`` regenerates
|
||||
only when its cache is empty. Symptom: a game that was live last night is still
|
||||
drawn as live the following morning.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
|
||||
class FakeDisplayManager:
|
||||
width = 64
|
||||
height = 32
|
||||
|
||||
|
||||
def _helper():
|
||||
"""A stand-in ScrollHelper holding both halves of its cache."""
|
||||
image = Image.new('RGB', (128, 32), (10, 20, 30))
|
||||
return SimpleNamespace(cached_image=image, cached_array=np.array(image))
|
||||
|
||||
|
||||
class TestInvalidatePluginScrollCache:
|
||||
"""The method PR #291 called but never defined."""
|
||||
|
||||
def test_method_exists(self):
|
||||
# It was called for months without existing; the broad except in
|
||||
# hot_swap_content() meant nothing ever surfaced.
|
||||
assert hasattr(PluginAdapter, 'invalidate_plugin_scroll_cache')
|
||||
|
||||
def test_clears_helper_attached_to_the_plugin(self):
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
helper = _helper()
|
||||
plugin = SimpleNamespace(scroll_helper=helper)
|
||||
|
||||
assert adapter.invalidate_plugin_scroll_cache(plugin, 'stocks') is True
|
||||
assert helper.cached_image is None
|
||||
assert helper.cached_array is None
|
||||
|
||||
def test_clears_helper_owned_by_a_scroll_manager(self):
|
||||
# The sports scoreboards keep theirs on _scroll_manager, which is the
|
||||
# layout that produced the reported stale-scores bug.
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
helper = _helper()
|
||||
plugin = SimpleNamespace(_scroll_manager=SimpleNamespace(scroll_helper=helper))
|
||||
|
||||
assert adapter.invalidate_plugin_scroll_cache(plugin, 'baseball') is True
|
||||
assert helper.cached_image is None
|
||||
assert helper.cached_array is None
|
||||
|
||||
def test_clears_both_halves_together(self):
|
||||
# cached_array is the image's numpy mirror; leaving one behind lets a
|
||||
# reader pick up content the other no longer has.
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
helper = _helper()
|
||||
adapter.invalidate_plugin_scroll_cache(
|
||||
SimpleNamespace(scroll_helper=helper), 'news')
|
||||
assert (helper.cached_image, helper.cached_array) == (None, None)
|
||||
|
||||
def test_plugin_without_a_helper_is_not_an_error(self):
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
assert adapter.invalidate_plugin_scroll_cache(SimpleNamespace(), 'clock') is False
|
||||
|
||||
|
||||
class TestInvalidatePendingUpdates:
|
||||
def _manager(self, plugins):
|
||||
stream = StreamManager(
|
||||
VegasModeConfig(),
|
||||
SimpleNamespace(plugins=plugins),
|
||||
MagicMock(),
|
||||
)
|
||||
stream.plugin_adapter = MagicMock()
|
||||
return stream
|
||||
|
||||
def test_drops_caches_for_updated_plugins(self):
|
||||
helper = _helper()
|
||||
plugin = SimpleNamespace(scroll_helper=helper)
|
||||
stream = self._manager({'baseball': plugin})
|
||||
stream.mark_plugin_updated('baseball')
|
||||
|
||||
assert stream.invalidate_pending_updates() == ['baseball']
|
||||
stream.plugin_adapter.invalidate_cache.assert_called_once_with('baseball')
|
||||
stream.plugin_adapter.invalidate_plugin_scroll_cache.assert_called_once_with(
|
||||
plugin, 'baseball')
|
||||
|
||||
def test_pending_flags_are_consumed(self):
|
||||
# Left unconsumed they accumulate forever and nothing ever refreshes.
|
||||
stream = self._manager({'baseball': SimpleNamespace()})
|
||||
stream.mark_plugin_updated('baseball')
|
||||
assert stream.has_pending_updates() is True
|
||||
|
||||
stream.invalidate_pending_updates()
|
||||
assert stream.has_pending_updates() is False
|
||||
assert stream.invalidate_pending_updates() == []
|
||||
|
||||
def test_no_pending_updates_does_no_work(self):
|
||||
stream = self._manager({})
|
||||
assert stream.invalidate_pending_updates() == []
|
||||
stream.plugin_adapter.invalidate_cache.assert_not_called()
|
||||
|
||||
def test_a_failing_plugin_does_not_stop_the_others(self):
|
||||
stream = self._manager({'a': SimpleNamespace(), 'b': SimpleNamespace()})
|
||||
stream.mark_plugin_updated('a')
|
||||
stream.mark_plugin_updated('b')
|
||||
stream.plugin_adapter.invalidate_cache.side_effect = [
|
||||
RuntimeError('boom'), None]
|
||||
|
||||
assert sorted(stream.invalidate_pending_updates()) == ['a', 'b']
|
||||
assert stream.plugin_adapter.invalidate_cache.call_count == 2
|
||||
|
||||
|
||||
class TestContinuousModeReachesTheRefresh:
|
||||
def _pipeline(self):
|
||||
stream = MagicMock()
|
||||
stream.get_buffer_status.return_value = {'staging_count': 0}
|
||||
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
|
||||
|
||||
def test_refresh_delegates_to_the_stream_manager(self):
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.return_value = ['baseball']
|
||||
assert pipeline.refresh_updated_plugins() is True
|
||||
|
||||
def test_refresh_reports_false_when_nothing_changed(self):
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.return_value = []
|
||||
assert pipeline.refresh_updated_plugins() is False
|
||||
|
||||
def test_refresh_never_raises_into_the_render_loop(self):
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.side_effect = RuntimeError('boom')
|
||||
assert pipeline.refresh_updated_plugins() is False
|
||||
|
||||
def test_refresh_does_not_reposition_the_scroll(self):
|
||||
# The whole point of preferring this over hot_swap_content(): that path
|
||||
# rebuilds and repositions, which reads as a freeze then a jump.
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.return_value = ['baseball']
|
||||
pipeline.scroll_helper.scroll_position = 1234
|
||||
|
||||
pipeline.refresh_updated_plugins()
|
||||
|
||||
assert pipeline.scroll_helper.scroll_position == 1234
|
||||
stream.swap_buffers.assert_not_called()
|
||||
stream.process_updates.assert_not_called()
|
||||
|
||||
|
||||
class TestCoordinatorWiring:
|
||||
"""
|
||||
The regression itself: continuous mode has to *call* the refresh.
|
||||
|
||||
should_recompose()/hot_swap_content() sit in the non-continuous branch, and
|
||||
continuous_scroll defaults to True, so before this fix the refresh was
|
||||
simply never reached on a default install.
|
||||
"""
|
||||
|
||||
def _coordinator(self, continuous):
|
||||
import threading
|
||||
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
config = VegasModeConfig()
|
||||
config.continuous_scroll = continuous
|
||||
# Built without __init__ so the test exercises run_frame's branching
|
||||
# without standing up a display, stream and render stack.
|
||||
coordinator = VegasModeCoordinator.__new__(VegasModeCoordinator)
|
||||
coordinator.vegas_config = config
|
||||
coordinator.render_pipeline = MagicMock()
|
||||
coordinator.render_pipeline.has_deferred.return_value = False
|
||||
coordinator.render_pipeline.needs_extension.return_value = False
|
||||
coordinator.render_pipeline.is_cycle_complete.return_value = False
|
||||
coordinator.render_pipeline.should_recompose.return_value = False
|
||||
coordinator.stream_manager = MagicMock()
|
||||
coordinator.stats = {'cycles_completed': 0}
|
||||
coordinator._state_lock = threading.Lock()
|
||||
coordinator._is_active = True
|
||||
coordinator._is_paused = False
|
||||
coordinator._should_stop = False
|
||||
coordinator._pending_config_update = False
|
||||
coordinator._live_priority_check = None
|
||||
coordinator._interrupt_check = None
|
||||
coordinator.sync_manager = None
|
||||
return coordinator
|
||||
|
||||
def test_continuous_mode_refreshes_updated_plugins_every_frame(self):
|
||||
coordinator = self._coordinator(continuous=True)
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.refresh_updated_plugins.assert_called_once()
|
||||
|
||||
def test_continuous_mode_does_not_use_the_disruptive_swap(self):
|
||||
coordinator = self._coordinator(continuous=True)
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.hot_swap_content.assert_not_called()
|
||||
|
||||
def test_swap_mode_still_uses_hot_swap(self):
|
||||
# The non-continuous path must keep its original behaviour.
|
||||
coordinator = self._coordinator(continuous=False)
|
||||
coordinator.render_pipeline.should_recompose.return_value = True
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.hot_swap_content.assert_called_once()
|
||||
coordinator.render_pipeline.refresh_updated_plugins.assert_not_called()
|
||||
|
||||
def test_a_frame_is_still_rendered_either_way(self):
|
||||
for continuous in (True, False):
|
||||
coordinator = self._coordinator(continuous=continuous)
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.render_frame.assert_called_once()
|
||||
@@ -1057,20 +1057,16 @@ class TestCutsNeverSplitWords:
|
||||
|
||||
def test_no_partial_letter_at_either_edge(self):
|
||||
# A split letter shows as a lit column touching the crop edge with the
|
||||
# rest of its glyph missing. The crop always starts at 0 here (fresh
|
||||
# adapter, no prior rotation offset), and word_strip's first word
|
||||
# begins at column 0 with no lead-in gap, so the left edge is the
|
||||
# true start of the content rather than a cut and legitimately
|
||||
# carries ink. Only the right edge is where the width budget actually
|
||||
# cropped, so that is the one that must land in a gap.
|
||||
# rest of its glyph missing. Requiring the edges to be blank is the
|
||||
# simplest way to assert we cut inside a gap.
|
||||
from src.vegas_mode.geometry import column_has_ink
|
||||
img, _ = word_strip([9, 9, 9, 9, 9, 9])
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.25,
|
||||
min_cut_gap=6)
|
||||
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
|
||||
ink = column_has_ink(out)
|
||||
assert not ink[-1] or out.width == img.width, \
|
||||
"crop's right edge lands on ink, so a glyph was cut through"
|
||||
assert not ink[0] or not ink[-1] or out.width == img.width, \
|
||||
"crop edges land on ink, so a glyph was cut through"
|
||||
|
||||
def test_rotation_never_orphans_a_fragment(self):
|
||||
# Walk the window across the whole strip and assert no slice is a
|
||||
@@ -1355,9 +1351,7 @@ class TestContinuousExtension:
|
||||
assert p.extend_scroll_content()
|
||||
# One offscreen prefetch, then one more kicked off for the group after.
|
||||
assert stream.calls[0] is True
|
||||
# The extend must consume the prepared group rather than fetching inline,
|
||||
# so no offscreen_only=False call may appear.
|
||||
assert False not in stream.calls
|
||||
assert p._prepared_group is None or isinstance(p._prepared_group, list)
|
||||
|
||||
def test_strip_stays_bounded_over_many_extensions(self):
|
||||
groups = [[('g%d' % i, [self._block(600)])] for i in range(30)]
|
||||
|
||||
@@ -167,78 +167,6 @@ class TestConfigAPI:
|
||||
'enabled': True, 'copies': 2, 'axis': 'vertical',
|
||||
}
|
||||
|
||||
def test_save_target_fps(self, client, mock_config_manager):
|
||||
"""The device-wide scroll frame rate persists as a top-level int."""
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data={'target_fps': '90'},
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||
# Must be the coerced int, not the raw form string -- the generic
|
||||
# remaining-keys loop would otherwise write '90' back over it.
|
||||
assert saved['target_fps'] == 90
|
||||
|
||||
def test_save_target_fps_alone_does_not_reset_other_general_settings(
|
||||
self, client, mock_config_manager):
|
||||
"""A target_fps-only POST must not be treated as a full General-tab save.
|
||||
|
||||
The general branch reads web_display_autostart as an unchecked-checkbox
|
||||
(absent means False), so counting target_fps as a general update would
|
||||
silently switch autostart off for anyone setting only the frame rate.
|
||||
"""
|
||||
mock_config_manager.load_config.return_value['web_display_autostart'] = True
|
||||
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data={'target_fps': '90'},
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||
assert saved['web_display_autostart'] is True
|
||||
|
||||
@pytest.mark.parametrize('value', [90.5, 90.0, True])
|
||||
def test_save_target_fps_rejects_non_integer_json(self, client, mock_config_manager, value):
|
||||
"""int() would truncate silently: 90.5 -> 90, True -> 1.
|
||||
|
||||
Only JSON can carry these; a form post sends '90.5', which int()
|
||||
already rejects.
|
||||
"""
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data=json.dumps({'target_fps': value}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize('value', ['20', '250', 'fast'])
|
||||
def test_save_target_fps_rejects_out_of_range(self, client, mock_config_manager, value):
|
||||
"""Values ScrollHelper would silently clamp are reported instead."""
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data={'target_fps': value},
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_save_target_fps_accepts_bounds(self, client, mock_config_manager):
|
||||
"""Both endpoints of the documented range are valid."""
|
||||
for value in ('30', '200'):
|
||||
response = client.post(
|
||||
'/api/v3/config/main',
|
||||
data={'target_fps': value},
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
)
|
||||
assert response.status_code == 200, f"{value} should be accepted"
|
||||
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||
assert saved['target_fps'] == int(value)
|
||||
|
||||
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
|
||||
"""An omitted 'enabled' checkbox is saved as disabled, not left stale."""
|
||||
response = client.post(
|
||||
|
||||
@@ -747,36 +747,6 @@ def save_main_config():
|
||||
if 'timezone' in data:
|
||||
current_config['timezone'] = data['timezone']
|
||||
|
||||
# Device-wide scroll frame rate, read by plugins via
|
||||
# BasePlugin.global_config. Bounds match ScrollHelper.set_target_fps,
|
||||
# which clamps silently -- rejecting here instead means a value that
|
||||
# would have been quietly altered is reported rather than appearing to
|
||||
# save and then behaving differently.
|
||||
if 'target_fps' in data and data['target_fps'] not in ('', None):
|
||||
raw_target_fps = data['target_fps']
|
||||
# A JSON body can carry real floats and bools, where int() would
|
||||
# silently truncate: 90.5 would save as 90, and true as 1. Reject
|
||||
# them rather than storing a value the user did not ask for. Form
|
||||
# posts arrive as strings, so '90.5' still fails in int() below.
|
||||
if isinstance(raw_target_fps, (bool, float)):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for target_fps: must be an integer"
|
||||
}), 400
|
||||
try:
|
||||
target_fps = int(raw_target_fps)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for target_fps: must be an integer"
|
||||
}), 400
|
||||
if not (30 <= target_fps <= 200):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for target_fps: must be between 30 and 200"
|
||||
}), 400
|
||||
current_config['target_fps'] = target_fps
|
||||
|
||||
# Handle location settings
|
||||
if 'city' in data or 'state' in data or 'country' in data:
|
||||
if 'location' not in current_config:
|
||||
@@ -1312,7 +1282,7 @@ def save_main_config():
|
||||
if key in ['timezone', 'city', 'state', 'country',
|
||||
'web_display_autostart', 'auto_discover',
|
||||
'auto_load_enabled', 'development_mode',
|
||||
'plugins_directory', 'target_fps']:
|
||||
'plugins_directory']:
|
||||
continue
|
||||
# Skip fields that are already handled above in their own named sections.
|
||||
# Without this, every form field name lands as a top-level config key too.
|
||||
|
||||
@@ -49,18 +49,6 @@
|
||||
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
|
||||
<div id="timezone_container" class="mt-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll frame rate (device-wide) -->
|
||||
<div class="form-group" id="setting-general-target-fps" data-setting-key="target_fps">
|
||||
<label for="target_fps" class="block text-sm font-medium text-gray-700">Scroll Frame Rate{{ ui.help_tip('Frames per second for scrolling content, applied across plugins that scroll.\nHigher is smoother but uses more CPU; lower frees CPU but looks steppier.\nRange 30-200. Default: 100.', 'Scroll Frame Rate') }}</label>
|
||||
<input type="number"
|
||||
id="target_fps"
|
||||
name="target_fps"
|
||||
value="{{ main_config.target_fps or 100 }}"
|
||||
min="30"
|
||||
max="200"
|
||||
class="form-control">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
// Track if already initialized to prevent re-render
|
||||
|
||||
Reference in New Issue
Block a user