fix(display): Vegas teardown and 240s default; remove dead Vegas buffer code (#637)

* fix(display): tear down Vegas mode on controller cleanup

DisplayController.cleanup() never called VegasModeCoordinator.cleanup(),
so the Vegas teardown (stop, pipeline/stream reset, adapter cache drop)
was unreachable. Call it before the display manager is cleaned up, and
skip it when Vegas was never created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(vegas): default max_cycle_duration to the documented 240s

The template, the web UI help, CONFIG_REFERENCE and the controller all
say 240, but the code defaulted to 600 in two places, so a config
without the key ran Vegas iterations 2.5x longer than documented.

from_config now falls back to the dataclass field defaults instead of
repeating each one, so the two copies can no longer drift, and the
controller's follower scroll-speed default reads VegasModeConfig's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): let run.py -d show display_manager's DEBUG output

display_manager pinned its logger to INFO at import, overriding the root
level, so debug mode never showed its DEBUG lines. Use get_logger() from
src.logging_config like the rest of the core and leave the level to the
logging setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): run each startup validation check once

StartupValidator.validate_all() ran twice at boot, before and after the
plugin manager was created, so every config, cache, display and
systemd-unit warning was logged twice. The second pass now runs only the
plugin checks. Drop the commented-out raise_on_errors line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(vegas): one INFO line per plugin-list refresh

StreamManager logged "=" * 60 banners and a line per plugin (INCLUDED,
SKIPPED, FETCHING CONTENT, SEGMENT CREATED) at INFO on every refresh and
fetch, i.e. at each cycle start and every 30s. Log one INFO summary of
the rotation per refresh and move the per-plugin detail, the weighting
breakdown and "no content this cycle" to DEBUG (the adapter still warns
when every content path fails).

Also drop the check/cross marks from the controller's log messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(vegas): drop the per-iteration static-mode plugin scan

run_iteration() rebuilt _static_mode_plugins on every iteration, asking
every plugin for its display mode and logging the set at INFO, but
nothing ever read it: static pauses are triggered by
_check_static_plugin_trigger() from the next segment. Delete it, the
coordinator's get_ordered_plugins() that only it used, and the
write-only _static_pause_plugin / _static_pause_start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(vegas): remove the staging buffer that was never filled

StreamManager and RenderPipeline carried a double-buffer design that
nothing used: _staging_buffer was only ever cleared or swapped, so
swap_buffers() never did anything and should_recompose()'s
staging_count > 0 branch was dead, and _active_scroll_image,
_staging_scroll_image, _is_rendering, _last_frame_time and
_frame_interval were written but never read. Delete the machinery and
rewrite the docstrings around what actually carries updates:
_pending_updates, consumed by process_updates() in swap mode and
invalidate_pending_updates() in continuous mode.

should_recompose() no longer builds a buffer-status dict every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): tidy the display controller without changing behaviour

- Import VegasModeCoordinator locally instead of through module globals
  (there is no circular import to avoid).
- Drop hasattr() checks on attributes PluginManager.__init__ always sets
  (plugin_executor, plugin_last_update, get_plugin_lock,
  run_scheduled_updates*, stop_update_worker) and the dead "older
  manager" fallbacks; keep the health_tracker None checks, now via
  _health_tracker().
- Extract _display_once() for the per-frame display call both render
  loops copied, _advance_on_demand() for the two on-demand rotations,
  _reset_on_demand_fields() for the error and clear paths, and
  _timezone() / _in_window() for the two schedule checks.
- Remove always-true conditions and the unreachable non-plugin else
  branch in run(), and read _was_display_active / _last_published_mode /
  vegas_coordinator directly now that __init__ declares them.
- Declare the follower render state in __init__, name its tuning
  constants, add _follower_sign(), and share the 90/s sync send
  interval with the render pipeline (SYNC_SEND_INTERVAL).
- Delete history narration and the "Opt #N" labels, fix the comment
  that called _scroll_speed constant (hot reload updates it), and drop
  a startup timing log that measured nothing.
- render_pipeline / plugin_adapter: read display_manager.width/height
  as the properties they are, drop an empty TYPE_CHECKING block, an
  aliased threading import and a duplicated `if result and
  self.sync_manager:`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): trim dead code from display_manager

