diff --git a/AGENTS.md b/AGENTS.md index bcd5d1f..dec7568 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,13 +22,37 @@ uv sync ### Available Commands ```bash +# Development mise run test # Run tests mise run test-v # Run tests verbose mise run test-cov # Run tests with coverage report mise run lint # Run ruff linter mise run lint-fix # Run ruff with auto-fix mise run format # Run ruff formatter -mise run ci # Full CI pipeline (sync + test + coverage) +mise run ci # Full CI pipeline + +# Runtime +mise run run # Interactive terminal mode (news) +mise run run-poetry # Interactive terminal mode (poetry) +mise run run-firehose # Dense headline mode + +# Daemon mode (recommended for long-running) +mise run daemon # Start mainline in background +mise run daemon-stop # Stop daemon +mise run daemon-restart # Restart daemon + +# Command & Control +mise run cmd # Interactive CLI +mise run cmd "/cmd" # Send single command +mise run cmd-stats # Watch performance stats +mise run topics-init # Initialize ntfy topics + +# Environment +mise run install # Install dependencies +mise run sync # Sync dependencies +mise run sync-all # Sync with all extras +mise run clean # Clean cache files +mise run clobber # Aggressive cleanup (git clean -fdx + caches) ``` ## Git Hooks @@ -108,6 +132,59 @@ The project uses pytest with strict marker enforcement. Test configuration is in - **eventbus.py** provides thread-safe event publishing for decoupled communication - **controller.py** coordinates ntfy/mic monitoring - The render pipeline: fetch → render → effects → scroll → terminal output +- **display.py** provides swappable display backends (TerminalDisplay, NullDisplay) + +## Operating Modes + +Mainline can run in two modes: + +### 1. Standalone Mode (Original) +Run directly as a terminal application with interactive pickers: +```bash +mise run run # news stream +mise run run-poetry # poetry mode +mise run run-firehose # dense headline mode +``` +This runs the full interactive experience with font picker and effects picker at startup. + +### 2. Daemon + Command Mode (Recommended for Long-Running) + +The recommended approach for persistent displays: + +```bash +# Start the daemon (headless rendering) +mise run daemon + +# Send commands via ntfy +mise run cmd "/effects list" +mise run cmd "/effects noise off" +mise run cmd "/effects stats" + +# Watch mode (continuous stats polling) +mise run cmd-stats + +# Stop the daemon +mise run daemon-stop +``` + +#### How It Works + +- **Daemon**: Runs `mainline.py` in the background, renders to terminal +- **C&C Topics**: Uses separate ntfy topics (like UART serial): + - `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline + - `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline +- **Topics are auto-warmed** on first daemon start + +#### Available Commands + +``` +/effects list - List all effects and status +/effects on - Enable an effect +/effects off - Disable an effect +/effects intensity 0.5 - Set effect intensity (0.0-1.0) +/effects reorder noise,fade,glitch,firehose - Reorder pipeline +/effects stats - Show performance statistics +``` ## Effects Plugin System @@ -143,15 +220,20 @@ class MyEffect: ### NTFY Commands -Send messages to the ntfy topic to control effects: +Send commands via `cmdline.py` or directly to the C&C topic: +```bash +# Using cmdline tool (recommended) +mise run cmd "/effects list" +mise run cmd "/effects noise on" +mise run cmd "/effects noise intensity 0.5" +mise run cmd "/effects reorder noise,glitch,fade,firehose" + +# Or directly via curl +curl -d "/effects list" https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd ``` -/effects list -/effects noise on -/effects noise off -/effects noise intensity 0.5 -/effects reorder noise,glitch,fade,firehose -``` + +The cmdline tool polls the response topic for the daemon's reply. ## Conventional Commits diff --git a/README.md b/README.md index dfc0257..fa1c292 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,46 @@ First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, --- +## Daemon Mode (Recommended for Long-Running) + +For persistent displays (e.g., always-on terminal), use daemon mode with command-and-control over ntfy: + +```bash +# Start the daemon (runs in background, auto-warms ntfy topics) +mise run daemon + +# Send commands via cmdline +mise run cmd "/effects list" +mise run cmd "/effects noise off" +mise run cmd "/effects noise intensity 0.5" + +# Watch performance stats continuously +mise run cmd-stats + +# Stop the daemon +mise run daemon-stop +``` + +### How It Works + +- **Topics**: Uses separate ntfy topics for serial-like communication: + - `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline + - `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline +- Topics are automatically created on first daemon start + +### Available Commands + +``` +/effects list - List all effects and status +/effects on - Enable an effect +/effects off - Disable an effect +/effects intensity 0.5 - Set effect intensity (0.0-1.0) +/effects reorder noise,fade,glitch,firehose - Reorder pipeline +/effects stats - Show performance statistics +``` + +--- + ## Config All constants live in `engine/config.py`: @@ -85,9 +125,17 @@ engine/ sources.py FEEDS, POETRY_SOURCES, language/script maps terminal.py ANSI codes, tw/th, type_out, boot_ln filter.py HTML stripping, content filter - translate.py Google Translate wrapper + region detection + translate.py Google Translate wrapper + region detection render.py OTF → half-block pipeline (SSAA, gradient) - effects.py noise, glitch_bar, fade, firehose + effects/ plugin-based effects system + types.py EffectConfig, EffectContext, EffectPlugin protocol + registry.py Plugin discovery and management + chain.py Ordered pipeline execution + performance.py Performance monitoring + controller.py NTFY command handler + legacy.py Original effects (noise, glitch, fade, firehose) + effects_plugins/ External effect plugins (noise, glitch, fade, firehose) + display.py Swappable display backends (TerminalDisplay, NullDisplay) fetch.py RSS/Gutenberg fetching + cache load/save ntfy.py NtfyPoller — standalone, zero internal deps mic.py MicMonitor — standalone, graceful fallback @@ -96,7 +144,7 @@ engine/ frame.py scroll step calculation, timing layers.py ticker zone, firehose, message overlay rendering eventbus.py thread-safe event publishing for decoupled communication - events.py event types and definitions + events.py event types and definitions controller.py coordinates ntfy/mic monitoring and event publishing emitters.py background emitters for ntfy and mic types.py type definitions and dataclasses diff --git a/cmdline.py b/cmdline.py index 691a2b7..9ee9ba6 100644 --- a/cmdline.py +++ b/cmdline.py @@ -32,18 +32,19 @@ from engine import config from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST try: - CC_TOPIC = config.NTFY_CC_TOPIC + CC_CMD_TOPIC = config.NTFY_CC_CMD_TOPIC + CC_RESP_TOPIC = config.NTFY_CC_RESP_TOPIC except AttributeError: - CC_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc/json" - -TOPIC = CC_TOPIC + CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json" + CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json" class NtfyResponsePoller: """Polls ntfy for command responses.""" - def __init__(self, topic_url: str, timeout: float = 10.0): - self.topic_url = topic_url + def __init__(self, cmd_topic: str, resp_topic: str, timeout: float = 10.0): + self.cmd_topic = cmd_topic + self.resp_topic = resp_topic self.timeout = timeout self._last_id = None self._lock = threading.Lock() @@ -51,7 +52,7 @@ class NtfyResponsePoller: def _build_url(self) -> str: from urllib.parse import parse_qs, urlencode, urlparse, urlunparse - parsed = urlparse(self.topic_url) + parsed = urlparse(self.resp_topic) params = parse_qs(parsed.query, keep_blank_values=True) params["since"] = [self._last_id if self._last_id else "20s"] new_query = urlencode({k: v[0] for k, v in params.items()}) @@ -59,7 +60,7 @@ class NtfyResponsePoller: def send_and_wait(self, cmd: str) -> str: """Send command and wait for response.""" - url = self.topic_url.replace("/json", "") + url = self.cmd_topic.replace("/json", "") data = cmd.encode("utf-8") req = urllib.request.Request( @@ -77,9 +78,9 @@ class NtfyResponsePoller: except Exception as e: return f"Error sending command: {e}" - return self._wait_for_response() + return self._wait_for_response(cmd) - def _wait_for_response(self) -> str: + def _wait_for_response(self, expected_cmd: str = "") -> str: """Poll for response message.""" start = time.time() while time.time() - start < self.timeout: @@ -127,7 +128,8 @@ def print_header(): f" \033[1;38;5;231m║\033[0m \033[1;38;5;82mMAINLINE\033[0m \033[3;38;5;245mCommand Center\033[0m \033[1;38;5;231m ║\033[0m" ) print(f" \033[1;38;5;231m╚{'═' * (w - 6)}╝\033[0m") - print(f" \033[2;38;5;37mTopic: {TOPIC}\033[0m") + print(f" \033[2;38;5;37mCMD: {CC_CMD_TOPIC.split('/')[-2]}\033[0m") + print(f" \033[2;38;5;37mRESP: {CC_RESP_TOPIC.split('/')[-2]}\033[0m") print() @@ -151,7 +153,7 @@ def interactive_mode(): import readline print_header() - poller = NtfyResponsePoller(TOPIC) + poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC) print(f" \033[38;5;245mType /help for commands, /quit to exit\033[0m") print() @@ -182,6 +184,7 @@ def interactive_mode(): print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n") print(CURSOR_ON, end="") + return 0 def main(): @@ -206,12 +209,20 @@ def main(): args = parser.parse_args() if args.command is None: - interactive_mode() - return + return interactive_mode() - poller = NtfyResponsePoller(TOPIC) + poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC) if args.watch and "/effects stats" in args.command: + import signal + + def handle_sigterm(*_): + print(f"\n \033[1;38;5;245mStopped watching{RST}") + print(CURSOR_ON, end="") + sys.exit(0) + + signal.signal(signal.SIGTERM, handle_sigterm) + print_header() print(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n") try: @@ -227,10 +238,12 @@ def main(): time.sleep(2) except KeyboardInterrupt: print(f"\n \033[1;38;5;245mStopped watching{RST}") - return + return 0 + return 0 result = poller.send_and_wait(args.command) print(result) + return 0 if __name__ == "__main__": diff --git a/engine/app.py b/engine/app.py index ced710a..3770bd3 100644 --- a/engine/app.py +++ b/engine/app.py @@ -11,10 +11,8 @@ import time import tty from engine import config, render +from engine.controller import StreamController from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache -from engine.mic import MicMonitor -from engine.ntfy import NtfyPoller -from engine.scroll import stream from engine.terminal import ( CLR, CURSOR_OFF, @@ -363,6 +361,8 @@ def main(): signal.signal(signal.SIGINT, handle_sigint) + StreamController.warmup_topics() + w = tw() print(CLR, end="") print(CURSOR_OFF, end="") @@ -419,9 +419,10 @@ def main(): sys.exit(1) print() - mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB) - mic_ok = mic.start() - if mic.available: + controller = StreamController() + mic_ok, ntfy_ok = controller.initialize_sources() + + if controller.mic and controller.mic.available: boot_ln( "Microphone", "ACTIVE" @@ -430,12 +431,6 @@ def main(): bool(mic_ok), ) - ntfy = NtfyPoller( - config.NTFY_TOPIC, - reconnect_delay=config.NTFY_RECONNECT_DELAY, - display_secs=config.MESSAGE_DISPLAY_SECS, - ) - ntfy_ok = ntfy.start() boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok) if config.FIREHOSE: @@ -448,7 +443,7 @@ def main(): print() time.sleep(0.4) - stream(items, ntfy, mic) + controller.run(items) print() print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}") diff --git a/engine/config.py b/engine/config.py index a24a8e0..957bf75 100644 --- a/engine/config.py +++ b/engine/config.py @@ -105,7 +105,8 @@ class Config: firehose: bool = False ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json" - ntfy_cc_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc/json" + ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json" + ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json" ntfy_reconnect_delay: int = 5 message_display_secs: int = 30 @@ -149,7 +150,8 @@ class Config: mode="poetry" if "--poetry" in argv or "-p" in argv else "news", firehose="--firehose" in argv, ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json", - ntfy_cc_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc/json", + ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json", + ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json", ntfy_reconnect_delay=5, message_display_secs=30, font_dir=font_dir, @@ -195,7 +197,8 @@ FIREHOSE = "--firehose" in sys.argv # ─── NTFY MESSAGE QUEUE ────────────────────────────────── NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json" -NTFY_CC_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc/json" +NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json" +NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json" NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen diff --git a/engine/controller.py b/engine/controller.py index 7885a71..90ef2e1 100644 --- a/engine/controller.py +++ b/engine/controller.py @@ -14,6 +14,8 @@ from engine.scroll import stream class StreamController: """Controls the stream lifecycle - initializes sources and runs the stream.""" + _topics_warmed = False + def __init__(self, config: Config | None = None, event_bus: EventBus | None = None): self.config = config or get_config() self.event_bus = event_bus @@ -21,6 +23,37 @@ class StreamController: self.ntfy: NtfyPoller | None = None self.ntfy_cc: NtfyPoller | None = None + @classmethod + def warmup_topics(cls) -> None: + """Warm up ntfy topics lazily (creates them if they don't exist).""" + if cls._topics_warmed: + return + + import urllib.request + + topics = [ + "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd", + "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp", + "https://ntfy.sh/klubhaus_terminal_mainline", + ] + + for topic in topics: + try: + req = urllib.request.Request( + topic, + data=b"init", + headers={ + "User-Agent": "mainline/0.1", + "Content-Type": "text/plain", + }, + method="POST", + ) + urllib.request.urlopen(req, timeout=5) + except Exception: + pass + + cls._topics_warmed = True + def initialize_sources(self) -> tuple[bool, bool]: """Initialize microphone and ntfy sources. @@ -38,7 +71,7 @@ class StreamController: ntfy_ok = self.ntfy.start() self.ntfy_cc = NtfyPoller( - self.config.ntfy_cc_topic, + self.config.ntfy_cc_cmd_topic, reconnect_delay=self.config.ntfy_reconnect_delay, display_secs=5, ) @@ -57,7 +90,7 @@ class StreamController: response = handle_effects_command(cmd) - topic_url = self.config.ntfy_cc_topic.replace("/json", "") + topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "") data = response.encode("utf-8") req = urllib.request.Request( topic_url, 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/effects/controller.py b/engine/effects/controller.py index eb547d5..3e72881 100644 --- a/engine/effects/controller.py +++ b/engine/effects/controller.py @@ -52,6 +52,12 @@ def handle_effects_command(cmd: str) -> str: if parts[1] == "stats": return _format_stats() + if parts[1] == "reorder" and len(parts) >= 3: + new_order = parts[2].split(",") + if chain and chain.reorder(new_order): + return f"Reordered pipeline: {new_order}" + return "Failed to reorder pipeline" + if len(parts) < 3: return "Usage: /effects on|off|intensity " @@ -81,12 +87,6 @@ def handle_effects_command(cmd: str) -> str: except ValueError: return "Invalid intensity value" - if action == "reorder" and len(parts) >= 3: - new_order = parts[2].split(",") - if chain and chain.reorder(new_order): - return f"Reordered pipeline: {new_order}" - return "Failed to reorder pipeline" - return f"Unknown action: {action}" diff --git a/engine/scroll.py b/engine/scroll.py index 9868b6b..d13408b 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -4,10 +4,16 @@ 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, @@ -16,24 +22,24 @@ from engine.layers import ( 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 @@ -97,6 +103,7 @@ def stream(items, ntfy_poller, mic_monitor): buf.extend(ticker_buf) mic_excess = mic_monitor.excess + render_start = time.perf_counter() if USE_EFFECT_CHAIN: buf = process_effects( @@ -119,12 +126,16 @@ def stream(items, ntfy_poller, mic_monitor): 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/mise.toml b/mise.toml index a921388..a286812 100644 --- a/mise.toml +++ b/mise.toml @@ -25,12 +25,19 @@ run = "uv run mainline.py" run-poetry = "uv run mainline.py --poetry" run-firehose = "uv run mainline.py --firehose" +daemon = "nohup uv run mainline.py > /dev/null 2>&1 &" +daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true" +daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon" + # ===================== # Command & Control # ===================== cmd = "uv run cmdline.py" -cmd-stats = "uv run cmdline.py -w /effects stats" +cmd-stats = "bash -c 'uv run cmdline.py -w \"/effects stats\"';:" + +# Initialize ntfy topics (warm up before first use - also done automatically by mainline) +topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null" # ===================== # Environment @@ -38,18 +45,21 @@ cmd-stats = "uv run cmdline.py -w /effects stats" sync = "uv sync" sync-all = "uv sync --all-extras" -install = "uv sync" -install-dev = "uv sync --group dev" +install = "mise run sync" +install-dev = "mise run sync && uv sync --group dev" bootstrap = "uv sync && uv run mainline.py --help" -clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache" +clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out" + +# Aggressive cleanup - removes all generated files, caches, and venv +clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out" # ===================== # CI/CD # ===================== -ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml" +ci = "mise run topics-init && uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml" ci-lint = "uv run ruff check engine/ mainline.py" # ===================== 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 diff --git a/tests/test_ntfy_integration.py b/tests/test_ntfy_integration.py index 5205930..d21acab 100644 --- a/tests/test_ntfy_integration.py +++ b/tests/test_ntfy_integration.py @@ -8,11 +8,11 @@ import urllib.request class TestNtfyTopics: - def test_cc_topic_exists_and_writable(self): - """Verify C&C topic exists and accepts messages.""" - from engine.config import NTFY_CC_TOPIC + def test_cc_cmd_topic_exists_and_writable(self): + """Verify C&C CMD topic exists and accepts messages.""" + from engine.config import NTFY_CC_CMD_TOPIC - topic_url = NTFY_CC_TOPIC.replace("/json", "") + topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "") test_message = f"test_{int(time.time())}" req = urllib.request.Request( @@ -29,7 +29,30 @@ class TestNtfyTopics: with urllib.request.urlopen(req, timeout=10) as resp: assert resp.status == 200 except Exception as e: - raise AssertionError(f"Failed to write to C&C topic: {e}") from e + raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e + + def test_cc_resp_topic_exists_and_writable(self): + """Verify C&C RESP topic exists and accepts messages.""" + from engine.config import NTFY_CC_RESP_TOPIC + + topic_url = NTFY_CC_RESP_TOPIC.replace("/json", "") + test_message = f"test_{int(time.time())}" + + req = urllib.request.Request( + topic_url, + data=test_message.encode("utf-8"), + headers={ + "User-Agent": "mainline-test/0.1", + "Content-Type": "text/plain", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=10) as resp: + assert resp.status == 200 + except Exception as e: + raise AssertionError(f"Failed to write to C&C RESP topic: {e}") from e def test_message_topic_exists_and_writable(self): """Verify message topic exists and accepts messages.""" @@ -54,12 +77,12 @@ class TestNtfyTopics: except Exception as e: raise AssertionError(f"Failed to write to message topic: {e}") from e - def test_cc_topic_readable(self): - """Verify we can read messages from C&C topic.""" - from engine.config import NTFY_CC_TOPIC + def test_cc_cmd_topic_readable(self): + """Verify we can read messages from C&C CMD topic.""" + from engine.config import NTFY_CC_CMD_TOPIC test_message = f"integration_test_{int(time.time())}" - topic_url = NTFY_CC_TOPIC.replace("/json", "") + topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "") req = urllib.request.Request( topic_url, @@ -74,11 +97,11 @@ class TestNtfyTopics: try: urllib.request.urlopen(req, timeout=10) except Exception as e: - raise AssertionError(f"Failed to write to C&C topic: {e}") from e + raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e time.sleep(1) - poll_url = f"{NTFY_CC_TOPIC}?poll=1&limit=1" + poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1" req = urllib.request.Request( poll_url, headers={"User-Agent": "mainline-test/0.1"}, @@ -91,11 +114,14 @@ class TestNtfyTopics: data = json.loads(body.split("\n")[0]) assert isinstance(data, dict) except Exception as e: - raise AssertionError(f"Failed to read from C&C topic: {e}") from e + raise AssertionError(f"Failed to read from C&C CMD topic: {e}") from e def test_topics_are_different(self): - """Verify C&C and message topics are different.""" - from engine.config import NTFY_CC_TOPIC, NTFY_TOPIC + """Verify C&C CMD/RESP and message topics are different.""" + from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC - assert NTFY_CC_TOPIC != NTFY_TOPIC - assert "_cc" in NTFY_CC_TOPIC + assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC + assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC + assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC + assert "_cc_cmd" in NTFY_CC_CMD_TOPIC + assert "_cc_resp" in NTFY_CC_RESP_TOPIC