From 4228400c43c17e0b93d2edbbfa978e3496f524b0 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 15 Mar 2026 18:43:18 -0700 Subject: [PATCH] feat(daemon): add display abstraction and daemon mode with C&C --- engine/display.py | 102 +++++++++++++++++++++++++++ engine/scroll.py | 56 +++++++++++---- tests/test_controller.py | 32 +++++++++ tests/test_display.py | 79 +++++++++++++++++++++ tests/test_effects_controller.py | 117 +++++++++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 13 deletions(-) create mode 100644 engine/display.py create mode 100644 tests/test_display.py create mode 100644 tests/test_effects_controller.py diff --git a/engine/display.py b/engine/display.py new file mode 100644 index 0000000..32eb09e --- /dev/null +++ b/engine/display.py @@ -0,0 +1,102 @@ +""" +Display output abstraction - allows swapping output backends. + +Protocol: + - init(width, height): Initialize display with terminal dimensions + - show(buffer): Render buffer (list of strings) to display + - clear(): Clear the display + - cleanup(): Shutdown display +""" + +import time +from typing import Protocol + + +class Display(Protocol): + """Protocol for display backends.""" + + def init(self, width: int, height: int) -> None: + """Initialize display with dimensions.""" + ... + + def show(self, buffer: list[str]) -> None: + """Show buffer on display.""" + ... + + def clear(self) -> None: + """Clear display.""" + ... + + def cleanup(self) -> None: + """Shutdown display.""" + ... + + +def get_monitor(): + """Get the performance monitor.""" + try: + from engine.effects.performance import get_monitor as _get_monitor + + return _get_monitor() + except Exception: + return None + + +class TerminalDisplay: + """ANSI terminal display backend.""" + + def __init__(self): + self.width = 80 + self.height = 24 + + def init(self, width: int, height: int) -> None: + from engine.terminal import CURSOR_OFF + + self.width = width + self.height = height + print(CURSOR_OFF, end="", flush=True) + + def show(self, buffer: list[str]) -> None: + import sys + + t0 = time.perf_counter() + sys.stdout.buffer.write("".join(buffer).encode()) + sys.stdout.flush() + elapsed_ms = (time.perf_counter() - t0) * 1000 + + monitor = get_monitor() + if monitor: + chars_in = sum(len(line) for line in buffer) + monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in) + + def clear(self) -> None: + from engine.terminal import CLR + + print(CLR, end="", flush=True) + + def cleanup(self) -> None: + from engine.terminal import CURSOR_ON + + print(CURSOR_ON, end="", flush=True) + + +class NullDisplay: + """Headless/null display - discards all output.""" + + def init(self, width: int, height: int) -> None: + self.width = width + self.height = height + + def show(self, buffer: list[str]) -> None: + monitor = get_monitor() + if monitor: + t0 = time.perf_counter() + chars_in = sum(len(line) for line in buffer) + elapsed_ms = (time.perf_counter() - t0) * 1000 + monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in) + + def clear(self) -> None: + pass + + def cleanup(self) -> None: + pass diff --git a/engine/scroll.py b/engine/scroll.py index 41445ad..d13408b 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -4,33 +4,42 @@ Orchestrates viewport, frame timing, and layers. """ import random -import sys import time from engine import config +from engine.display import ( + Display, + TerminalDisplay, +) +from engine.display import ( + get_monitor as _get_display_monitor, +) from engine.frame import calculate_scroll_step from engine.layers import ( apply_glitch, + process_effects, render_firehose, render_message_overlay, render_ticker_zone, ) -from engine.terminal import CLR from engine.viewport import th, tw +USE_EFFECT_CHAIN = True -def stream(items, ntfy_poller, mic_monitor): + +def stream(items, ntfy_poller, mic_monitor, display: Display | None = None): """Main render loop with four layers: message, ticker, scroll motion, firehose.""" + if display is None: + display = TerminalDisplay() random.shuffle(items) pool = list(items) seen = set() queued = 0 time.sleep(0.5) - sys.stdout.write(CLR) - sys.stdout.flush() - w, h = tw(), th() + display.init(w, h) + display.clear() fh = config.FIREHOSE_H if config.FIREHOSE else 0 ticker_view_h = h - fh GAP = 3 @@ -42,6 +51,7 @@ def stream(items, ntfy_poller, mic_monitor): noise_cache = {} scroll_motion_accum = 0.0 msg_cache = (None, None) + frame_number = 0 while True: if queued >= config.HEADLINE_LIMIT and not active: @@ -93,19 +103,39 @@ def stream(items, ntfy_poller, mic_monitor): buf.extend(ticker_buf) mic_excess = mic_monitor.excess - buf = apply_glitch(buf, ticker_buf_start, mic_excess, w) + render_start = time.perf_counter() - firehose_buf = render_firehose(items, w, fh, h) - buf.extend(firehose_buf) + if USE_EFFECT_CHAIN: + buf = process_effects( + buf, + w, + h, + scroll_cam, + ticker_h, + mic_excess, + grad_offset, + frame_number, + msg is not None, + items, + ) + else: + buf = apply_glitch(buf, ticker_buf_start, mic_excess, w) + firehose_buf = render_firehose(items, w, fh, h) + buf.extend(firehose_buf) if msg_overlay: buf.extend(msg_overlay) - sys.stdout.buffer.write("".join(buf).encode()) - sys.stdout.flush() + render_elapsed = (time.perf_counter() - render_start) * 1000 + monitor = _get_display_monitor() + if monitor: + chars = sum(len(line) for line in buf) + monitor.record_effect("render", render_elapsed, chars, chars) + + display.show(buf) elapsed = time.monotonic() - t0 time.sleep(max(0, config.FRAME_DT - elapsed)) + frame_number += 1 - sys.stdout.write(CLR) - sys.stdout.flush() + display.cleanup() diff --git a/tests/test_controller.py b/tests/test_controller.py index 96ef02d..0f08b9b 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -83,3 +83,35 @@ class TestStreamControllerCleanup: controller.cleanup() mock_mic_instance.stop.assert_called_once() + + +class TestStreamControllerWarmup: + """Tests for StreamController topic warmup.""" + + def test_warmup_topics_idempotent(self): + """warmup_topics can be called multiple times.""" + StreamController._topics_warmed = False + + with patch("urllib.request.urlopen") as mock_urlopen: + StreamController.warmup_topics() + StreamController.warmup_topics() + + assert mock_urlopen.call_count >= 3 + + def test_warmup_topics_sets_flag(self): + """warmup_topics sets the warmed flag.""" + StreamController._topics_warmed = False + + with patch("urllib.request.urlopen"): + StreamController.warmup_topics() + + assert StreamController._topics_warmed is True + + def test_warmup_topics_skips_after_first(self): + """warmup_topics skips after first call.""" + StreamController._topics_warmed = True + + with patch("urllib.request.urlopen") as mock_urlopen: + StreamController.warmup_topics() + + mock_urlopen.assert_not_called() diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..e2c08b4 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,79 @@ +""" +Tests for engine.display module. +""" + +from engine.display import NullDisplay, TerminalDisplay + + +class TestDisplayProtocol: + """Test that display backends satisfy the Display protocol.""" + + def test_terminal_display_is_display(self): + """TerminalDisplay satisfies Display protocol.""" + display = TerminalDisplay() + assert hasattr(display, "init") + assert hasattr(display, "show") + assert hasattr(display, "clear") + assert hasattr(display, "cleanup") + + def test_null_display_is_display(self): + """NullDisplay satisfies Display protocol.""" + display = NullDisplay() + assert hasattr(display, "init") + assert hasattr(display, "show") + assert hasattr(display, "clear") + assert hasattr(display, "cleanup") + + +class TestTerminalDisplay: + """Tests for TerminalDisplay class.""" + + def test_init_sets_dimensions(self): + """init stores terminal dimensions.""" + display = TerminalDisplay() + display.init(80, 24) + assert display.width == 80 + assert display.height == 24 + + def test_show_returns_none(self): + """show returns None after writing to stdout.""" + display = TerminalDisplay() + display.width = 80 + display.height = 24 + display.show(["line1", "line2"]) + + def test_clear_does_not_error(self): + """clear works without error.""" + display = TerminalDisplay() + display.clear() + + def test_cleanup_does_not_error(self): + """cleanup works without error.""" + display = TerminalDisplay() + display.cleanup() + + +class TestNullDisplay: + """Tests for NullDisplay class.""" + + def test_init_stores_dimensions(self): + """init stores dimensions.""" + display = NullDisplay() + display.init(100, 50) + assert display.width == 100 + assert display.height == 50 + + def test_show_does_nothing(self): + """show discards buffer without error.""" + display = NullDisplay() + display.show(["line1", "line2", "line3"]) + + def test_clear_does_nothing(self): + """clear does nothing.""" + display = NullDisplay() + display.clear() + + def test_cleanup_does_nothing(self): + """cleanup does nothing.""" + display = NullDisplay() + display.cleanup() diff --git a/tests/test_effects_controller.py b/tests/test_effects_controller.py new file mode 100644 index 0000000..fd17fe8 --- /dev/null +++ b/tests/test_effects_controller.py @@ -0,0 +1,117 @@ +""" +Tests for engine.effects.controller module. +""" + +from unittest.mock import MagicMock, patch + +from engine.effects.controller import ( + handle_effects_command, + set_effect_chain_ref, +) + + +class TestHandleEffectsCommand: + """Tests for handle_effects_command function.""" + + def test_list_effects(self): + """list command returns formatted effects list.""" + with patch("engine.effects.controller.get_registry") as mock_registry: + mock_plugin = MagicMock() + mock_plugin.config.enabled = True + mock_plugin.config.intensity = 0.5 + mock_registry.return_value.list_all.return_value = {"noise": mock_plugin} + + with patch("engine.effects.controller._get_effect_chain") as mock_chain: + mock_chain.return_value.get_order.return_value = ["noise"] + + result = handle_effects_command("/effects list") + + assert "noise: ON" in result + assert "intensity=0.5" in result + + def test_enable_effect(self): + """enable command calls registry.enable.""" + with patch("engine.effects.controller.get_registry") as mock_registry: + mock_plugin = MagicMock() + mock_registry.return_value.get.return_value = mock_plugin + mock_registry.return_value.list_all.return_value = {"noise": mock_plugin} + + result = handle_effects_command("/effects noise on") + + assert "Enabled: noise" in result + mock_registry.return_value.enable.assert_called_once_with("noise") + + def test_disable_effect(self): + """disable command calls registry.disable.""" + with patch("engine.effects.controller.get_registry") as mock_registry: + mock_plugin = MagicMock() + mock_registry.return_value.get.return_value = mock_plugin + mock_registry.return_value.list_all.return_value = {"noise": mock_plugin} + + result = handle_effects_command("/effects noise off") + + assert "Disabled: noise" in result + mock_registry.return_value.disable.assert_called_once_with("noise") + + def test_set_intensity(self): + """intensity command sets plugin intensity.""" + with patch("engine.effects.controller.get_registry") as mock_registry: + mock_plugin = MagicMock() + mock_plugin.config.intensity = 0.5 + mock_registry.return_value.get.return_value = mock_plugin + mock_registry.return_value.list_all.return_value = {"noise": mock_plugin} + + result = handle_effects_command("/effects noise intensity 0.8") + + assert "intensity to 0.8" in result + assert mock_plugin.config.intensity == 0.8 + + def test_invalid_intensity_range(self): + """intensity outside 0.0-1.0 returns error.""" + with patch("engine.effects.controller.get_registry") as mock_registry: + mock_plugin = MagicMock() + mock_registry.return_value.get.return_value = mock_plugin + mock_registry.return_value.list_all.return_value = {"noise": mock_plugin} + + result = handle_effects_command("/effects noise intensity 1.5") + + assert "between 0.0 and 1.0" in result + + def test_reorder_pipeline(self): + """reorder command calls chain.reorder.""" + with patch("engine.effects.controller.get_registry") as mock_registry: + mock_registry.return_value.list_all.return_value = {} + + with patch("engine.effects.controller._get_effect_chain") as mock_chain: + mock_chain_instance = MagicMock() + mock_chain_instance.reorder.return_value = True + mock_chain.return_value = mock_chain_instance + + result = handle_effects_command("/effects reorder noise,fade") + + assert "Reordered pipeline" in result + mock_chain_instance.reorder.assert_called_once_with(["noise", "fade"]) + + def test_unknown_command(self): + """unknown command returns error.""" + result = handle_effects_command("/unknown") + assert "Unknown command" in result + + def test_non_effects_command(self): + """non-effects command returns error.""" + result = handle_effects_command("not a command") + assert "Unknown command" in result + + +class TestSetEffectChainRef: + """Tests for set_effect_chain_ref function.""" + + def test_sets_global_ref(self): + """set_effect_chain_ref updates global reference.""" + mock_chain = MagicMock() + set_effect_chain_ref(mock_chain) + + from engine.effects.controller import _get_effect_chain + + result = _get_effect_chain() + assert result == mock_chain