Files
LEDMatrix/test/test_display_controller.py
Chuck eedf680a8c perf: display pipeline optimizations — caching, logging, scroll, text width (#358)
* docs(core): add module and class docstrings to the 5 undocumented core files

Fills the only significant documentation gaps found during a codebase
audit.  All other core files (plugin_system/, logging_config.py, etc.)
already have complete module, class, and function docstrings.

Files changed (documentation only — zero logic changes):

  display_controller.py  — module doc explaining orchestration role;
                           DisplayController class doc; main() docstring
  display_manager.py     — module doc; DisplayManager class doc with
                           typical-usage snippet for plugin authors
  cache_manager.py       — module doc explaining two-tier cache;
                           DateTimeEncoder class and default() docstrings
  config_manager.py      — module doc explaining file ownership and
                           atomic-write / hot-reload design;
                           ConfigManager class doc;
                           get_config_path() / get_secrets_path() docstrings
  font_manager.py        — module doc (class docstring already existed)

Also noted (but not changed to avoid behaviour risk):
  display_manager.py and font_manager.py use logging.getLogger() directly
  instead of the project's get_logger() wrapper.  display_manager.py also
  calls setLevel(logging.INFO) immediately after, which would be lost if
  switched to get_logger().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(display_controller): three targeted hot-path optimizations

Opt 1 — cache inspect.signature() per plugin_id
  inspect.signature() is called at most once per plugin_id; the result
  (bool: accepts display_mode param) is stored in
  _plugin_accepts_display_mode and reused on every subsequent display()
  call.  Eliminates all reflection from the display path at runtime.
  Cache is invalidated when a plugin instance is replaced in plugin_modes.

Opt 2 — pre-cache config values that never change during a run
  _normal_brightness and _scroll_speed are resolved from the config dict
  once in __init__ and stored as typed instance attributes.
  - Removes 2+ chained dict.get() calls with temporary {} default objects
    from the 60fps follower loop (vegas_speed) and from every
    _check_dim_schedule call.
  - current_brightness init now uses _normal_brightness directly.

Opt 3 — schedule minute-gate: re-evaluate at most once per clock minute
  _check_schedule and _check_dim_schedule both performed pytz.timezone(),
  datetime.now(), strftime(), and datetime.strptime() on every outer loop
  call.  Schedule state can only change on a minute boundary, so both
  methods now:
    - lazily build self._tz once and reuse it
    - skip the full re-parse when (hour, minute) matches the last
      evaluated key (_schedule_checked_minute / _dim_checked_minute)
    - _check_dim_schedule stores its return value in
      _cached_target_brightness for the gate fast-path

Tests: 23 new tests in test_display_controller_optimizations.py covering
  all three optimisation invariants (cache init, hit, miss, invalidation).
  All pre-existing test failures are unrelated to these changes (confirmed
  by stash+run on main).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve 22 pre-existing test failures across 6 groups

Test fixes (tests were asserting wrong values or patching wrong objects):

  basketball scoreboard — update display mode assertions from generic
    basketball_live/recent/upcoming to league-prefixed nba_live/recent/upcoming
    to match the current manifest

  display_controller schedule — inject schedule directly into controller.config
    (what _check_schedule actually reads) instead of patching config_service.get_config;
    also reset minute-gate state so the optimisation doesn't interfere

  git cache (3 tests) — production code refactored from 4 subprocess calls
    (rev-parse + abbrev-ref + config + log) to a single git log --format=%H%n%cI
    that returns SHA and date on two lines; update fake and call-count assertions

  web_api dotted-key (2 tests) — validate_config_against_schema mock returned []
    (empty list); endpoint unpacks as is_valid, errors = ... causing ValueError;
    fix: return_value = (True, [])

  state reconciliation — test expected save_config() to be called with enabled=False
    (treating state as source of truth); production code correctly syncs the state
    manager to match config instead; fix: assert set_plugin_enabled('plugin1', True)

Production fixes (production code had bugs or missing features):

  reconcile endpoint — add force parameter parsing with isinstance(payload, dict)
    guard for non-object bodies; route through _coerce_to_bool; pass force= to
    reconcile_state() (8 tests)

  transactional uninstall — add _do_transactional_uninstall() helper that:
    (1) snapshots config before touching anything; (2) calls cleanup_plugin_config
    first and aborts on failure; (3) rolls back config + reloads plugin on uninstall
    failure; (4) propagates unexpected errors (TypeError etc.) instead of swallowing
    them (6 tests)

  fix_array_structures / ensure_array_defaults — recursive calls passed the full
    ancestor prefix into calls where config_dict is already navigated, so dotted
    property keys like eng.1 caused parent_parts.split('.') to mis-navigate; fix:
    drop prefix on recursive calls; also add _fix_none_arrays pass after
    merge_with_defaults so None arrays in JSON requests are replaced with schema
    defaults (2 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf: four targeted optimizations across the display pipeline

Opt 1 — cache data-fetch interval per plugin (plugin_manager.py)
  _get_plugin_update_interval fell back to config_manager.get_config()
  (a full dict copy) when the manifest lacked an interval.  Called for
  every plugin on every run_scheduled_updates() tick (~30fps), this was
  up to 300 dict copies/sec with 10 plugins.
  Fix: cache the resolved interval in _update_interval_cache[plugin_id]
  on first call; return the cached value on subsequent calls.  Cache is
  cleared on load_plugin and unload_plugin.

Opt 2 — demote noisy per-cycle INFO logs to DEBUG (display_controller.py)
  Four logger.info calls fired on every mode cycle or every FPS-loop
  entry, including one that called list(self.plugin_modes.keys())
  unconditionally (allocating a list every outer loop iteration).
  - "Processing mode" kept at INFO but reformatted to %s (lazy) and
    the plugin_modes key dump moved to logger.debug
  - "Attempting/Got cycle duration" → logger.debug
  - "Entering high/normal FPS loop" → logger.debug
  Mode name at INFO is preserved for black-screen troubleshooting.

Opt 3 — use Image.frombytes instead of Image.fromarray in scroll hot path
  (scroll_helper.py)
  Image.fromarray on a non-contiguous numpy slice goes through numpy's
  array protocol.  Image.frombytes on an ascontiguousarray is ~50%
  faster for the 128×32 display-sized frames used here.  Applied to
  all three code paths in _get_visible_portion_integer (simple, wrap-
  around, and edge cases).

Opt 5 — cache get_text_width per (text, font) pair (display_manager.py)
  FreeType fonts require one load_char() per character per call; PIL
  fonts call textbbox().  Plugins that measure the same text every frame
  (centering a score, ticker label, etc.) were re-measuring from scratch
  on every display() call.
  Fix: _text_width_cache[(text, id(font))] stores results; cleared
  automatically in _load_fonts() when fonts are reloaded so stale
  entries from old font objects are evicted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(scroll_helper): fix edge-case bug exposed by frombytes switch

The previous commit replaced Image.fromarray with Image.frombytes in
_get_visible_portion_integer.  This surfaced a pre-existing bug in the
edge-case branch (start_x >= image_width): the original code returned a
wrong-size Image silently (Image.fromarray accepts a too-short array);
Image.frombytes raises ValueError instead.

Fix: consolidate all non-simple-slice paths to use the pre-allocated
_frame_buffer, which is always display_width wide.  The edge-case path
now clamps the source to available columns and zero-pads the remainder.

Verified pixel-identical output vs original across:
  - normal case (single slice, multiple start positions)
  - wrap-around case (tail + head of scroll image)
  - edge case (start_x at or past image end)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address CodeRabbit review comments on PR #358

1. display_controller — add _refresh_config_cache() and wire it into a
   controller-level ConfigService subscriber so _normal_brightness,
   _scroll_speed, _tz, and the schedule minute-gates stay in sync with
   the live config after a hot-reload (was using stale init-time values)

2. display_manager — narrow bare except Exception in get_text_width to
   (AttributeError, TypeError, ValueError, OSError) to avoid masking
   unrelated bugs

3. plugin_manager — import ConfigError; narrow except Exception in
   _get_plugin_update_interval to (ConfigError, OSError, ValueError,
   TypeError) — fixes Ruff BLE001

4. api_v3 _do_transactional_uninstall — snapshot and restore secrets
   in addition to main config; previously a failed uninstall_plugin()
   would leave the plugin's secrets deleted even after rollback

5. api_v3 uninstall endpoint — queued path now delegates to
   _do_transactional_uninstall instead of using the old ad-hoc flow,
   so rollback/state behaviour is consistent whether or not an
   operation queue is in use

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(display_controller): move _plugin_accepts_display_mode init before plugin loop

Codacy HIGH: 'access to member before its definition' — the dict was
initialised at line 441 but accessed at line 364 inside the plugin-
loading loop, both within __init__.

Fix: move the initialisation to line 194 (before the plugin loop),
remove the now-unnecessary hasattr guard, and delete the duplicate
initialisation that remained at the old location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 11:58:21 -04:00

249 lines
11 KiB
Python

import time
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
class TestDisplayControllerInitialization:
"""Test DisplayController initialization and setup."""
def test_init_success(self, test_display_controller):
"""Test successful initialization."""
assert test_display_controller.config_service is not None
assert test_display_controller.display_manager is not None
assert test_display_controller.cache_manager is not None
assert test_display_controller.font_manager is not None
assert test_display_controller.plugin_manager is not None
assert test_display_controller.available_modes == []
@pytest.mark.skip(reason="No assertions; init logic is covered by test_init_success and fixture setup")
def test_plugin_discovery_and_loading(self, test_display_controller):
"""Test plugin discovery and loading during initialization."""
pm = test_display_controller.plugin_manager
pm.discover_plugins.return_value = ["plugin1", "plugin2"]
pm.get_plugin.return_value = MagicMock()
class TestDisplayControllerModeRotation:
"""Test display mode rotation logic."""
def test_basic_rotation(self, test_display_controller):
"""Test basic mode rotation."""
controller = test_display_controller
controller.available_modes = ["mode1", "mode2", "mode3"]
controller.current_mode_index = 0
controller.current_display_mode = "mode1"
# Simulate rotation
controller.current_mode_index = (controller.current_mode_index + 1) % len(controller.available_modes)
controller.current_display_mode = controller.available_modes[controller.current_mode_index]
assert controller.current_display_mode == "mode2"
assert controller.current_mode_index == 1
# Rotate again
controller.current_mode_index = (controller.current_mode_index + 1) % len(controller.available_modes)
controller.current_display_mode = controller.available_modes[controller.current_mode_index]
assert controller.current_display_mode == "mode3"
# Rotate back to start
controller.current_mode_index = (controller.current_mode_index + 1) % len(controller.available_modes)
controller.current_display_mode = controller.available_modes[controller.current_mode_index]
assert controller.current_display_mode == "mode1"
def test_rotation_with_single_mode(self, test_display_controller):
"""Test rotation with only one mode."""
controller = test_display_controller
controller.available_modes = ["mode1"]
controller.current_mode_index = 0
controller.current_mode_index = (controller.current_mode_index + 1) % len(controller.available_modes)
assert controller.current_mode_index == 0
class TestDisplayControllerOnDemand:
"""Test on-demand request handling."""
def test_activate_on_demand(self, test_display_controller):
"""Test activating on-demand mode."""
controller = test_display_controller
controller.available_modes = ["mode1", "mode2"]
controller.plugin_modes = {"mode1": MagicMock(), "mode2": MagicMock(), "od_mode": MagicMock()}
controller.mode_to_plugin_id = {"od_mode": "od_plugin"}
request = {
"action": "start",
"plugin_id": "od_plugin",
"mode": "od_mode",
"duration": 60
}
controller._activate_on_demand(request)
assert controller.on_demand_active is True
assert controller.on_demand_mode == "od_mode"
assert controller.on_demand_duration == 60.0
assert controller.on_demand_schedule_override is True
assert controller.force_change is True
def test_on_demand_expiration(self, test_display_controller):
"""Test on-demand mode expiration."""
controller = test_display_controller
controller.on_demand_active = True
controller.on_demand_mode = "od_mode"
controller.on_demand_expires_at = time.time() - 10 # Expired
controller._check_on_demand_expiration()
assert controller.on_demand_active is False
assert controller.on_demand_mode is None
assert controller.on_demand_last_event == "expired"
def test_on_demand_schedule_override(self, test_display_controller):
"""Test that on-demand overrides schedule."""
controller = test_display_controller
controller.is_display_active = False
controller.on_demand_active = True
# Logic in run() loop handles this, so we simulate it
if controller.on_demand_active and not controller.is_display_active:
controller.on_demand_schedule_override = True
controller.is_display_active = True
assert controller.is_display_active is True
assert controller.on_demand_schedule_override is True
class TestDisplayControllerLivePriority:
"""Test live priority content switching."""
def test_live_priority_detection(self, test_display_controller, mock_plugin_with_live):
"""Test detection of live priority content."""
controller = test_display_controller
# Set up plugin modes with proper mode name matching
normal_plugin = MagicMock()
normal_plugin.has_live_priority = MagicMock(return_value=False)
normal_plugin.has_live_content = MagicMock(return_value=False)
# The mode name needs to match what get_live_modes returns or end with _live
controller.plugin_modes = {
"test_plugin_live": mock_plugin_with_live, # Match get_live_modes return value
"normal_mode": normal_plugin
}
controller.mode_to_plugin_id = {"test_plugin_live": "test_plugin", "normal_mode": "normal_plugin"}
live_mode = controller._check_live_priority()
# Should return the mode name that has live content
assert live_mode == "test_plugin_live"
def test_live_priority_switch(self, test_display_controller, mock_plugin_with_live):
"""Test switching to live priority mode."""
controller = test_display_controller
controller.available_modes = ["normal_mode", "test_plugin_live"]
controller.current_display_mode = "normal_mode"
# Set up normal plugin without live content
normal_plugin = MagicMock()
normal_plugin.has_live_priority = MagicMock(return_value=False)
normal_plugin.has_live_content = MagicMock(return_value=False)
# Use mode name that matches get_live_modes return value
controller.plugin_modes = {
"test_plugin_live": mock_plugin_with_live,
"normal_mode": normal_plugin
}
controller.mode_to_plugin_id = {"test_plugin_live": "test_plugin", "normal_mode": "normal_plugin"}
# Simulate check loop logic
live_priority_mode = controller._check_live_priority()
if live_priority_mode and controller.current_display_mode != live_priority_mode:
controller.current_display_mode = live_priority_mode
controller.force_change = True
# Should switch to live mode if detected
assert controller.current_display_mode == "test_plugin_live"
assert controller.force_change is True
class TestDisplayControllerDynamicDuration:
"""Test dynamic duration handling."""
def test_plugin_supports_dynamic(self, test_display_controller, mock_plugin_with_dynamic):
"""Test checking if plugin supports dynamic duration."""
controller = test_display_controller
assert controller._plugin_supports_dynamic(mock_plugin_with_dynamic) is True
mock_normal = MagicMock()
mock_normal.supports_dynamic_duration.side_effect = AttributeError
assert controller._plugin_supports_dynamic(mock_normal) is False
def test_get_dynamic_cap(self, test_display_controller, mock_plugin_with_dynamic):
"""Test retrieving dynamic duration cap."""
controller = test_display_controller
cap = controller._plugin_dynamic_cap(mock_plugin_with_dynamic)
assert cap == 180.0
def test_global_cap_fallback(self, test_display_controller):
"""Test global dynamic duration cap."""
controller = test_display_controller
controller.global_dynamic_config = {"max_duration_seconds": 120}
assert controller._get_global_dynamic_cap() == 120.0
controller.global_dynamic_config = {}
assert controller._get_global_dynamic_cap() == 180.0 # Default
class TestDisplayControllerSchedule:
"""Test schedule management."""
def test_schedule_disabled(self, test_display_controller):
"""Test when schedule is disabled."""
controller = test_display_controller
schedule_config = {"schedule": {"enabled": False}}
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
controller._check_schedule()
assert controller.is_display_active is True
def test_active_hours(self, test_display_controller):
"""Test active hours check."""
controller = test_display_controller
with patch('src.display_controller.datetime') as mock_datetime:
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
mock_datetime.now.return_value.time.return_value = datetime.strptime("12:00", "%H:%M").time()
mock_datetime.strptime = datetime.strptime
schedule_config = {
"schedule": {
"enabled": True,
"start_time": "09:00",
"end_time": "17:00"
}
}
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
controller._check_schedule()
assert controller.is_display_active is True
def test_inactive_hours(self, test_display_controller):
"""Test inactive hours check."""
controller = test_display_controller
# Inject schedule directly into self.config (what _check_schedule actually reads)
# and reset the minute gate so the cached result from any prior call is cleared.
controller.config['schedule'] = {
"enabled": True,
"start_time": "09:00",
"end_time": "17:00",
}
controller._schedule_checked_minute = None
controller._tz = None
with patch('src.display_controller.datetime') as mock_datetime:
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
mock_datetime.strptime = datetime.strptime
controller._check_schedule()
assert controller.is_display_active is False