- Add _new_canvas() for the image/draw/fontmode="1" setup that was
  copied six times.
- Call resolve_double_sided() and compose_pixel_mapper_config() directly
  instead of through a module alias and a passthrough method, and replace
  the comment that said the passthrough read class attributes.
- Delete the unused _initialized flag and _ORIENTATION_ROTATE_DEGREES
  alias (no core or monorepo reader; tests stop resetting the flag), the
  test pattern's unreachable no-matrix branch (it only runs once the
  matrix exists), `del old_image  # help GC` (a no-op on a local), a
  duplicated early return in process_deferred_updates, and stale
  comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(vegas): remove unread fields and test-only helpers, fix docstrings

- ContentSegment: drop total_width, fetched_at, is_stale, image_count and
  is_static, none of which is read.
- StreamManager: drop _current_index (never advanced) and the test-only
  get_all_content_for_composition() and has_pending_updates();
  VegasModeConfig: drop the test-only is_plugin_included().
- geometry.find_blank_cut() has had no production caller since the crop
  moved to item boundaries; delete it and its tests.
- PluginAdapter: the _finalize docstring described separator_width
  between every image, and _crop_to_budget's said cuts snap to the
  nearest blank column; both now describe what the code does.
- Coordinator: the static-pause interrupt log no longer blames follower
  mode for every interrupt, and set_update_callback names the callback
  the controller actually wires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(scroll): correct ScrollHelper comments and drop dead branches

- Four comments said the strip always starts with display_width of
  blank; it does only when lead_gap is None (Vegas passes its own).
- Delete the "Width calculation mismatch" warning: the image is created
  at the calculated width, so the two can never differ.
- Remove the two scroll_delay <= 0 fallbacks (which disagreed with each
  other): set_scroll_delay clamps it to at least 0.001 and nothing in
  core or the plugin monorepo assigns it directly.
- Trim the scipy history from the blend docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(run): drop a redundant comment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): log set_scrolling_state only when it changes

