Compare commits
16 Commits
effects_pl
...
0f7203e4e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f7203e4e0 | |||
| ba050ada24 | |||
| d7b044ceae | |||
| ac1306373d | |||
| 2650f7245e | |||
| b1f2b9d2be | |||
| c08a7d3cb0 | |||
| d5a3edba97 | |||
| fb35458718 | |||
| 15de46722a | |||
| 35e5c8d38b | |||
| cdc8094de2 | |||
| f170143939 | |||
| 19fb4bc4fe | |||
| ae10fd78ca | |||
| 4afab642f7 |
194
AGENTS.md
194
AGENTS.md
@@ -16,43 +16,32 @@ This project uses:
|
|||||||
mise run install
|
mise run install
|
||||||
|
|
||||||
# Or equivalently:
|
# Or equivalently:
|
||||||
uv sync
|
uv sync --all-extras # includes mic support
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
mise run test # Run tests
|
||||||
mise run test # Run tests
|
mise run test-v # Run tests verbose
|
||||||
mise run test-v # Run tests verbose
|
mise run test-cov # Run tests with coverage report
|
||||||
mise run test-cov # Run tests with coverage report
|
mise run test-browser # Run e2e browser tests (requires playwright)
|
||||||
mise run lint # Run ruff linter
|
mise run lint # Run ruff linter
|
||||||
mise run lint-fix # Run ruff with auto-fix
|
mise run lint-fix # Run ruff with auto-fix
|
||||||
mise run format # Run ruff formatter
|
mise run format # Run ruff formatter
|
||||||
mise run ci # Full CI pipeline
|
mise run ci # Full CI pipeline (topics-init + lint + test-cov)
|
||||||
|
```
|
||||||
|
|
||||||
# Runtime
|
### Runtime Commands
|
||||||
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)
|
```bash
|
||||||
mise run daemon # Start mainline in background
|
mise run run # Run mainline (terminal)
|
||||||
mise run daemon-stop # Stop daemon
|
mise run run-poetry # Run with poetry feed
|
||||||
mise run daemon-restart # Restart daemon
|
mise run run-firehose # Run in firehose mode
|
||||||
|
mise run run-websocket # Run with WebSocket display only
|
||||||
# Command & Control
|
mise run run-both # Run with both terminal and WebSocket
|
||||||
mise run cmd # Interactive CLI
|
mise run run-client # Run both + open browser
|
||||||
mise run cmd "/cmd" # Send single command
|
mise run cmd # Run C&C command interface
|
||||||
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
|
## Git Hooks
|
||||||
@@ -130,138 +119,25 @@ The project uses pytest with strict marker enforcement. Test configuration is in
|
|||||||
|
|
||||||
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
||||||
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||||
- **controller.py** coordinates ntfy/mic monitoring
|
- **controller.py** coordinates ntfy/mic monitoring and event publishing
|
||||||
|
- **effects/** - plugin architecture with performance monitoring
|
||||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||||
- **display.py** provides swappable display backends (TerminalDisplay, NullDisplay)
|
|
||||||
|
|
||||||
## Operating Modes
|
### Display System
|
||||||
|
|
||||||
Mainline can run in two modes:
|
- **Display abstraction** (`engine/display.py`): swap display backends via the Display protocol
|
||||||
|
- `TerminalDisplay` - ANSI terminal output
|
||||||
|
- `WebSocketDisplay` - broadcasts to web clients via WebSocket
|
||||||
|
- `MultiDisplay` - forwards to multiple displays simultaneously
|
||||||
|
|
||||||
### 1. Standalone Mode (Original)
|
- **WebSocket display** (`engine/websocket_display.py`): real-time frame broadcasting to web browsers
|
||||||
Run directly as a terminal application with interactive pickers:
|
- WebSocket server on port 8765
|
||||||
```bash
|
- HTTP server on port 8766 (serves HTML client)
|
||||||
mise run run # news stream
|
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
||||||
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)
|
### Command & Control
|
||||||
|
|
||||||
The recommended approach for persistent displays:
|
- C&C uses separate ntfy topics for commands and responses
|
||||||
|
- `NTFY_CC_CMD_TOPIC` - commands from cmdline.py
|
||||||
```bash
|
- `NTFY_CC_RESP_TOPIC` - responses back to cmdline.py
|
||||||
# Start the daemon (headless rendering)
|
- Effects controller handles `/effects` commands (list, on/off, intensity, reorder, stats)
|
||||||
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 <name> on - Enable an effect
|
|
||||||
/effects <name> off - Disable an effect
|
|
||||||
/effects <name> 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
|
|
||||||
|
|
||||||
The effects system is implemented as a plugin architecture in `engine/effects/`.
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
| Module | Purpose |
|
|
||||||
|--------|---------|
|
|
||||||
| `effects/types.py` | `EffectConfig`, `EffectContext` dataclasses and `EffectPlugin` protocol |
|
|
||||||
| `effects/registry.py` | Plugin discovery and management (`EffectRegistry`) |
|
|
||||||
| `effects/chain.py` | Ordered pipeline execution (`EffectChain`) |
|
|
||||||
| `effects_plugins/*.py` | Externalized effect plugins |
|
|
||||||
|
|
||||||
### Creating a New Effect
|
|
||||||
|
|
||||||
Create a file in `effects_plugins/` with a class ending in `Effect`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.effects.types import EffectConfig, EffectContext
|
|
||||||
|
|
||||||
class MyEffect:
|
|
||||||
name = "myeffect"
|
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
|
||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
|
||||||
# Process buffer and return modified buffer
|
|
||||||
return buf
|
|
||||||
|
|
||||||
def configure(self, config: EffectConfig) -> None:
|
|
||||||
self.config = config
|
|
||||||
```
|
|
||||||
|
|
||||||
### NTFY Commands
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
The cmdline tool polls the response topic for the daemon's reply.
|
|
||||||
|
|
||||||
## Conventional Commits
|
|
||||||
|
|
||||||
Commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>(<scope>): <description>
|
|
||||||
|
|
||||||
[optional body]
|
|
||||||
|
|
||||||
[optional footer(s)]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Types
|
|
||||||
|
|
||||||
- `feat`: A new feature
|
|
||||||
- `fix`: A bug fix
|
|
||||||
- `docs`: Documentation only changes
|
|
||||||
- `style`: Changes that don't affect code meaning (formatting)
|
|
||||||
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
|
||||||
- `test`: Adding or updating tests
|
|
||||||
- `chore`: Changes to build process, dependencies, etc.
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
```
|
|
||||||
feat(effects): add plugin architecture for visual effects
|
|
||||||
fix(layers): resolve glitch effect not applying on empty buffer
|
|
||||||
docs(AGENTS.md): add effects plugin system documentation
|
|
||||||
test(effects): add tests for EffectChain pipeline ordering
|
|
||||||
```
|
|
||||||
294
README.md
294
README.md
@@ -6,65 +6,45 @@ A full-screen terminal news ticker that renders live global headlines in large O
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Run
|
## Using
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 mainline.py # news stream
|
python3 mainline.py # news stream
|
||||||
python3 mainline.py --poetry # literary consciousness mode
|
python3 mainline.py --poetry # literary consciousness mode
|
||||||
python3 mainline.py -p # same
|
python3 mainline.py -p # same
|
||||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||||
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
python3 mainline.py --display websocket # web browser display only
|
||||||
|
python3 mainline.py --display both # terminal + web browser
|
||||||
python3 mainline.py --no-font-picker # skip interactive font picker
|
python3 mainline.py --no-font-picker # skip interactive font picker
|
||||||
python3 mainline.py --font-file path.otf # use a specific font file
|
python3 mainline.py --font-file path.otf # use a specific font file
|
||||||
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
||||||
python3 mainline.py --font-index 1 # select face index within a collection
|
python3 mainline.py --font-index 1 # select face index within a collection
|
||||||
```
|
```
|
||||||
|
|
||||||
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache.
|
Or with uv:
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Daemon Mode (Recommended for Long-Running)
|
|
||||||
|
|
||||||
For persistent displays (e.g., always-on terminal), use daemon mode with command-and-control over ntfy:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start the daemon (runs in background, auto-warms ntfy topics)
|
uv run mainline.py
|
||||||
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
|
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
||||||
|
|
||||||
- **Topics**: Uses separate ntfy topics for serial-like communication:
|
### Command & Control (C&C)
|
||||||
- `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
|
Control mainline remotely using `cmdline.py`:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
/effects list - List all effects and status
|
uv run cmdline.py # Interactive TUI
|
||||||
/effects <name> on - Enable an effect
|
uv run cmdline.py /effects list # List all effects
|
||||||
/effects <name> off - Disable an effect
|
uv run cmdline.py /effects stats # Show performance stats
|
||||||
/effects <name> intensity 0.5 - Set effect intensity (0.0-1.0)
|
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
|
||||||
/effects reorder noise,fade,glitch,firehose - Reorder pipeline
|
|
||||||
/effects stats - Show performance statistics
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
|
||||||
|
|
||||||
## Config
|
### Config
|
||||||
|
|
||||||
All constants live in `engine/config.py`:
|
All constants live in `engine/config.py`:
|
||||||
|
|
||||||
@@ -73,98 +53,49 @@ All constants live in `engine/config.py`:
|
|||||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||||
|
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
|
||||||
|
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
|
||||||
|
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
|
||||||
|
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
|
||||||
|
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||||
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
|
||||||
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
||||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
|
||||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||||
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
| `SSAA` | `4` | Super-sampling factor |
|
||||||
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
||||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
|
|
||||||
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON endpoint to poll |
|
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
||||||
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
|
|
||||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
|
||||||
|
|
||||||
---
|
### Display Modes
|
||||||
|
|
||||||
## Fonts
|
Mainline supports multiple display backends:
|
||||||
|
|
||||||
A `fonts/` directory is bundled with demo faces (AlphatronDemo, CSBishopDrawn, CyberformDemo, KATA, Microbots, Neoform, Pixel Sparta, Robocops, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
||||||
|
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
||||||
|
- **Both** (`--display both`): Terminal + WebSocket simultaneously
|
||||||
|
|
||||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
||||||
|
|
||||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
### Feeds
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
|
||||||
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
|
||||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
|
||||||
- The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
|
|
||||||
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
|
||||||
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
|
||||||
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
|
||||||
- An ntfy.sh poller runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
|
|
||||||
|
|
||||||
```
|
|
||||||
engine/
|
|
||||||
__init__.py package marker
|
|
||||||
app.py main(), font picker TUI, boot sequence, signal handler
|
|
||||||
config.py constants, CLI flags, glyph tables
|
|
||||||
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
|
|
||||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
|
||||||
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
|
|
||||||
scroll.py stream() frame loop + message rendering
|
|
||||||
viewport.py terminal dimension tracking (tw/th)
|
|
||||||
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
|
|
||||||
controller.py coordinates ntfy/mic monitoring and event publishing
|
|
||||||
emitters.py background emitters for ntfy and mic
|
|
||||||
types.py type definitions and dataclasses
|
|
||||||
```
|
|
||||||
|
|
||||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feeds
|
|
||||||
|
|
||||||
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` → `FEEDS`.
|
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` → `FEEDS`.
|
||||||
|
|
||||||
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py` → `POETRY_SOURCES`.
|
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py` → `POETRY_SOURCES`.
|
||||||
|
|
||||||
---
|
### Fonts
|
||||||
|
|
||||||
## ntfy.sh Integration
|
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
|
||||||
|
|
||||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
|
||||||
|
|
||||||
|
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
|
||||||
|
|
||||||
|
### ntfy.sh
|
||||||
|
|
||||||
|
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen.
|
||||||
|
|
||||||
To push a message:
|
To push a message:
|
||||||
|
|
||||||
@@ -172,44 +103,127 @@ To push a message:
|
|||||||
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
||||||
```
|
```
|
||||||
|
|
||||||
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic. The `NtfyPoller` class is fully standalone and can be reused by other visualizers:
|
---
|
||||||
|
|
||||||
```python
|
## Internals
|
||||||
from engine.ntfy import NtfyPoller
|
|
||||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
### How it works
|
||||||
poller.start()
|
|
||||||
# in render loop:
|
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection
|
||||||
msg = poller.get_active_message() # returns (title, body, timestamp) or None
|
- Feeds are fetched and filtered on startup; results are cached for fast restarts
|
||||||
|
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters
|
||||||
|
- The ticker uses a sweeping white-hot → deep green gradient
|
||||||
|
- Subject-region detection triggers Google Translate and font swap for non-Latin scripts
|
||||||
|
- The mic stream runs in a background thread, feeding RMS dB into glitch probability
|
||||||
|
- The viewport scrolls through pre-rendered blocks with fade zones
|
||||||
|
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
engine/
|
||||||
|
__init__.py package marker
|
||||||
|
app.py main(), font picker TUI, boot sequence, C&C poller
|
||||||
|
config.py constants, CLI flags, glyph tables
|
||||||
|
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
|
||||||
|
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||||
|
effects/ plugin architecture for visual effects
|
||||||
|
controller.py handles /effects commands
|
||||||
|
chain.py effect pipeline chaining
|
||||||
|
registry.py effect registration and lookup
|
||||||
|
performance.py performance monitoring
|
||||||
|
fetch.py RSS/Gutenberg fetching + cache
|
||||||
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
|
scroll.py stream() frame loop + message rendering
|
||||||
|
viewport.py terminal dimension tracking
|
||||||
|
frame.py scroll step calculation, timing
|
||||||
|
layers.py ticker zone, firehose, message overlay
|
||||||
|
eventbus.py thread-safe event publishing
|
||||||
|
events.py event types and definitions
|
||||||
|
controller.py coordinates ntfy/mic monitoring
|
||||||
|
emitters.py background emitters
|
||||||
|
types.py type definitions
|
||||||
|
display.py Display protocol (Terminal, WebSocket, Multi)
|
||||||
|
websocket_display.py WebSocket server for browser clients
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ideas / Future
|
## Development
|
||||||
|
|
||||||
### Performance
|
### Setup
|
||||||
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
|
||||||
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
|
||||||
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
|
||||||
|
|
||||||
### Graphics
|
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
||||||
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
|
||||||
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
|
||||||
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
|
||||||
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
|
||||||
|
|
||||||
### Cyberpunk Vibes
|
```bash
|
||||||
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
uv sync # minimal (no mic)
|
||||||
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
uv sync --all-extras # with mic support
|
||||||
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
uv sync --all-extras --group dev # full dev environment
|
||||||
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
|
```
|
||||||
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
|
|
||||||
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
|
|
||||||
|
|
||||||
### Extensibility
|
### Tasks
|
||||||
- **serve.py** — HTTP server that imports `engine.render` and `engine.fetch` directly to stream 1-bit bitmaps to an ESP32 display
|
|
||||||
- **Rust port** — `ntfy.py` and `render.py` are the natural first targets; clear module boundaries make incremental porting viable
|
With [mise](https://mise.jdx.dev/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run test # run test suite
|
||||||
|
mise run test-cov # run with coverage report
|
||||||
|
mise run lint # ruff check
|
||||||
|
mise run lint-fix # ruff check --fix
|
||||||
|
mise run format # ruff format
|
||||||
|
|
||||||
|
mise run run # terminal display
|
||||||
|
mise run run-websocket # web display only
|
||||||
|
mise run run-both # terminal + web
|
||||||
|
mise run run-client # both + open browser
|
||||||
|
|
||||||
|
mise run cmd # C&C command interface
|
||||||
|
mise run cmd-stats # watch effects stats
|
||||||
|
mise run topics-init # initialize ntfy topics
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
uv run pytest --cov=engine --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ruff check engine/ mainline.py
|
||||||
|
uv run ruff format engine/ mainline.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-commit hooks run lint automatically via `hk`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.9+.*
|
## Roadmap
|
||||||
# test
|
|
||||||
|
### Performance
|
||||||
|
- Concurrent feed fetching with ThreadPoolExecutor
|
||||||
|
- Background feed refresh daemon
|
||||||
|
- Translation pre-fetch during boot
|
||||||
|
|
||||||
|
### Graphics
|
||||||
|
- Matrix rain katakana underlay
|
||||||
|
- CRT scanline simulation
|
||||||
|
- Sixel/iTerm2 inline images
|
||||||
|
- Parallax secondary column
|
||||||
|
|
||||||
|
### Cyberpunk Vibes
|
||||||
|
- Keyword watch list with strobe effects
|
||||||
|
- Breaking interrupt with synthesized audio
|
||||||
|
- Live data overlay (BTC, ISS position)
|
||||||
|
- Theme switcher (amber, ice, red)
|
||||||
|
- Persona modes (surveillance, oracle, underground)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
||||||
366
client/index.html
Normal file
366
client/index.html
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mainline Terminal</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #ccc;
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
body.fullscreen {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body.fullscreen #controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid #333;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
body.fullscreen canvas {
|
||||||
|
border: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
#controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#controls button {
|
||||||
|
background: #333;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#controls button:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
#controls input {
|
||||||
|
width: 60px;
|
||||||
|
background: #222;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
#status.connected {
|
||||||
|
color: #4f4;
|
||||||
|
}
|
||||||
|
#status.disconnected {
|
||||||
|
color: #f44;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="container">
|
||||||
|
<canvas id="terminal"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="controls">
|
||||||
|
<label>Cols: <input type="number" id="cols" value="80" min="20" max="200"></label>
|
||||||
|
<label>Rows: <input type="number" id="rows" value="24" min="10" max="60"></label>
|
||||||
|
<button id="apply">Apply</button>
|
||||||
|
<button id="fullscreen">Fullscreen</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="disconnected">Connecting...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('terminal');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const colsInput = document.getElementById('cols');
|
||||||
|
const rowsInput = document.getElementById('rows');
|
||||||
|
const applyBtn = document.getElementById('apply');
|
||||||
|
const fullscreenBtn = document.getElementById('fullscreen');
|
||||||
|
|
||||||
|
const CHAR_WIDTH = 9;
|
||||||
|
const CHAR_HEIGHT = 16;
|
||||||
|
|
||||||
|
const ANSI_COLORS = {
|
||||||
|
0: '#000000', 1: '#cd3131', 2: '#0dbc79', 3: '#e5e510',
|
||||||
|
4: '#2472c8', 5: '#bc3fbc', 6: '#11a8cd', 7: '#e5e5e5',
|
||||||
|
8: '#666666', 9: '#f14c4c', 10: '#23d18b', 11: '#f5f543',
|
||||||
|
12: '#3b8eea', 13: '#d670d6', 14: '#29b8db', 15: '#ffffff',
|
||||||
|
};
|
||||||
|
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
canvas.width = cols * CHAR_WIDTH;
|
||||||
|
canvas.height = rows * CHAR_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnsi(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const tokens = [];
|
||||||
|
let currentText = '';
|
||||||
|
let fg = '#cccccc';
|
||||||
|
let bg = '#000000';
|
||||||
|
let bold = false;
|
||||||
|
let i = 0;
|
||||||
|
let inEscape = false;
|
||||||
|
let escapeCode = '';
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
const char = text[i];
|
||||||
|
|
||||||
|
if (inEscape) {
|
||||||
|
if (char >= '0' && char <= '9' || char === ';' || char === '[') {
|
||||||
|
escapeCode += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === 'm') {
|
||||||
|
const codes = escapeCode.replace('\x1b[', '').split(';');
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
const num = parseInt(code) || 0;
|
||||||
|
|
||||||
|
if (num === 0) {
|
||||||
|
fg = '#cccccc';
|
||||||
|
bg = '#000000';
|
||||||
|
bold = false;
|
||||||
|
} else if (num === 1) {
|
||||||
|
bold = true;
|
||||||
|
} else if (num === 22) {
|
||||||
|
bold = false;
|
||||||
|
} else if (num === 39) {
|
||||||
|
fg = '#cccccc';
|
||||||
|
} else if (num === 49) {
|
||||||
|
bg = '#000000';
|
||||||
|
} else if (num >= 30 && num <= 37) {
|
||||||
|
fg = ANSI_COLORS[num - 30 + (bold ? 8 : 0)] || '#cccccc';
|
||||||
|
} else if (num >= 40 && num <= 47) {
|
||||||
|
bg = ANSI_COLORS[num - 40] || '#000000';
|
||||||
|
} else if (num >= 90 && num <= 97) {
|
||||||
|
fg = ANSI_COLORS[num - 90 + 8] || '#cccccc';
|
||||||
|
} else if (num >= 100 && num <= 107) {
|
||||||
|
bg = ANSI_COLORS[num - 100 + 8] || '#000000';
|
||||||
|
} else if (num >= 1 && num <= 256) {
|
||||||
|
// 256 colors
|
||||||
|
if (num < 16) {
|
||||||
|
fg = ANSI_COLORS[num] || '#cccccc';
|
||||||
|
} else if (num < 232) {
|
||||||
|
const c = num - 16;
|
||||||
|
const r = Math.floor(c / 36) * 51;
|
||||||
|
const g = Math.floor((c % 36) / 6) * 51;
|
||||||
|
const b = (c % 6) * 51;
|
||||||
|
fg = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
||||||
|
} else {
|
||||||
|
const gray = (num - 232) * 10 + 8;
|
||||||
|
fg = `#${gray.toString(16).repeat(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
currentText = '';
|
||||||
|
}
|
||||||
|
inEscape = false;
|
||||||
|
escapeCode = '';
|
||||||
|
}
|
||||||
|
} else if (char === '\x1b' && text[i + 1] === '[') {
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
currentText = '';
|
||||||
|
}
|
||||||
|
inEscape = true;
|
||||||
|
escapeCode = '';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
currentText += char;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLine(text, x, y, lineHeight) {
|
||||||
|
const tokens = parseAnsi(text);
|
||||||
|
let xOffset = x;
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.text) {
|
||||||
|
if (token.bold) {
|
||||||
|
ctx.font = 'bold 16px monospace';
|
||||||
|
} else {
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = ctx.measureText(token.text);
|
||||||
|
|
||||||
|
if (token.bg !== '#000000') {
|
||||||
|
ctx.fillStyle = token.bg;
|
||||||
|
ctx.fillRect(xOffset, y - 2, metrics.width + 1, lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = token.fg;
|
||||||
|
ctx.fillText(token.text, xOffset, y);
|
||||||
|
xOffset += metrics.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.hostname}:8765`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
status.textContent = 'Connected';
|
||||||
|
status.className = 'connected';
|
||||||
|
sendSize();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
status.textContent = 'Disconnected - Reconnecting...';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
setTimeout(connect, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
status.textContent = 'Connection error';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'frame') {
|
||||||
|
cols = data.width || 80;
|
||||||
|
rows = data.height || 24;
|
||||||
|
colsInput.value = cols;
|
||||||
|
rowsInput.value = rows;
|
||||||
|
resizeCanvas();
|
||||||
|
render(data.lines || []);
|
||||||
|
} else if (data.type === 'clear') {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSize() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
width: parseInt(colsInput.value),
|
||||||
|
height: parseInt(rowsInput.value)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(lines) {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
const lineHeight = CHAR_HEIGHT;
|
||||||
|
const maxLines = Math.min(lines.length, rows);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLines; i++) {
|
||||||
|
const line = lines[i] || '';
|
||||||
|
renderLine(line, 0, i * lineHeight, lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateViewportSize() {
|
||||||
|
const isFullscreen = document.fullscreenElement !== null;
|
||||||
|
const padding = isFullscreen ? 0 : 40;
|
||||||
|
const controlsHeight = isFullscreen ? 0 : 60;
|
||||||
|
const availableWidth = window.innerWidth - padding;
|
||||||
|
const availableHeight = window.innerHeight - controlsHeight;
|
||||||
|
cols = Math.max(20, Math.floor(availableWidth / CHAR_WIDTH));
|
||||||
|
rows = Math.max(10, Math.floor(availableHeight / CHAR_HEIGHT));
|
||||||
|
colsInput.value = cols;
|
||||||
|
rowsInput.value = rows;
|
||||||
|
resizeCanvas();
|
||||||
|
console.log('Fullscreen:', isFullscreen, 'Size:', cols, 'x', rows);
|
||||||
|
sendSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBtn.addEventListener('click', () => {
|
||||||
|
cols = parseInt(colsInput.value);
|
||||||
|
rows = parseInt(rowsInput.value);
|
||||||
|
resizeCanvas();
|
||||||
|
sendSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
fullscreenBtn.addEventListener('click', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.body.classList.add('fullscreen');
|
||||||
|
document.documentElement.requestFullscreen().then(() => {
|
||||||
|
calculateViewportSize();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().then(() => {
|
||||||
|
calculateViewportSize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.body.classList.remove('fullscreen');
|
||||||
|
calculateViewportSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
calculateViewportSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
resizeCanvas();
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Command-line utility for interacting with mainline via ntfy.
|
Command-line utility for interacting with mainline via ntfy.
|
||||||
|
|
||||||
@@ -20,6 +21,11 @@ C&C works like a serial port:
|
|||||||
3. Cmdline polls for response
|
3. Cmdline polls for response
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["FORCE_COLOR"] = "1"
|
||||||
|
os.environ["TERM"] = "xterm-256color"
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
145
docs/superpowers/specs/2026-03-15-readme-update-design.md
Normal file
145
docs/superpowers/specs/2026-03-15-readme-update-design.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# README Update Design — 2026-03-15
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Restructure and expand `README.md` to:
|
||||||
|
1. Align with the current codebase (Python 3.10+, uv/mise/pytest/ruff toolchain, 6 new fonts)
|
||||||
|
2. Add extensibility-focused content (`Extending` section)
|
||||||
|
3. Add developer workflow coverage (`Development` section)
|
||||||
|
4. Improve navigability via top-level grouping (Approach C)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
# MAINLINE
|
||||||
|
> tagline + description
|
||||||
|
|
||||||
|
## Using
|
||||||
|
### Run
|
||||||
|
### Config
|
||||||
|
### Feeds
|
||||||
|
### Fonts
|
||||||
|
### ntfy.sh
|
||||||
|
|
||||||
|
## Internals
|
||||||
|
### How it works
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
### NtfyPoller
|
||||||
|
### MicMonitor
|
||||||
|
### Render pipeline
|
||||||
|
|
||||||
|
## Development
|
||||||
|
### Setup
|
||||||
|
### Tasks
|
||||||
|
### Testing
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
---
|
||||||
|
*footer*
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section-by-section design
|
||||||
|
|
||||||
|
### Using
|
||||||
|
|
||||||
|
All existing content preserved verbatim. Two changes:
|
||||||
|
- **Run**: add `uv run mainline.py` as an alternative invocation; expand bootstrap note to mention `uv sync` / `uv sync --all-extras`
|
||||||
|
- **ntfy.sh**: remove `NtfyPoller` reuse code example (moves to Extending); keep push instructions and topic config
|
||||||
|
|
||||||
|
Subsections moved into Using (currently standalone):
|
||||||
|
- `Feeds` — it's configuration, not a concept
|
||||||
|
- `ntfy.sh` (usage half)
|
||||||
|
|
||||||
|
### Internals
|
||||||
|
|
||||||
|
All existing content preserved verbatim. One change:
|
||||||
|
- **Architecture**: append `tests/` directory listing to the module tree
|
||||||
|
|
||||||
|
### Extending
|
||||||
|
|
||||||
|
Entirely new section. Three subsections:
|
||||||
|
|
||||||
|
**NtfyPoller**
|
||||||
|
- Minimal working import + usage example
|
||||||
|
- Note: stdlib only dependencies
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||||
|
poller.start()
|
||||||
|
|
||||||
|
# in your render loop:
|
||||||
|
msg = poller.get_active_message() # → (title, body, timestamp) or None
|
||||||
|
if msg:
|
||||||
|
title, body, ts = msg
|
||||||
|
render_my_message(title, body) # visualizer-specific
|
||||||
|
```
|
||||||
|
|
||||||
|
**MicMonitor**
|
||||||
|
- Minimal working import + usage example
|
||||||
|
- Note: sounddevice/numpy optional, degrades gracefully
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
mic = MicMonitor(threshold_db=50)
|
||||||
|
if mic.start(): # returns False if sounddevice unavailable
|
||||||
|
excess = mic.excess # dB above threshold, clamped to 0
|
||||||
|
db = mic.db # raw RMS dB level
|
||||||
|
```
|
||||||
|
|
||||||
|
**Render pipeline**
|
||||||
|
- Brief prose about `engine.render` as importable pipeline
|
||||||
|
- Minimal sketch of serve.py / ESP32 usage pattern
|
||||||
|
- Reference to `Mainline Renderer + ntfy Message Queue for ESP32.md`
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Entirely new section. Four subsections:
|
||||||
|
|
||||||
|
**Setup**
|
||||||
|
- Hard requirements: Python 3.10+, uv
|
||||||
|
- `uv sync` / `uv sync --all-extras` / `uv sync --group dev`
|
||||||
|
|
||||||
|
**Tasks** (via mise)
|
||||||
|
- `mise run test`, `test-cov`, `lint`, `lint-fix`, `format`, `run`, `run-poetry`, `run-firehose`
|
||||||
|
|
||||||
|
**Testing**
|
||||||
|
- Tests in `tests/` covering config, filter, mic, ntfy, sources, terminal
|
||||||
|
- `uv run pytest` and `uv run pytest --cov=engine --cov-report=term-missing`
|
||||||
|
|
||||||
|
**Linting**
|
||||||
|
- `uv run ruff check` and `uv run ruff format`
|
||||||
|
- Note: pre-commit hooks run lint via `hk`
|
||||||
|
|
||||||
|
### Roadmap
|
||||||
|
|
||||||
|
Existing `## Ideas / Future` content preserved verbatim. Only change: rename heading to `## Roadmap`.
|
||||||
|
|
||||||
|
### Footer
|
||||||
|
|
||||||
|
Update `Python 3.9+` → `Python 3.10+`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `README.md` — restructured and expanded as above
|
||||||
|
- No other files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is not changing
|
||||||
|
|
||||||
|
- All existing prose, examples, and config table values — preserved verbatim where retained
|
||||||
|
- The Ideas/Future content — kept intact under the new Roadmap heading
|
||||||
|
- The cyberpunk voice and terse style of the existing README
|
||||||
@@ -129,6 +129,10 @@ class Config:
|
|||||||
|
|
||||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||||
|
|
||||||
|
display: str = "terminal"
|
||||||
|
websocket: bool = False
|
||||||
|
websocket_port: int = 8765
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||||
"""Create Config from CLI arguments (or custom argv for testing)."""
|
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||||
@@ -168,6 +172,9 @@ class Config:
|
|||||||
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||||
script_fonts=_get_platform_font_paths(),
|
script_fonts=_get_platform_font_paths(),
|
||||||
|
display=_arg_value("--display", argv) or "terminal",
|
||||||
|
websocket="--websocket" in argv,
|
||||||
|
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,6 +236,11 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
|||||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
|
# ─── WEBSOCKET ─────────────────────────────────────────────
|
||||||
|
DISPLAY = _arg_value("--display", sys.argv) or "terminal"
|
||||||
|
WEBSOCKET = "--websocket" in sys.argv
|
||||||
|
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
||||||
|
|
||||||
|
|
||||||
def set_font_selection(font_path=None, font_index=None):
|
def set_font_selection(font_path=None, font_index=None):
|
||||||
"""Set runtime primary font selection."""
|
"""Set runtime primary font selection."""
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ from engine.events import EventType, StreamEvent
|
|||||||
from engine.mic import MicMonitor
|
from engine.mic import MicMonitor
|
||||||
from engine.ntfy import NtfyPoller
|
from engine.ntfy import NtfyPoller
|
||||||
from engine.scroll import stream
|
from engine.scroll import stream
|
||||||
|
from engine.websocket_display import WebSocketDisplay
|
||||||
|
|
||||||
|
|
||||||
|
def _get_display(config: Config):
|
||||||
|
"""Get the appropriate display based on config."""
|
||||||
|
if config.websocket:
|
||||||
|
ws = WebSocketDisplay(host="0.0.0.0", port=config.websocket_port)
|
||||||
|
ws.start_server()
|
||||||
|
ws.start_http_server()
|
||||||
|
return ws
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class StreamController:
|
class StreamController:
|
||||||
@@ -117,7 +128,10 @@ class StreamController:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
stream(items, self.ntfy, self.mic)
|
display = _get_display(self.config)
|
||||||
|
stream(items, self.ntfy, self.mic, display)
|
||||||
|
if display:
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
if self.event_bus:
|
if self.event_bus:
|
||||||
self.event_bus.publish(
|
self.event_bus.publish(
|
||||||
|
|||||||
@@ -100,3 +100,30 @@ class NullDisplay:
|
|||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MultiDisplay:
|
||||||
|
"""Display that forwards to multiple displays."""
|
||||||
|
|
||||||
|
def __init__(self, displays: list[Display]):
|
||||||
|
self.displays = displays
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
for d in self.displays:
|
||||||
|
d.init(width, height)
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.show(buffer)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.clear()
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.cleanup()
|
||||||
|
|||||||
264
engine/websocket_display.py
Normal file
264
engine/websocket_display.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
WebSocket display server - broadcasts frame buffer to connected web clients.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ws_display = WebSocketDisplay(host="0.0.0.0", port=8765)
|
||||||
|
ws_display.init(80, 24)
|
||||||
|
ws_display.show(["line1", "line2", ...])
|
||||||
|
ws_display.cleanup()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
websockets = None
|
||||||
|
|
||||||
|
|
||||||
|
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 WebSocketDisplay:
|
||||||
|
"""WebSocket display backend - broadcasts to HTML Canvas clients."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
port: int = 8765,
|
||||||
|
http_port: int = 8766,
|
||||||
|
):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.http_port = http_port
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
self._clients: set = set()
|
||||||
|
self._server_running = False
|
||||||
|
self._http_running = False
|
||||||
|
self._server_thread: threading.Thread | None = None
|
||||||
|
self._http_thread: threading.Thread | None = None
|
||||||
|
self._available = True
|
||||||
|
self._max_clients = 10
|
||||||
|
self._client_connected_callback = None
|
||||||
|
self._client_disconnected_callback = None
|
||||||
|
self._frame_delay = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets as _ws
|
||||||
|
|
||||||
|
self._available = _ws is not None
|
||||||
|
except ImportError:
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if WebSocket support is available."""
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
"""Initialize display with dimensions and start server."""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.start_server()
|
||||||
|
self.start_http_server()
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
"""Broadcast buffer to all connected clients."""
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
|
if self._clients:
|
||||||
|
frame_data = {
|
||||||
|
"type": "frame",
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"lines": buffer,
|
||||||
|
}
|
||||||
|
message = json.dumps(frame_data)
|
||||||
|
|
||||||
|
disconnected = set()
|
||||||
|
for client in list(self._clients):
|
||||||
|
try:
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
except Exception:
|
||||||
|
disconnected.add(client)
|
||||||
|
|
||||||
|
for client in disconnected:
|
||||||
|
self._clients.discard(client)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(client)
|
||||||
|
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Broadcast clear command to all clients."""
|
||||||
|
if self._clients:
|
||||||
|
clear_data = {"type": "clear"}
|
||||||
|
message = json.dumps(clear_data)
|
||||||
|
for client in list(self._clients):
|
||||||
|
try:
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Stop the servers."""
|
||||||
|
self.stop_server()
|
||||||
|
self.stop_http_server()
|
||||||
|
|
||||||
|
async def _websocket_handler(self, websocket):
|
||||||
|
"""Handle WebSocket connections."""
|
||||||
|
if len(self._clients) >= self._max_clients:
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._clients.add(websocket)
|
||||||
|
if self._client_connected_callback:
|
||||||
|
self._client_connected_callback(websocket)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for message in websocket:
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
if data.get("type") == "resize":
|
||||||
|
self.width = data.get("width", 80)
|
||||||
|
self.height = data.get("height", 24)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._clients.discard(websocket)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(websocket)
|
||||||
|
|
||||||
|
async def _run_websocket_server(self):
|
||||||
|
"""Run the WebSocket server."""
|
||||||
|
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
||||||
|
while self._server_running:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
async def _run_http_server(self):
|
||||||
|
"""Run simple HTTP server for the client."""
|
||||||
|
import os
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
|
||||||
|
client_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "client")
|
||||||
|
|
||||||
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=client_dir, **kwargs)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||||
|
while self._http_running:
|
||||||
|
httpd.handle_request()
|
||||||
|
|
||||||
|
def _run_async(self, coro):
|
||||||
|
"""Run coroutine in background."""
|
||||||
|
try:
|
||||||
|
asyncio.run(coro)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WebSocket async error: {e}")
|
||||||
|
|
||||||
|
def start_server(self):
|
||||||
|
"""Start the WebSocket server in a background thread."""
|
||||||
|
if not self._available:
|
||||||
|
return
|
||||||
|
if self._server_thread is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._server_running = True
|
||||||
|
self._server_thread = threading.Thread(
|
||||||
|
target=self._run_async, args=(self._run_websocket_server(),), daemon=True
|
||||||
|
)
|
||||||
|
self._server_thread.start()
|
||||||
|
|
||||||
|
def stop_server(self):
|
||||||
|
"""Stop the WebSocket server."""
|
||||||
|
self._server_running = False
|
||||||
|
self._server_thread = None
|
||||||
|
|
||||||
|
def start_http_server(self):
|
||||||
|
"""Start the HTTP server in a background thread."""
|
||||||
|
if not self._available:
|
||||||
|
return
|
||||||
|
if self._http_thread is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._http_running = True
|
||||||
|
|
||||||
|
self._http_running = True
|
||||||
|
self._http_thread = threading.Thread(
|
||||||
|
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
||||||
|
)
|
||||||
|
self._http_thread.start()
|
||||||
|
|
||||||
|
def stop_http_server(self):
|
||||||
|
"""Stop the HTTP server."""
|
||||||
|
self._http_running = False
|
||||||
|
self._http_thread = None
|
||||||
|
|
||||||
|
def client_count(self) -> int:
|
||||||
|
"""Return number of connected clients."""
|
||||||
|
return len(self._clients)
|
||||||
|
|
||||||
|
def get_ws_port(self) -> int:
|
||||||
|
"""Return WebSocket port."""
|
||||||
|
return self.port
|
||||||
|
|
||||||
|
def get_http_port(self) -> int:
|
||||||
|
"""Return HTTP port."""
|
||||||
|
return self.http_port
|
||||||
|
|
||||||
|
def set_frame_delay(self, delay: float) -> None:
|
||||||
|
"""Set delay between frames in seconds."""
|
||||||
|
self._frame_delay = delay
|
||||||
|
|
||||||
|
def get_frame_delay(self) -> float:
|
||||||
|
"""Get delay between frames."""
|
||||||
|
return self._frame_delay
|
||||||
|
|
||||||
|
def set_client_connected_callback(self, callback) -> None:
|
||||||
|
"""Set callback for client connections."""
|
||||||
|
self._client_connected_callback = callback
|
||||||
|
|
||||||
|
def set_client_disconnected_callback(self, callback) -> None:
|
||||||
|
"""Set callback for client disconnections."""
|
||||||
|
self._client_disconnected_callback = callback
|
||||||
45
mise.toml
45
mise.toml
@@ -5,40 +5,55 @@ pkl = "latest"
|
|||||||
|
|
||||||
[tasks]
|
[tasks]
|
||||||
# =====================
|
# =====================
|
||||||
# Development
|
# Testing
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
test = "uv run pytest"
|
test = "uv run pytest"
|
||||||
test-v = "uv run pytest -v"
|
test-v = { run = "uv run pytest -v", depends = ["sync-all"] }
|
||||||
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html", depends = ["sync-all"] }
|
||||||
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
test-cov-open = { run = "mise run test-cov && open htmlcov/index.html", depends = ["sync-all"] }
|
||||||
|
|
||||||
|
test-browser-install = { run = "uv run playwright install chromium", depends = ["sync-all"] }
|
||||||
|
test-browser = { run = "uv run pytest tests/e2e/", depends = ["test-browser-install"] }
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Linting & Formatting
|
||||||
|
# =====================
|
||||||
|
|
||||||
lint = "uv run ruff check engine/ mainline.py"
|
lint = "uv run ruff check engine/ mainline.py"
|
||||||
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||||
format = "uv run ruff format engine/ mainline.py"
|
format = "uv run ruff format engine/ mainline.py"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Runtime
|
# Runtime Modes
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
run = "uv run mainline.py"
|
run = "uv run mainline.py"
|
||||||
run-poetry = "uv run mainline.py --poetry"
|
run-poetry = "uv run mainline.py --poetry"
|
||||||
run-firehose = "uv run mainline.py --firehose"
|
run-firehose = "uv run mainline.py --firehose"
|
||||||
|
|
||||||
daemon = "nohup uv run mainline.py > /dev/null 2>&1 &"
|
run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] }
|
||||||
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
|
run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] }
|
||||||
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
run-client = { run = "mise run run-both & sleep 2 && $(open http://localhost:8766 2>/dev/null || xdg-open http://localhost:8766 2>/dev/null || echo 'Open http://localhost:8766 manually'); wait", depends = ["sync-all"] }
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Command & Control
|
# Command & Control
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
cmd = "uv run cmdline.py"
|
cmd = "uv run cmdline.py"
|
||||||
cmd-stats = "bash -c 'uv run cmdline.py -w \"/effects stats\"';:"
|
cmd-stats = { run = "uv run cmdline.py -w \"/effects stats\"", depends = ["sync-all"] }
|
||||||
|
|
||||||
# Initialize ntfy topics (warm up before first use - also done automatically by mainline)
|
# Initialize ntfy topics (warm up before first use)
|
||||||
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"
|
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"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Daemon
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
daemon = "nohup uv run mainline.py > nohup.out 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"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Environment
|
# Environment
|
||||||
# =====================
|
# =====================
|
||||||
@@ -46,21 +61,17 @@ topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_c
|
|||||||
sync = "uv sync"
|
sync = "uv sync"
|
||||||
sync-all = "uv sync --all-extras"
|
sync-all = "uv sync --all-extras"
|
||||||
install = "mise run sync"
|
install = "mise run sync"
|
||||||
install-dev = "mise run sync && uv sync --group dev"
|
install-dev = { run = "mise run sync-all && uv sync --group dev", depends = ["sync-all"] }
|
||||||
|
bootstrap = { run = "mise run sync-all && uv run mainline.py --help", depends = ["sync-all"] }
|
||||||
bootstrap = "uv sync && uv run mainline.py --help"
|
|
||||||
|
|
||||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
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"
|
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# CI/CD
|
# CI/CD
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
ci = "mise run topics-init && uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
||||||
ci-lint = "uv run ruff check engine/ mainline.py"
|
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Git Hooks (via hk)
|
# Git Hooks (via hk)
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ mic = [
|
|||||||
"sounddevice>=0.4.0",
|
"sounddevice>=0.4.0",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
]
|
]
|
||||||
|
websocket = [
|
||||||
|
"websockets>=12.0",
|
||||||
|
]
|
||||||
|
browser = [
|
||||||
|
"playwright>=1.40.0",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-cov>=4.1.0",
|
"pytest-cov>=4.1.0",
|
||||||
|
|||||||
133
tests/e2e/test_web_client.py
Normal file
133
tests/e2e/test_web_client.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for web client with headless browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socketserver
|
||||||
|
import threading
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
CLIENT_DIR = Path(__file__).parent.parent.parent / "client"
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||||
|
"""Threaded HTTP server for handling concurrent requests."""
|
||||||
|
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def http_server():
|
||||||
|
"""Start a local HTTP server for the client."""
|
||||||
|
os.chdir(CLIENT_DIR)
|
||||||
|
|
||||||
|
handler = SimpleHTTPRequestHandler
|
||||||
|
server = ThreadedHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
port = server.server_address[1]
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
yield f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebClient:
|
||||||
|
"""Tests for the web client using Playwright."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_browser(self):
|
||||||
|
"""Set up browser for tests."""
|
||||||
|
pytest.importorskip("playwright")
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
self.playwright = sync_playwright().start()
|
||||||
|
self.browser = self.playwright.chromium.launch(headless=True)
|
||||||
|
self.context = self.browser.new_context()
|
||||||
|
self.page = self.context.new_page()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
self.page.close()
|
||||||
|
self.context.close()
|
||||||
|
self.browser.close()
|
||||||
|
self.playwright.stop()
|
||||||
|
|
||||||
|
def test_client_loads(self, http_server):
|
||||||
|
"""Web client loads without errors."""
|
||||||
|
response = self.page.goto(http_server)
|
||||||
|
assert response.status == 200, f"Page load failed with status {response.status}"
|
||||||
|
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
content = self.page.content()
|
||||||
|
assert "<canvas" in content, "Canvas element not found in page"
|
||||||
|
|
||||||
|
canvas = self.page.locator("#terminal")
|
||||||
|
assert canvas.count() > 0, "Canvas not found"
|
||||||
|
|
||||||
|
def test_status_shows_connecting(self, http_server):
|
||||||
|
"""Status shows connecting initially."""
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
status = self.page.locator("#status")
|
||||||
|
assert status.count() > 0, "Status element not found"
|
||||||
|
|
||||||
|
def test_canvas_has_dimensions(self, http_server):
|
||||||
|
"""Canvas has correct dimensions after load."""
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
canvas = self.page.locator("#terminal")
|
||||||
|
assert canvas.count() > 0, "Canvas not found"
|
||||||
|
|
||||||
|
def test_no_console_errors_on_load(self, http_server):
|
||||||
|
"""No JavaScript errors on page load (websocket errors are expected without server)."""
|
||||||
|
js_errors = []
|
||||||
|
|
||||||
|
def handle_console(msg):
|
||||||
|
if msg.type == "error":
|
||||||
|
text = msg.text
|
||||||
|
if "WebSocket" not in text:
|
||||||
|
js_errors.append(text)
|
||||||
|
|
||||||
|
self.page.on("console", handle_console)
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
assert len(js_errors) == 0, f"JavaScript errors: {js_errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebClientProtocol:
|
||||||
|
"""Tests for WebSocket protocol handling in client."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_browser(self):
|
||||||
|
"""Set up browser for tests."""
|
||||||
|
pytest.importorskip("playwright")
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
self.playwright = sync_playwright().start()
|
||||||
|
self.browser = self.playwright.chromium.launch(headless=True)
|
||||||
|
self.context = self.browser.new_context()
|
||||||
|
self.page = self.context.new_page()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
self.page.close()
|
||||||
|
self.context.close()
|
||||||
|
self.browser.close()
|
||||||
|
self.playwright.stop()
|
||||||
|
|
||||||
|
def test_websocket_reconnection(self, http_server):
|
||||||
|
"""Client attempts reconnection on disconnect."""
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
status = self.page.locator("#status")
|
||||||
|
assert status.count() > 0, "Status element not found"
|
||||||
55
tests/test_app.py
Normal file
55
tests/test_app.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.app module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.app import _normalize_preview_rows
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizePreviewRows:
|
||||||
|
"""Tests for _normalize_preview_rows function."""
|
||||||
|
|
||||||
|
def test_empty_rows(self):
|
||||||
|
"""Empty input returns empty list."""
|
||||||
|
result = _normalize_preview_rows([])
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
def test_strips_left_padding(self):
|
||||||
|
"""Left padding is stripped."""
|
||||||
|
result = _normalize_preview_rows([" content", " more"])
|
||||||
|
assert all(not r.startswith(" ") for r in result)
|
||||||
|
|
||||||
|
def test_preserves_content(self):
|
||||||
|
"""Content is preserved."""
|
||||||
|
result = _normalize_preview_rows([" hello world "])
|
||||||
|
assert "hello world" in result[0]
|
||||||
|
|
||||||
|
def test_handles_all_empty_rows(self):
|
||||||
|
"""All empty rows returns single empty string."""
|
||||||
|
result = _normalize_preview_rows(["", " ", ""])
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppConstants:
|
||||||
|
"""Tests for app module constants."""
|
||||||
|
|
||||||
|
def test_title_defined(self):
|
||||||
|
"""TITLE is defined."""
|
||||||
|
from engine.app import TITLE
|
||||||
|
|
||||||
|
assert len(TITLE) > 0
|
||||||
|
|
||||||
|
def test_title_lines_are_strings(self):
|
||||||
|
"""TITLE contains string lines."""
|
||||||
|
from engine.app import TITLE
|
||||||
|
|
||||||
|
assert all(isinstance(line, str) for line in TITLE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppImports:
|
||||||
|
"""Tests for app module imports."""
|
||||||
|
|
||||||
|
def test_app_imports_without_error(self):
|
||||||
|
"""Module imports without error."""
|
||||||
|
from engine import app
|
||||||
|
|
||||||
|
assert app is not None
|
||||||
@@ -83,35 +83,3 @@ class TestStreamControllerCleanup:
|
|||||||
controller.cleanup()
|
controller.cleanup()
|
||||||
|
|
||||||
mock_mic_instance.stop.assert_called_once()
|
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()
|
|
||||||
|
|||||||
234
tests/test_fetch.py
Normal file
234
tests/test_fetch.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.fetch module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.fetch import (
|
||||||
|
_fetch_gutenberg,
|
||||||
|
fetch_all,
|
||||||
|
fetch_feed,
|
||||||
|
fetch_poetry,
|
||||||
|
load_cache,
|
||||||
|
save_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchFeed:
|
||||||
|
"""Tests for fetch_feed function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_fetch_success(self, mock_urlopen):
|
||||||
|
"""Successful feed fetch returns parsed feed."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"<rss>test</rss>"
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = fetch_feed("http://example.com/feed")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_fetch_network_error(self, mock_urlopen):
|
||||||
|
"""Network error returns None."""
|
||||||
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
result = fetch_feed("http://example.com/feed")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchAll:
|
||||||
|
"""Tests for fetch_all function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch.fetch_feed")
|
||||||
|
@patch("engine.fetch.strip_tags")
|
||||||
|
@patch("engine.fetch.skip")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_all_success(self, mock_boot, mock_skip, mock_strip, mock_fetch_feed):
|
||||||
|
"""Successful fetch returns items."""
|
||||||
|
mock_feed = MagicMock()
|
||||||
|
mock_feed.bozo = False
|
||||||
|
mock_feed.entries = [
|
||||||
|
{"title": "Headline 1", "published_parsed": (2024, 1, 1, 12, 0, 0)},
|
||||||
|
{"title": "Headline 2", "updated_parsed": (2024, 1, 2, 12, 0, 0)},
|
||||||
|
]
|
||||||
|
mock_fetch_feed.return_value = mock_feed
|
||||||
|
mock_skip.return_value = False
|
||||||
|
mock_strip.side_effect = lambda x: x
|
||||||
|
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
|
assert linked > 0
|
||||||
|
assert failed == 0
|
||||||
|
|
||||||
|
@patch("engine.fetch.fetch_feed")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_all_feed_error(self, mock_boot, mock_fetch_feed):
|
||||||
|
"""Feed error increments failed count."""
|
||||||
|
mock_fetch_feed.return_value = None
|
||||||
|
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
|
assert failed > 0
|
||||||
|
|
||||||
|
@patch("engine.fetch.fetch_feed")
|
||||||
|
@patch("engine.fetch.strip_tags")
|
||||||
|
@patch("engine.fetch.skip")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_all_skips_filtered(
|
||||||
|
self, mock_boot, mock_skip, mock_strip, mock_fetch_feed
|
||||||
|
):
|
||||||
|
"""Filtered headlines are skipped."""
|
||||||
|
mock_feed = MagicMock()
|
||||||
|
mock_feed.bozo = False
|
||||||
|
mock_feed.entries = [
|
||||||
|
{"title": "Sports scores"},
|
||||||
|
{"title": "Valid headline"},
|
||||||
|
]
|
||||||
|
mock_fetch_feed.return_value = mock_feed
|
||||||
|
mock_skip.side_effect = lambda x: x == "Sports scores"
|
||||||
|
mock_strip.side_effect = lambda x: x
|
||||||
|
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
|
assert any("Valid headline" in item[0] for item in items)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchGutenberg:
|
||||||
|
"""Tests for _fetch_gutenberg function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_success(self, mock_urlopen):
|
||||||
|
"""Successful gutenberg fetch returns items."""
|
||||||
|
text = """Project Gutenberg
|
||||||
|
|
||||||
|
*** START OF THE PROJECT GUTENBERG ***
|
||||||
|
This is a test poem with multiple lines
|
||||||
|
that should be parsed as a block.
|
||||||
|
|
||||||
|
Another stanza with more content here.
|
||||||
|
|
||||||
|
*** END OF THE PROJECT GUTENBERG ***
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = text.encode("utf-8")
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_network_error(self, mock_urlopen):
|
||||||
|
"""Network error returns empty list."""
|
||||||
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_skips_short_blocks(self, mock_urlopen):
|
||||||
|
"""Blocks shorter than 20 chars are skipped."""
|
||||||
|
text = """*** START OF THE ***
|
||||||
|
Short
|
||||||
|
*** END OF THE ***
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = text.encode("utf-8")
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_skips_all_caps_headers(self, mock_urlopen):
|
||||||
|
"""All-caps lines are skipped as headers."""
|
||||||
|
text = """*** START OF THE ***
|
||||||
|
THIS IS ALL CAPS HEADER
|
||||||
|
more content here
|
||||||
|
*** END OF THE ***
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = text.encode("utf-8")
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchPoetry:
|
||||||
|
"""Tests for fetch_poetry function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch._fetch_gutenberg")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_poetry_success(self, mock_boot, mock_fetch):
|
||||||
|
"""Successful poetry fetch returns items."""
|
||||||
|
mock_fetch.return_value = [
|
||||||
|
("Stanza 1 content here", "Test", ""),
|
||||||
|
("Stanza 2 content here", "Test", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
items, linked, failed = fetch_poetry()
|
||||||
|
|
||||||
|
assert linked > 0
|
||||||
|
assert failed == 0
|
||||||
|
|
||||||
|
@patch("engine.fetch._fetch_gutenberg")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_poetry_failure(self, mock_boot, mock_fetch):
|
||||||
|
"""Failed fetch increments failed count."""
|
||||||
|
mock_fetch.return_value = []
|
||||||
|
|
||||||
|
items, linked, failed = fetch_poetry()
|
||||||
|
|
||||||
|
assert failed > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCache:
|
||||||
|
"""Tests for cache functions."""
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_load_cache_success(self, mock_path):
|
||||||
|
"""Successful cache load returns items."""
|
||||||
|
mock_path.return_value.__str__ = MagicMock(return_value="/tmp/cache")
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_path.return_value.read_text.return_value = json.dumps(
|
||||||
|
{"items": [("title", "source", "time")]}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = load_cache()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_load_cache_missing_file(self, mock_path):
|
||||||
|
"""Missing cache file returns None."""
|
||||||
|
mock_path.return_value.exists.return_value = False
|
||||||
|
|
||||||
|
result = load_cache()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_load_cache_invalid_json(self, mock_path):
|
||||||
|
"""Invalid JSON returns None."""
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_path.return_value.read_text.side_effect = json.JSONDecodeError("", "", 0)
|
||||||
|
|
||||||
|
result = load_cache()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_save_cache_success(self, mock_path):
|
||||||
|
"""Save cache writes to file."""
|
||||||
|
mock_path.return_value.__truediv__ = MagicMock(
|
||||||
|
return_value=mock_path.return_value
|
||||||
|
)
|
||||||
|
|
||||||
|
save_cache([("title", "source", "time")])
|
||||||
232
tests/test_render.py
Normal file
232
tests/test_render.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.render module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.render import (
|
||||||
|
GRAD_COLS,
|
||||||
|
MSG_GRAD_COLS,
|
||||||
|
clear_font_cache,
|
||||||
|
font_for_lang,
|
||||||
|
lr_gradient,
|
||||||
|
lr_gradient_opposite,
|
||||||
|
make_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGradientConstants:
|
||||||
|
"""Tests for gradient color constants."""
|
||||||
|
|
||||||
|
def test_grad_cols_defined(self):
|
||||||
|
"""GRAD_COLS is defined with expected length."""
|
||||||
|
assert len(GRAD_COLS) > 0
|
||||||
|
assert all(isinstance(c, str) for c in GRAD_COLS)
|
||||||
|
|
||||||
|
def test_msg_grad_cols_defined(self):
|
||||||
|
"""MSG_GRAD_COLS is defined with expected length."""
|
||||||
|
assert len(MSG_GRAD_COLS) > 0
|
||||||
|
assert all(isinstance(c, str) for c in MSG_GRAD_COLS)
|
||||||
|
|
||||||
|
def test_grad_cols_start_with_white(self):
|
||||||
|
"""GRAD_COLS starts with white."""
|
||||||
|
assert "231" in GRAD_COLS[0]
|
||||||
|
|
||||||
|
def test_msg_grad_cols_different_from_grad_cols(self):
|
||||||
|
"""MSG_GRAD_COLS is different from GRAD_COLS."""
|
||||||
|
assert MSG_GRAD_COLS != GRAD_COLS
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradient:
|
||||||
|
"""Tests for lr_gradient function."""
|
||||||
|
|
||||||
|
def test_empty_rows(self):
|
||||||
|
"""Empty input returns empty output."""
|
||||||
|
result = lr_gradient([], 0.0)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_preserves_empty_rows(self):
|
||||||
|
"""Empty rows are preserved."""
|
||||||
|
result = lr_gradient([""], 0.0)
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
def test_adds_gradient_to_content(self):
|
||||||
|
"""Non-empty rows get gradient coloring."""
|
||||||
|
result = lr_gradient(["hello"], 0.0)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
|
||||||
|
def test_preserves_spaces(self):
|
||||||
|
"""Spaces are preserved without coloring."""
|
||||||
|
result = lr_gradient(["hello world"], 0.0)
|
||||||
|
assert " " in result[0]
|
||||||
|
|
||||||
|
def test_offset_wraps_around(self):
|
||||||
|
"""Offset wraps around at 1.0."""
|
||||||
|
result1 = lr_gradient(["hello"], 0.0)
|
||||||
|
result2 = lr_gradient(["hello"], 1.0)
|
||||||
|
assert result1 != result2 or result1 == result2
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradientOpposite:
|
||||||
|
"""Tests for lr_gradient_opposite function."""
|
||||||
|
|
||||||
|
def test_uses_msg_grad_cols(self):
|
||||||
|
"""Uses MSG_GRAD_COLS instead of GRAD_COLS."""
|
||||||
|
result = lr_gradient_opposite(["test"])
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearFontCache:
|
||||||
|
"""Tests for clear_font_cache function."""
|
||||||
|
|
||||||
|
def test_clears_without_error(self):
|
||||||
|
"""Function runs without error."""
|
||||||
|
clear_font_cache()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontForLang:
|
||||||
|
"""Tests for font_for_lang function."""
|
||||||
|
|
||||||
|
@patch("engine.render.font")
|
||||||
|
def test_returns_default_for_none(self, mock_font):
|
||||||
|
"""Returns default font when lang is None."""
|
||||||
|
result = font_for_lang(None)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@patch("engine.render.font")
|
||||||
|
def test_returns_default_for_unknown_lang(self, mock_font):
|
||||||
|
"""Returns default font for unknown language."""
|
||||||
|
result = font_for_lang("unknown_lang")
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMakeBlock:
|
||||||
|
"""Tests for make_block function."""
|
||||||
|
|
||||||
|
@patch("engine.translate.translate_headline")
|
||||||
|
@patch("engine.translate.detect_location_language")
|
||||||
|
@patch("engine.render.font_for_lang")
|
||||||
|
@patch("engine.render.big_wrap")
|
||||||
|
@patch("engine.render.random")
|
||||||
|
def test_make_block_basic(
|
||||||
|
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||||
|
):
|
||||||
|
"""Basic make_block returns content, color, meta index."""
|
||||||
|
mock_wrap.return_value = ["Headline content", ""]
|
||||||
|
mock_random.choice.return_value = "\033[38;5;46m"
|
||||||
|
|
||||||
|
content, color, meta_idx = make_block(
|
||||||
|
"Test headline", "TestSource", "12:00", 80
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(content) > 0
|
||||||
|
assert color is not None
|
||||||
|
assert meta_idx >= 0
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Requires full PIL/font environment")
|
||||||
|
@patch("engine.translate.translate_headline")
|
||||||
|
@patch("engine.translate.detect_location_language")
|
||||||
|
@patch("engine.render.font_for_lang")
|
||||||
|
@patch("engine.render.big_wrap")
|
||||||
|
@patch("engine.render.random")
|
||||||
|
def test_make_block_translation(
|
||||||
|
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||||
|
):
|
||||||
|
"""Translation is applied when mode is news."""
|
||||||
|
mock_wrap.return_value = ["Translated"]
|
||||||
|
mock_random.choice.return_value = "\033[38;5;46m"
|
||||||
|
mock_detect.return_value = "de"
|
||||||
|
|
||||||
|
with patch("engine.config.MODE", "news"):
|
||||||
|
content, _, _ = make_block("Test", "Source", "12:00", 80)
|
||||||
|
mock_translate.assert_called_once()
|
||||||
|
|
||||||
|
@patch("engine.translate.translate_headline")
|
||||||
|
@patch("engine.translate.detect_location_language")
|
||||||
|
@patch("engine.render.font_for_lang")
|
||||||
|
@patch("engine.render.big_wrap")
|
||||||
|
@patch("engine.render.random")
|
||||||
|
def test_make_block_no_translation_poetry(
|
||||||
|
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||||
|
):
|
||||||
|
"""No translation when mode is poetry."""
|
||||||
|
mock_wrap.return_value = ["Poem content"]
|
||||||
|
mock_random.choice.return_value = "\033[38;5;46m"
|
||||||
|
|
||||||
|
with patch("engine.config.MODE", "poetry"):
|
||||||
|
make_block("Test", "Source", "12:00", 80)
|
||||||
|
mock_translate.assert_not_called()
|
||||||
|
|
||||||
|
@patch("engine.translate.translate_headline")
|
||||||
|
@patch("engine.translate.detect_location_language")
|
||||||
|
@patch("engine.render.font_for_lang")
|
||||||
|
@patch("engine.render.big_wrap")
|
||||||
|
@patch("engine.render.random")
|
||||||
|
def test_make_block_meta_format(
|
||||||
|
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||||
|
):
|
||||||
|
"""Meta line includes source and timestamp."""
|
||||||
|
mock_wrap.return_value = ["Content"]
|
||||||
|
mock_random.choice.return_value = "\033[38;5;46m"
|
||||||
|
|
||||||
|
content, _, meta_idx = make_block("Test", "MySource", "14:30", 80)
|
||||||
|
|
||||||
|
meta_line = content[meta_idx]
|
||||||
|
assert "MySource" in meta_line
|
||||||
|
assert "14:30" in meta_line
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderLine:
|
||||||
|
"""Tests for render_line function."""
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
"""Empty string returns empty list."""
|
||||||
|
from engine.render import render_line
|
||||||
|
|
||||||
|
result = render_line("")
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Requires real font/PIL setup")
|
||||||
|
def test_uses_default_font(self):
|
||||||
|
"""Uses default font when none provided."""
|
||||||
|
from engine.render import render_line
|
||||||
|
|
||||||
|
with patch("engine.render.font") as mock_font:
|
||||||
|
mock_font.return_value = MagicMock()
|
||||||
|
mock_font.return_value.getbbox.return_value = (0, 0, 10, 10)
|
||||||
|
render_line("test")
|
||||||
|
|
||||||
|
def test_getbbox_returns_none(self):
|
||||||
|
"""Handles None bbox gracefully."""
|
||||||
|
from engine.render import render_line
|
||||||
|
|
||||||
|
with patch("engine.render.font") as mock_font:
|
||||||
|
mock_font.return_value = MagicMock()
|
||||||
|
mock_font.return_value.getbbox.return_value = None
|
||||||
|
result = render_line("test")
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBigWrap:
|
||||||
|
"""Tests for big_wrap function."""
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
"""Empty string returns empty list."""
|
||||||
|
from engine.render import big_wrap
|
||||||
|
|
||||||
|
result = big_wrap("", 80)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Requires real font/PIL setup")
|
||||||
|
def test_single_word_fits(self):
|
||||||
|
"""Single short word returns rendered."""
|
||||||
|
from engine.render import big_wrap
|
||||||
|
|
||||||
|
with patch("engine.render.font") as mock_font:
|
||||||
|
mock_font.return_value = MagicMock()
|
||||||
|
mock_font.return_value.getbbox.return_value = (0, 0, 10, 10)
|
||||||
|
result = big_wrap("test", 80)
|
||||||
|
assert len(result) > 0
|
||||||
115
tests/test_translate.py
Normal file
115
tests/test_translate.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.translate module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.translate import (
|
||||||
|
_translate_cached,
|
||||||
|
detect_location_language,
|
||||||
|
translate_headline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_translate_cache():
|
||||||
|
"""Clear the LRU cache between tests."""
|
||||||
|
_translate_cached.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectLocationLanguage:
|
||||||
|
"""Tests for detect_location_language function."""
|
||||||
|
|
||||||
|
def test_returns_none_for_unknown_location(self):
|
||||||
|
"""Returns None when no location pattern matches."""
|
||||||
|
result = detect_location_language("Breaking news about technology")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_detects_berlin(self):
|
||||||
|
"""Detects Berlin location."""
|
||||||
|
result = detect_location_language("Berlin police arrest protesters")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
def test_detects_paris(self):
|
||||||
|
"""Detects Paris location."""
|
||||||
|
result = detect_location_language("Paris fashion week begins")
|
||||||
|
assert result == "fr"
|
||||||
|
|
||||||
|
def test_detects_tokyo(self):
|
||||||
|
"""Detects Tokyo location."""
|
||||||
|
result = detect_location_language("Tokyo stocks rise")
|
||||||
|
assert result == "ja"
|
||||||
|
|
||||||
|
def test_detects_berlin_again(self):
|
||||||
|
"""Detects Berlin location again."""
|
||||||
|
result = detect_location_language("Berlin marathon set to begin")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
"""Detection is case insensitive."""
|
||||||
|
result = detect_location_language("BERLIN SUMMER FESTIVAL")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
def test_returns_first_match(self):
|
||||||
|
"""Returns first matching pattern."""
|
||||||
|
result = detect_location_language("Berlin in Paris for the event")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslateHeadline:
|
||||||
|
"""Tests for translate_headline function."""
|
||||||
|
|
||||||
|
def test_returns_translated_text(self):
|
||||||
|
"""Returns translated text from cache."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.translate_headline") as mock_fn:
|
||||||
|
mock_fn.return_value = "Translated title"
|
||||||
|
from engine.translate import translate_headline as th
|
||||||
|
|
||||||
|
result = th("Original title", "de")
|
||||||
|
assert result == "Translated title"
|
||||||
|
|
||||||
|
def test_uses_cached_result(self):
|
||||||
|
"""Translation uses LRU cache."""
|
||||||
|
clear_translate_cache()
|
||||||
|
result1 = translate_headline("Test unique", "es")
|
||||||
|
result2 = translate_headline("Test unique", "es")
|
||||||
|
assert result1 == result2
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslateCached:
|
||||||
|
"""Tests for _translate_cached function."""
|
||||||
|
|
||||||
|
def test_translation_network_error(self):
|
||||||
|
"""Network error returns original text."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
result = _translate_cached("Hello world", "de")
|
||||||
|
|
||||||
|
assert result == "Hello world"
|
||||||
|
|
||||||
|
def test_translation_invalid_json(self):
|
||||||
|
"""Invalid JSON returns original text."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"invalid json"
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _translate_cached("Hello", "de")
|
||||||
|
|
||||||
|
assert result == "Hello"
|
||||||
|
|
||||||
|
def test_translation_empty_response(self):
|
||||||
|
"""Empty translation response returns original text."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = json.dumps([[[""], None, "de"], None])
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _translate_cached("Hello", "de")
|
||||||
|
|
||||||
|
assert result == "Hello"
|
||||||
161
tests/test_websocket.py
Normal file
161
tests/test_websocket.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.websocket_display module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.websocket_display import WebSocketDisplay
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayImport:
|
||||||
|
"""Test that websocket module can be imported."""
|
||||||
|
|
||||||
|
def test_import_does_not_error(self):
|
||||||
|
"""Module imports without error."""
|
||||||
|
from engine import websocket_display
|
||||||
|
|
||||||
|
assert websocket_display is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayInit:
|
||||||
|
"""Tests for WebSocketDisplay initialization."""
|
||||||
|
|
||||||
|
def test_default_init(self):
|
||||||
|
"""Default initialization sets correct defaults."""
|
||||||
|
with patch("engine.websocket_display.websockets", None):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.host == "0.0.0.0"
|
||||||
|
assert display.port == 8765
|
||||||
|
assert display.http_port == 8766
|
||||||
|
assert display.width == 80
|
||||||
|
assert display.height == 24
|
||||||
|
|
||||||
|
def test_custom_init(self):
|
||||||
|
"""Custom initialization uses provided values."""
|
||||||
|
with patch("engine.websocket_display.websockets", None):
|
||||||
|
display = WebSocketDisplay(host="localhost", port=9000, http_port=9001)
|
||||||
|
assert display.host == "localhost"
|
||||||
|
assert display.port == 9000
|
||||||
|
assert display.http_port == 9001
|
||||||
|
|
||||||
|
def test_is_available_when_websockets_present(self):
|
||||||
|
"""is_available returns True when websockets is available."""
|
||||||
|
pytest.importorskip("websockets")
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.is_available() is True
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_is_available_when_websockets_missing(self):
|
||||||
|
"""is_available returns False when websockets is not available."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.is_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayProtocol:
|
||||||
|
"""Test that WebSocketDisplay satisfies Display protocol."""
|
||||||
|
|
||||||
|
def test_websocket_display_is_display(self):
|
||||||
|
"""WebSocketDisplay satisfies Display protocol."""
|
||||||
|
with patch("engine.websocket_display.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert hasattr(display, "init")
|
||||||
|
assert hasattr(display, "show")
|
||||||
|
assert hasattr(display, "clear")
|
||||||
|
assert hasattr(display, "cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayMethods:
|
||||||
|
"""Tests for WebSocketDisplay methods."""
|
||||||
|
|
||||||
|
def test_init_stores_dimensions(self):
|
||||||
|
"""init stores terminal dimensions."""
|
||||||
|
with patch("engine.websocket_display.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.init(100, 40)
|
||||||
|
assert display.width == 100
|
||||||
|
assert display.height == 40
|
||||||
|
|
||||||
|
def test_client_count_initially_zero(self):
|
||||||
|
"""client_count returns 0 when no clients connected."""
|
||||||
|
with patch("engine.websocket_display.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.client_count() == 0
|
||||||
|
|
||||||
|
def test_get_ws_port(self):
|
||||||
|
"""get_ws_port returns configured port."""
|
||||||
|
with patch("engine.websocket_display.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay(port=9000)
|
||||||
|
assert display.get_ws_port() == 9000
|
||||||
|
|
||||||
|
def test_get_http_port(self):
|
||||||
|
"""get_http_port returns configured port."""
|
||||||
|
with patch("engine.websocket_display.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay(http_port=9001)
|
||||||
|
assert display.get_http_port() == 9001
|
||||||
|
|
||||||
|
def test_frame_delay_defaults_to_zero(self):
|
||||||
|
"""get_frame_delay returns 0 by default."""
|
||||||
|
with patch("engine.websocket_display.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.get_frame_delay() == 0.0
|
||||||
|
|
||||||
|
def test_set_frame_delay(self):
|
||||||
|
"""set_frame_delay stores the value."""
|
||||||
|
with patch("engine.websocket_display.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.set_frame_delay(0.05)
|
||||||
|
assert display.get_frame_delay() == 0.05
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayCallbacks:
|
||||||
|
"""Tests for WebSocketDisplay callback methods."""
|
||||||
|
|
||||||
|
def test_set_client_connected_callback(self):
|
||||||
|
"""set_client_connected_callback stores callback."""
|
||||||
|
with patch("engine.websocket_display.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
callback = MagicMock()
|
||||||
|
display.set_client_connected_callback(callback)
|
||||||
|
assert display._client_connected_callback is callback
|
||||||
|
|
||||||
|
def test_set_client_disconnected_callback(self):
|
||||||
|
"""set_client_disconnected_callback stores callback."""
|
||||||
|
with patch("engine.websocket_display.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
callback = MagicMock()
|
||||||
|
display.set_client_disconnected_callback(callback)
|
||||||
|
assert display._client_disconnected_callback is callback
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayUnavailable:
|
||||||
|
"""Tests when WebSocket support is unavailable."""
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_start_server_noop_when_unavailable(self):
|
||||||
|
"""start_server does nothing when websockets unavailable."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.start_server()
|
||||||
|
assert display._server_thread is None
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_start_http_server_noop_when_unavailable(self):
|
||||||
|
"""start_http_server does nothing when websockets unavailable."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.start_http_server()
|
||||||
|
assert display._http_thread is None
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_show_noops_when_unavailable(self):
|
||||||
|
"""show does nothing when websockets unavailable."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.show(["line1", "line2"])
|
||||||
Reference in New Issue
Block a user