Vegas and scrolling plugins set the scrolling state every frame, so once
display_manager's DEBUG output became visible in debug mode it printed
"Scrolling state set to: True" about 120 times a second. Log only when
the value differs from the previous one; the state, activity timestamp
and frame hold still update on every call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): display-vegas

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 17:37:15 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f6afbdbb15
commit b9416ef803
26 changed files with 1182 additions and 1277 deletions
-1
View File
@@ -105,7 +105,6 @@ def _reset_display_manager_globals():
# An instance left here is what the next module's DisplayManager() call
# gets back -- potentially one built against a MagicMock matrix.
dm_mod.DisplayManager._instance = None
dm_mod.DisplayManager._initialized = False
# Put a binding back if a patch outlived the module that started it.
# Restoring rather than failing: a leak reported against an innocent module
+59
View File
@@ -0,0 +1,59 @@
"""DisplayController.cleanup() must tear down Vegas mode.
VegasModeCoordinator.cleanup() stops the scroll, resets the render pipeline and
stream manager and drops the adapter's content cache, but the controller never
called it, so none of that teardown ran at shutdown.
"""
import os
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController
def _controller(vegas_coordinator):
dc = object.__new__(DisplayController)
dc.plugin_manager = MagicMock()
dc.config_service = MagicMock()
dc._font_usage_publisher = None
dc.display_manager = MagicMock()
dc.vegas_coordinator = vegas_coordinator
return dc
def test_cleanup_tears_down_vegas():
vegas = MagicMock()
_controller(vegas).cleanup()
vegas.cleanup.assert_called_once_with()
def test_vegas_is_torn_down_before_the_display_manager():
# Stopping Vegas resets the display's scrolling state, so the display
# manager has to still be there when it runs.
order = []
vegas = MagicMock()
vegas.cleanup.side_effect = lambda: order.append('vegas')
dc = _controller(vegas)
dc.display_manager.cleanup.side_effect = lambda: order.append('display')
dc.cleanup()
assert order == ['vegas', 'display']
def test_cleanup_without_vegas_does_not_raise():
dc = _controller(None)
dc.cleanup()
dc.display_manager.cleanup.assert_called_once_with()
def test_a_failing_vegas_teardown_does_not_stop_the_rest():
vegas = MagicMock()
vegas.cleanup.side_effect = RuntimeError('boom')
dc = _controller(vegas)
dc.cleanup()
dc.display_manager.cleanup.assert_called_once_with()
@@ -0,0 +1,77 @@
"""Startup validation runs each check once.
DisplayController ran StartupValidator.validate_all() twice -- once before the
plugin manager existed and again after -- so every config, cache, display and
systemd-unit warning was logged twice at every boot. The second pass only
needs the plugin checks.
"""
import logging
from unittest.mock import MagicMock, patch
class RecordingValidator:
"""Stands in for StartupValidator and records which checks ran."""
calls = []
def __init__(self, config_manager, plugin_manager=None, cache_manager=None):
self.plugin_manager = plugin_manager
self.errors = []
self.warnings = []
def validate_all(self):
RecordingValidator.calls.append(('validate_all', self.plugin_manager is not None))
self.warnings = ['config warning']
return True, [], list(self.warnings)
def _validate_plugins(self):
RecordingValidator.calls.append(('plugins',))
self.warnings = ['plugin warning']
def _build_controller(mock_config_manager, mock_display_manager,
mock_cache_manager, config):
from src.display_controller import DisplayController
mock_config_manager.get_config.return_value = config
mock_config_manager.load_config.return_value = config
mock_pm = MagicMock()
mock_pm.discover_plugins.return_value = []
mock_pm.plugins = {}
mock_pm.plugin_manifests = {}
mock_pm.plugin_last_update = {}
mock_pm.health_tracker = None
with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \
patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \
patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \
patch('src.display_controller.FontManager'), \
patch('src.plugin_system.PluginManager', return_value=mock_pm), \
patch('src.startup_validator.StartupValidator', RecordingValidator):
controller = DisplayController()
return controller
def test_each_check_runs_once(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins,
emulator_mode, caplog):
RecordingValidator.calls = []
with caplog.at_level(logging.WARNING, logger='src.display_controller'):
controller = _build_controller(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins)
try:
full_passes = [c for c in RecordingValidator.calls if c[0] == 'validate_all']
assert len(full_passes) == 1, RecordingValidator.calls
plugin_checks = (
sum(1 for c in RecordingValidator.calls if c[0] == 'plugins')
+ sum(1 for c in full_passes if c[1]))
assert plugin_checks == 1, RecordingValidator.calls
config_warnings = [r for r in caplog.records
if 'config warning' in r.getMessage()]
assert len(config_warnings) == 1
assert any('plugin warning' in r.getMessage() for r in caplog.records)
finally:
controller.cleanup()
+3 -9
View File
@@ -83,15 +83,9 @@ class TestVegasCoordinatorCallbackWiring:
fake_coordinator = MagicMock()
import src.display_controller as dc_module
original_imported = dc_module._vegas_mode_imported
original_class = dc_module.VegasModeCoordinator
try:
dc_module._vegas_mode_imported = True
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
from unittest.mock import patch
with patch('src.vegas_mode.VegasModeCoordinator',
MagicMock(return_value=fake_coordinator)):
dc._initialize_vegas_mode()
finally:
dc_module._vegas_mode_imported = original_imported
dc_module.VegasModeCoordinator = original_class
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)
-4
View File
@@ -28,7 +28,6 @@ def dm(tmp_path_factory):
"""One real DisplayManager on the emulator (it's a process singleton)."""
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
manager = DisplayManager({
"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
@@ -60,7 +59,6 @@ def dm(tmp_path_factory):
"test module.")
yield manager
DisplayManager._instance = None
DisplayManager._initialized = False
class _SwapSpy:
@@ -230,7 +228,6 @@ class TestKillSwitch:
def test_config_flag_wires_through(self):
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
try:
manager = DisplayManager({
"display": {
@@ -243,7 +240,6 @@ class TestKillSwitch:
assert manager._dirty_tracking_enabled is False
finally:
DisplayManager._instance = None
DisplayManager._initialized = False
if __name__ == "__main__":
+49
View File
@@ -0,0 +1,49 @@
"""display_manager's logger must follow the level run.py configures.
The module pinned its own logger to INFO at import, which overrides the root
level, so ``run.py -d`` never showed a single DEBUG line from the display
manager -- the dirty-tracking, fallback-mode and scrolling-state traces were
unreachable without editing the source.
"""
import logging
import os
os.environ.setdefault("EMULATOR", "true")
import src.display_manager as dm
def test_the_module_sets_no_level_of_its_own():
assert dm.logger.level == logging.NOTSET
def test_scrolling_state_logs_only_when_it_changes(caplog):
# Vegas and every scrolling plugin set the state on each frame, so a line
# per call was ~120 DEBUG lines a second once debug output was visible.
dm_obj = object.__new__(dm.DisplayManager)
dm_obj._frame_hold = 1
dm_obj._scrolling_state = {'is_scrolling': False, 'last_scroll_activity': 0}
with caplog.at_level(logging.DEBUG, logger='src.display_manager'):
for _ in range(5):
dm_obj.set_scrolling_state(True, frame_hold=2)
dm_obj.set_scrolling_state(False)
dm_obj.set_scrolling_state(False)
lines = [r.getMessage() for r in caplog.records
if r.getMessage().startswith('Scrolling state set to')]
assert lines == ['Scrolling state set to: True', 'Scrolling state set to: False']
# The state itself still updates on every call.
assert dm_obj._scrolling_state['is_scrolling'] is False
assert dm_obj._frame_hold == 1
def test_debug_output_appears_when_the_root_is_at_debug():
root = logging.getLogger()
previous = root.level
root.setLevel(logging.DEBUG)
try:
assert dm.logger.isEnabledFor(logging.DEBUG)
finally:
root.setLevel(previous)
-1
View File
@@ -154,7 +154,6 @@ def vegas_coordinator(controller):
coord._update_callback = None
coord._update_tick_running = False
coord.sync_manager = None
coord._update_static_mode_plugins = lambda: None
coord._check_static_plugin_trigger = lambda: None
coord.frames = 0
+24 -19
View File
@@ -2,7 +2,7 @@
Tests for src/vegas_mode/config.py
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
is_plugin_included, get_ordered_plugins, validate.
get_ordered_plugins, validate.
"""
import pytest
@@ -38,6 +38,29 @@ class TestVegasModeConfigDefaults:
cfg = VegasModeConfig()
assert len(cfg.excluded_plugins) == 0
def test_defaults_match_the_shipped_template(self):
# The template, the web UI help and CONFIG_REFERENCE all document
# these values. max_cycle_duration defaulted to 600 in code while all
# three said 240, so an install without the key ran iterations 2.5x
# longer than documented.
import json
from pathlib import Path
template = json.loads(
(Path(__file__).resolve().parent.parent / "config"
/ "config.template.json").read_text(encoding="utf-8"))
shipped = template["display"]["vegas_scroll"]
defaults = VegasModeConfig().to_dict()
mismatched = {k: (v, defaults[k]) for k, v in shipped.items()
if k in defaults and defaults[k] != v}
assert not mismatched, f"template vs code default: {mismatched}"
def test_missing_keys_read_the_field_defaults(self):
# from_config used to repeat every default; with no keys set it must
# produce exactly the dataclass defaults.
assert VegasModeConfig.from_config({}).to_dict() == VegasModeConfig().to_dict()
assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}).to_dict()
== VegasModeConfig().to_dict())
# ---------------------------------------------------------------------------
# from_config
@@ -147,24 +170,6 @@ class TestGetFrameInterval:
assert result == 1.0
# ---------------------------------------------------------------------------
# is_plugin_included
# ---------------------------------------------------------------------------
class TestIsPluginIncluded:
def test_not_excluded_is_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("good_plugin") is True
def test_excluded_plugin_not_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("bad_plugin") is False
def test_empty_exclusions_all_included(self):
cfg = VegasModeConfig()
assert cfg.is_plugin_included("anything") is True
# ---------------------------------------------------------------------------
# get_ordered_plugins
# ---------------------------------------------------------------------------
+2 -4
View File
@@ -108,10 +108,10 @@ class TestInvalidatePendingUpdates:
# 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
assert stream._pending_updates
stream.invalidate_pending_updates()
assert stream.has_pending_updates() is False
assert not stream._pending_updates
assert stream.invalidate_pending_updates() == []
def test_no_pending_updates_does_no_work(self):
@@ -133,7 +133,6 @@ class TestInvalidatePendingUpdates:
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):
@@ -161,7 +160,6 @@ class TestContinuousModeReachesTheRefresh:
pipeline.refresh_updated_plugins()
assert pipeline.scroll_helper.scroll_position == 1234
stream.swap_buffers.assert_not_called()
stream.process_updates.assert_not_called()
+54
View File
@@ -0,0 +1,54 @@
"""run_iteration() does no per-iteration static-mode bookkeeping.
Every iteration used to rebuild a set of STATIC-mode plugins -- asking each
plugin for its display mode and logging the result at INFO -- that nothing
ever read. The static pause is driven by _check_static_plugin_trigger(), which
looks at the next segment, not at that set.
"""
import logging
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
from src.plugin_system.base_plugin import VegasDisplayMode
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.coordinator import VegasModeCoordinator
def _coordinator(plugins):
coord = VegasModeCoordinator.__new__(VegasModeCoordinator)
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'enabled': True, 'max_cycle_duration': 0}}})
coord.render_pipeline = MagicMock()
coord.stream_manager = MagicMock()
coord.display_manager = MagicMock()
coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get)
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
coord._state_lock = threading.Lock()
coord._is_active = True
coord._is_paused = False
coord._should_stop = False
coord._fps_last_health_log = 0.0
coord._fps_was_degraded = False
coord._live_priority_check = None
coord._live_priority_active = False
coord._interrupt_check = None
coord._interrupt_check_interval = 10
coord._update_callback = None
coord._update_tick_running = False
coord._check_static_plugin_trigger = lambda: None
coord.run_frame = lambda: True
return coord
def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
static = MagicMock()
static.get_vegas_display_mode.return_value = VegasDisplayMode.STATIC
coord = _coordinator({'static-plugin': static})
with caplog.at_level(logging.INFO, logger='src.vegas_mode.coordinator'):
assert coord.run_iteration() is True
static.get_vegas_display_mode.assert_not_called()
assert not any('Static mode plugins' in r.getMessage() for r in caplog.records)
+3 -7
View File
@@ -416,7 +416,7 @@ class TestStreamGrouping:
from src.plugin_system.base_plugin import VegasDisplayMode
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
return ContentSegment(
plugin_id=plugin_id, images=imgs, total_width=10 * count,
plugin_id=plugin_id, images=imgs,
display_mode=mode or VegasDisplayMode.SCROLL)
def test_grouping_preserves_plugin_boundaries(self):
@@ -437,10 +437,6 @@ class TestStreamGrouping:
sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
def test_flat_accessor_still_matches_grouped_total(self):
sm = self._stream([self._seg('a', 3), self._seg('b', 2)])
assert len(sm.get_all_content_for_composition()) == 5
class TestApiBoundsMatchValidate:
"""
@@ -980,8 +976,8 @@ class TestRotationAcrossMultipleCycles:
"""
The single-image crop advances a window across cycles. The second and later
passes are where start + budget can land exactly on the image width, which
crashed find_blank_cut in the field and lost that plugin's content for the
cycle. First-pass-only tests never reach it.
once crashed the cut and lost that plugin's content for the cycle.
First-pass-only tests never reach it.
"""
def test_window_advances_over_many_cycles_without_error(self):
-49
View File
@@ -10,7 +10,6 @@ from src.vegas_mode.geometry import (
content_bounds,
dead_window_stats,
edge_blank,
find_blank_cut,
separation_gap,
trim_to_content,
window_coverage_stats,
@@ -320,51 +319,3 @@ class TestSeparationGap:
# flat gap; measured separation lifts them to the 24px target.
card = paint(make_img(150), 0, 150)
assert separation_gap(card, card, target=24, minimum=8) == 24
class TestFindBlankCut:
def test_snaps_to_the_nearest_gap(self):
img = paint(make_img(200), 0, 90)
paint(img, 110, 200)
# 100 is inside the 90..110 gap already.
assert find_blank_cut(img, 100, 20) == 100
def test_walks_outwards_to_find_a_gap(self):
img = paint(make_img(200), 0, 95)
paint(img, 105, 200)
cut = find_blank_cut(img, 90, 20)
assert 95 <= cut < 105
def test_solid_ink_returns_the_target(self):
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
def test_target_at_image_width_does_not_index_past_the_end(self):
# A cut after the last column is legal. Indexing ink[width] raised
# IndexError in the field, losing that plugin's content for the cycle.
# Reached once the rotation offset advances so start + budget lands
# exactly on the image width.
img = paint(make_img(1840), 0, 1840)
assert find_blank_cut(img, 1840, 32) == 1840
def test_target_past_image_width_is_clamped(self):
img = paint(make_img(100), 0, 100)
assert find_blank_cut(img, 500, 32) == 100
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
# Content 0..179, blank 180..199. The nearest blank column to 200 is
# 199, not the start of the gap — nearest is what keeps the cut as
# close as possible to the requested budget.
img = paint(make_img(200), 0, 180)
assert find_blank_cut(img, 200, 32) == 199
def test_zero_radius_returns_the_target(self):
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
def test_negative_target_is_clamped_to_zero(self):
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
def test_never_raises_across_the_range(self, target):
img = paint(make_img(100), 0, 100)
cut = find_blank_cut(img, target, 16)
assert 0 <= cut <= 100
+1 -8
View File
@@ -24,7 +24,6 @@ class FakeDisplayManager:
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
@@ -37,7 +36,7 @@ class TestShouldRecompose:
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):
def test_no_pending_updates_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
@@ -49,12 +48,6 @@ class TestShouldRecompose:
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
+79
View File
@@ -0,0 +1,79 @@
"""StreamManager logs one INFO line per plugin-list refresh.
The refresh runs at each cycle start and every 30 seconds. It used to write
decorative "=" * 60 banners plus a line per plugin ("INCLUDED", "SKIPPED"),
and each fetch added its own banners and "FETCHING CONTENT" / "SEGMENT
CREATED" lines, all at INFO: dozens of journal lines a minute on a Pi, burying
anything that mattered. Per-plugin detail belongs at DEBUG.
"""
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock
from PIL import Image
from src.plugin_system.base_plugin import VegasDisplayMode
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.stream_manager import StreamManager
LOGGER = "src.vegas_mode.stream_manager"
class _Plugin:
def __init__(self, enabled=True, mode=VegasDisplayMode.SCROLL):
self.enabled = enabled
self._mode = mode
def get_vegas_display_mode(self):
return self._mode
def _stream(plugins):
adapter = MagicMock()
adapter.get_content_type.return_value = 'multi'
adapter.get_content.return_value = [Image.new('RGB', (20, 8))]
return StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), adapter)
def _info(caplog):
return [r for r in caplog.records
if r.name == LOGGER and r.levelno == logging.INFO]
def test_a_refresh_logs_a_single_info_summary(caplog):
stream = _stream({
'clock': _Plugin(),
'weather': _Plugin(),
'off': _Plugin(enabled=False),
})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
stream._refresh_plugin_list()
info = _info(caplog)
assert len(info) == 1, [r.getMessage() for r in info]
summary = info[0].getMessage()
assert 'clock' in summary and 'weather' in summary
assert 'off' not in summary.split(':', 1)[1]
# The per-plugin decisions are still there for anyone at DEBUG.
debug = [r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG]
assert any('off' in m and 'not enabled' in m for m in debug)
def test_fetching_content_logs_nothing_at_info(caplog):
stream = _stream({'clock': _Plugin()})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
segment = stream._fetch_plugin_content('clock')
assert segment is not None
assert _info(caplog) == []
def test_no_decorative_banners(caplog):
stream = _stream({'clock': _Plugin(), 'static': _Plugin(mode=VegasDisplayMode.STATIC)})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
stream._refresh_plugin_list()
stream._fetch_plugin_content('clock')
stream._fetch_plugin_content('static')
assert not any(set(r.getMessage()) == {'='} for r in caplog.records)