forked from genewildish/Mainline
feat: Add gallery presets, MultiDisplay support, and viewport tests
- Add ~20 gallery presets covering sources, effects, cameras, displays - Add MultiDisplay support with --display multi:terminal,pygame syntax - Fix ViewportFilterStage to recompute layout on viewport_width change - Add benchmark.py module for hook-based performance testing - Add viewport resize tests to test_viewport_filter_performance.py
This commit is contained in:
@@ -116,6 +116,20 @@ def run_pipeline_mode(preset_name: str = "demo"):
|
||||
display_name = sys.argv[idx + 1]
|
||||
|
||||
display = DisplayRegistry.create(display_name)
|
||||
if not display and not display_name.startswith("multi"):
|
||||
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
# Handle multi display (format: "multi:terminal,pygame")
|
||||
if not display and display_name.startswith("multi"):
|
||||
parts = display_name[6:].split(
|
||||
","
|
||||
) # "multi:terminal,pygame" -> ["terminal", "pygame"]
|
||||
display = DisplayRegistry.create_multi(parts)
|
||||
if not display:
|
||||
print(f" \033[38;5;196mFailed to create multi display: {parts}\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
if not display:
|
||||
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
73
engine/benchmark.py
Normal file
73
engine/benchmark.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Benchmark module for performance testing.
|
||||
|
||||
Usage:
|
||||
python -m engine.benchmark # Run all benchmarks
|
||||
python -m engine.benchmark --hook # Run benchmarks in hook mode (for CI)
|
||||
python -m engine.benchmark --displays null --iterations 20
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run performance benchmarks")
|
||||
parser.add_argument(
|
||||
"--hook",
|
||||
action="store_true",
|
||||
help="Run in hook mode (fail on regression)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--displays",
|
||||
default="null",
|
||||
help="Comma-separated list of displays to benchmark",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--iterations",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of iterations per benchmark",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Run pytest with benchmark markers
|
||||
pytest_args = [
|
||||
"-v",
|
||||
"-m",
|
||||
"benchmark",
|
||||
]
|
||||
|
||||
if args.hook:
|
||||
# Hook mode: stricter settings
|
||||
pytest_args.extend(
|
||||
[
|
||||
"--benchmark-only",
|
||||
"--benchmark-compare",
|
||||
"--benchmark-compare-fail=min:5%", # Fail if >5% slower
|
||||
]
|
||||
)
|
||||
|
||||
# Add display filter if specified
|
||||
if args.displays:
|
||||
pytest_args.extend(["-k", args.displays])
|
||||
|
||||
# Add iterations
|
||||
if args.iterations:
|
||||
# Set environment variable for benchmark tests
|
||||
import os
|
||||
|
||||
os.environ["BENCHMARK_ITERATIONS"] = str(args.iterations)
|
||||
|
||||
# Run pytest
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "tests/test_benchmark.py"] + pytest_args,
|
||||
cwd=None, # Current directory
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -147,6 +147,31 @@ class DisplayRegistry:
|
||||
|
||||
cls._initialized = True
|
||||
|
||||
@classmethod
|
||||
def create_multi(cls, names: list[str]) -> "Display | None":
|
||||
"""Create a MultiDisplay from a list of backend names.
|
||||
|
||||
Args:
|
||||
names: List of display backend names (e.g., ["terminal", "pygame"])
|
||||
|
||||
Returns:
|
||||
MultiDisplay instance or None if any backend fails
|
||||
"""
|
||||
from engine.display.backends.multi import MultiDisplay
|
||||
|
||||
displays = []
|
||||
for name in names:
|
||||
backend = cls.create(name)
|
||||
if backend:
|
||||
displays.append(backend)
|
||||
else:
|
||||
return None
|
||||
|
||||
if not displays:
|
||||
return None
|
||||
|
||||
return MultiDisplay(displays)
|
||||
|
||||
|
||||
def get_monitor():
|
||||
"""Get the performance monitor."""
|
||||
|
||||
@@ -437,8 +437,9 @@ class ViewportFilterStage(Stage):
|
||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||
camera_y = ctx.get("camera_y", 0)
|
||||
|
||||
# Recompute layout only when item count changes
|
||||
if len(data) != self._cached_count:
|
||||
# Recompute layout when item count OR viewport width changes
|
||||
cached_width = getattr(self, "_cached_width", None)
|
||||
if len(data) != self._cached_count or cached_width != viewport_width:
|
||||
self._layout = []
|
||||
y = 0
|
||||
from engine.render.blocks import estimate_block_height
|
||||
@@ -454,6 +455,7 @@ class ViewportFilterStage(Stage):
|
||||
self._layout.append((y, h))
|
||||
y += h
|
||||
self._cached_count = len(data)
|
||||
self._cached_width = viewport_width
|
||||
|
||||
# Find items visible in [camera_y - buffer, camera_y + viewport_height + buffer]
|
||||
buffer_zone = viewport_height
|
||||
|
||||
73
mise.toml
73
mise.toml
@@ -5,75 +5,27 @@ pkl = "latest"
|
||||
|
||||
[tasks]
|
||||
# =====================
|
||||
# Testing
|
||||
# Core
|
||||
# =====================
|
||||
|
||||
test = "uv run pytest"
|
||||
test-v = { run = "uv run pytest -v", depends = ["sync-all"] }
|
||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html", depends = ["sync-all"] }
|
||||
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
|
||||
# =====================
|
||||
|
||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing", depends = ["sync-all"] }
|
||||
lint = "uv run ruff check engine/ mainline.py"
|
||||
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||
format = "uv run ruff format engine/ mainline.py"
|
||||
|
||||
# =====================
|
||||
# Runtime Modes
|
||||
# Run
|
||||
# =====================
|
||||
|
||||
run = "uv run mainline.py"
|
||||
run-poetry = "uv run mainline.py --poetry"
|
||||
run-firehose = "uv run mainline.py --firehose"
|
||||
|
||||
run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] }
|
||||
run-sixel = { run = "uv run mainline.py --display sixel", depends = ["sync-all"] }
|
||||
run-kitty = { run = "uv run mainline.py --display kitty", depends = ["sync-all"] }
|
||||
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
|
||||
run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] }
|
||||
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"] }
|
||||
run-terminal = { run = "uv run mainline.py --display terminal", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Pipeline Architecture (unified Stage-based)
|
||||
# Presets
|
||||
# =====================
|
||||
|
||||
run-pipeline = { run = "uv run mainline.py --pipeline --display pygame", depends = ["sync-all"] }
|
||||
run-pipeline-demo = { run = "uv run mainline.py --pipeline --pipeline-preset demo --display pygame", depends = ["sync-all"] }
|
||||
run-pipeline-poetry = { run = "uv run mainline.py --pipeline --pipeline-preset poetry --display pygame", depends = ["sync-all"] }
|
||||
run-pipeline-websocket = { run = "uv run mainline.py --pipeline --pipeline-preset websocket", depends = ["sync-all"] }
|
||||
run-pipeline-firehose = { run = "uv run mainline.py --pipeline --pipeline-preset firehose --display pygame", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Presets (Animation-controlled modes)
|
||||
# =====================
|
||||
|
||||
run-preset-demo = { run = "uv run mainline.py --preset demo --display pygame", depends = ["sync-all"] }
|
||||
run-preset-border-test = { run = "uv run mainline.py --preset border-test --display terminal", depends = ["sync-all"] }
|
||||
run-preset-pipeline-inspect = { run = "uv run mainline.py --preset pipeline-inspect --display terminal", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Command & Control
|
||||
# =====================
|
||||
|
||||
cmd = "uv run cmdline.py"
|
||||
cmd-stats = { run = "uv run cmdline.py -w \"/effects stats\"", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Benchmark
|
||||
# =====================
|
||||
|
||||
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
||||
benchmark-json = { run = "uv run python -m engine.benchmark --format json --output benchmark.json", depends = ["sync-all"] }
|
||||
benchmark-report = { run = "uv run python -m engine.benchmark --output BENCHMARK.md", depends = ["sync-all"] }
|
||||
|
||||
# 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"
|
||||
run-demo = { run = "uv run mainline.py --preset demo --display pygame", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Daemon
|
||||
@@ -90,20 +42,21 @@ daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
||||
sync = "uv sync"
|
||||
sync-all = "uv sync --all-extras"
|
||||
install = "mise run sync"
|
||||
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"] }
|
||||
|
||||
clean = "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
|
||||
# =====================
|
||||
|
||||
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
||||
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"
|
||||
|
||||
# =====================
|
||||
# Git Hooks (via hk)
|
||||
# Hooks
|
||||
# =====================
|
||||
|
||||
pre-commit = "hk run pre-commit"
|
||||
pre-commit = "hk run pre-commit"
|
||||
|
||||
[env]
|
||||
KAGI_API_KEY = "lOp6AGyX6TUB0kGzAli1BlAx5-VjlIN1OPCPYEXDdQc.FOKLieOa7NgWUUZi4mTZvHmrW2uNnOr8hfgv7jMvRQM"
|
||||
|
||||
289
presets.toml
289
presets.toml
@@ -8,84 +8,246 @@
|
||||
# - ~/.config/mainline/presets.toml
|
||||
# - ./presets.toml (local override)
|
||||
|
||||
[presets.demo]
|
||||
description = "Demo mode with effect cycling and camera modes"
|
||||
# ============================================
|
||||
# DATA SOURCE GALLERY
|
||||
# ============================================
|
||||
|
||||
[presets.gallery-sources]
|
||||
description = "Gallery: Headlines data source"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "scroll"
|
||||
effects = ["noise", "fade", "glitch", "firehose"]
|
||||
camera = "feed"
|
||||
effects = []
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
camera_speed = 1.0
|
||||
firehose_enabled = true
|
||||
|
||||
[presets.poetry]
|
||||
description = "Poetry feed with subtle effects"
|
||||
[presets.gallery-sources-poetry]
|
||||
description = "Gallery: Poetry data source"
|
||||
source = "poetry"
|
||||
display = "pygame"
|
||||
camera = "scroll"
|
||||
camera = "feed"
|
||||
effects = ["fade"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
camera_speed = 0.5
|
||||
|
||||
[presets.border-test]
|
||||
description = "Test border rendering with empty buffer"
|
||||
source = "empty"
|
||||
display = "terminal"
|
||||
camera = "scroll"
|
||||
effects = ["border"]
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
camera_speed = 1.0
|
||||
firehose_enabled = false
|
||||
border = false
|
||||
|
||||
[presets.websocket]
|
||||
description = "WebSocket display mode"
|
||||
source = "headlines"
|
||||
display = "websocket"
|
||||
camera = "scroll"
|
||||
effects = ["noise", "fade", "glitch"]
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
camera_speed = 1.0
|
||||
firehose_enabled = false
|
||||
|
||||
[presets.sixel]
|
||||
description = "Sixel graphics display mode"
|
||||
source = "headlines"
|
||||
display = "sixel"
|
||||
camera = "scroll"
|
||||
effects = ["noise", "fade", "glitch"]
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
camera_speed = 1.0
|
||||
firehose_enabled = false
|
||||
|
||||
[presets.firehose]
|
||||
description = "High-speed firehose mode"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "scroll"
|
||||
effects = ["noise", "fade", "glitch", "firehose"]
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
camera_speed = 2.0
|
||||
firehose_enabled = true
|
||||
|
||||
[presets.pipeline-inspect]
|
||||
description = "Live pipeline introspection with DAG and performance metrics"
|
||||
[presets.gallery-sources-pipeline]
|
||||
description = "Gallery: Pipeline introspection"
|
||||
source = "pipeline-inspect"
|
||||
display = "pygame"
|
||||
camera = "scroll"
|
||||
effects = ["crop"]
|
||||
effects = []
|
||||
camera_speed = 0.3
|
||||
viewport_width = 100
|
||||
viewport_height = 35
|
||||
camera_speed = 0.3
|
||||
firehose_enabled = false
|
||||
|
||||
# Sensor configuration (for future use with param bindings)
|
||||
[presets.gallery-sources-empty]
|
||||
description = "Gallery: Empty source (for border tests)"
|
||||
source = "empty"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = ["border"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# EFFECT GALLERY
|
||||
# ============================================
|
||||
|
||||
[presets.gallery-effect-noise]
|
||||
description = "Gallery: Noise effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-fade]
|
||||
description = "Gallery: Fade effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["fade"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-glitch]
|
||||
description = "Gallery: Glitch effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["glitch"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-firehose]
|
||||
description = "Gallery: Firehose effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["firehose"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-hud]
|
||||
description = "Gallery: HUD effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["hud"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-tint]
|
||||
description = "Gallery: Tint effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["tint"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-border]
|
||||
description = "Gallery: Border effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["border"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-crop]
|
||||
description = "Gallery: Crop effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["crop"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# CAMERA GALLERY
|
||||
# ============================================
|
||||
|
||||
[presets.gallery-camera-feed]
|
||||
description = "Gallery: Feed camera (rapid single-item)"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 1.0
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-scroll]
|
||||
description = "Gallery: Scroll camera (smooth)"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "scroll"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.3
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-horizontal]
|
||||
description = "Gallery: Horizontal camera"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "horizontal"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.5
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-omni]
|
||||
description = "Gallery: Omni camera"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "omni"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.5
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-floating]
|
||||
description = "Gallery: Floating camera"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "floating"
|
||||
effects = ["noise"]
|
||||
camera_speed = 1.0
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-bounce]
|
||||
description = "Gallery: Bounce camera"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "bounce"
|
||||
effects = ["noise"]
|
||||
camera_speed = 1.0
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# DISPLAY GALLERY
|
||||
# ============================================
|
||||
|
||||
[presets.gallery-display-terminal]
|
||||
description = "Gallery: Terminal display"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-display-pygame]
|
||||
description = "Gallery: Pygame display"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-display-websocket]
|
||||
description = "Gallery: WebSocket display"
|
||||
source = "headlines"
|
||||
display = "websocket"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-display-multi]
|
||||
description = "Gallery: MultiDisplay (terminal + pygame)"
|
||||
source = "headlines"
|
||||
display = "multi:terminal,pygame"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# SENSOR CONFIGURATION
|
||||
# ============================================
|
||||
|
||||
[sensors.mic]
|
||||
enabled = false
|
||||
threshold_db = 50.0
|
||||
@@ -95,7 +257,10 @@ enabled = false
|
||||
waveform = "sine"
|
||||
frequency = 1.0
|
||||
|
||||
# Effect configurations
|
||||
# ============================================
|
||||
# EFFECT CONFIGURATIONS
|
||||
# ============================================
|
||||
|
||||
[effect_configs.noise]
|
||||
enabled = true
|
||||
intensity = 1.0
|
||||
|
||||
@@ -30,11 +30,11 @@ class TestMain:
|
||||
patch("engine.app.run_pipeline_mode") as mock_run,
|
||||
):
|
||||
mock_config.PIPELINE_DIAGRAM = False
|
||||
mock_config.PRESET = "border-test"
|
||||
mock_config.PRESET = "gallery-sources"
|
||||
mock_config.PIPELINE_MODE = False
|
||||
sys.argv = ["mainline.py"]
|
||||
main()
|
||||
mock_run.assert_called_once_with("border-test")
|
||||
mock_run.assert_called_once_with("gallery-sources")
|
||||
|
||||
def test_main_exits_on_unknown_preset(self):
|
||||
"""main() exits with error for unknown preset."""
|
||||
@@ -120,11 +120,11 @@ class TestRunPipelineMode:
|
||||
mock_create.return_value = mock_display
|
||||
|
||||
try:
|
||||
run_pipeline_mode("border-test")
|
||||
run_pipeline_mode("gallery-display-terminal")
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
# Verify display was created with 'terminal' (preset display for border-test)
|
||||
# Verify display was created with 'terminal' (preset display)
|
||||
mock_create.assert_called_once_with("terminal")
|
||||
|
||||
def test_run_pipeline_mode_respects_display_cli_flag(self):
|
||||
|
||||
@@ -158,3 +158,96 @@ class TestViewportFilterIntegration:
|
||||
# Verify we kept the first N items in order
|
||||
for i, item in enumerate(filtered):
|
||||
assert item.content == f"Headline {i}"
|
||||
|
||||
|
||||
class TestViewportResize:
|
||||
"""Test ViewportFilterStage handles viewport resize correctly."""
|
||||
|
||||
def test_layout_recomputes_on_width_change(self):
|
||||
"""Test that layout is recomputed when viewport_width changes."""
|
||||
stage = ViewportFilterStage()
|
||||
# Use long headlines that will wrap differently at different widths
|
||||
items = [
|
||||
SourceItem(
|
||||
f"This is a very long headline number {i} that will definitely wrap at narrow widths",
|
||||
"test",
|
||||
str(i),
|
||||
)
|
||||
for i in range(50)
|
||||
]
|
||||
|
||||
# Initial render at 80 cols
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_width=80, viewport_height=24)
|
||||
ctx.set("camera_y", 0)
|
||||
|
||||
stage.process(items, ctx)
|
||||
cached_layout_80 = stage._layout.copy()
|
||||
|
||||
# Resize to 40 cols - layout should recompute
|
||||
ctx.params.viewport_width = 40
|
||||
stage.process(items, ctx)
|
||||
cached_layout_40 = stage._layout.copy()
|
||||
|
||||
# With narrower viewport, items wrap to more lines
|
||||
# So the cumulative heights should be different
|
||||
assert cached_layout_40 != cached_layout_80, (
|
||||
"Layout should recompute when viewport_width changes"
|
||||
)
|
||||
|
||||
def test_layout_recomputes_on_height_change(self):
|
||||
"""Test that visible items change when viewport_height changes."""
|
||||
stage = ViewportFilterStage()
|
||||
items = [SourceItem(f"Headline {i}", "test", str(i)) for i in range(100)]
|
||||
|
||||
ctx = PipelineContext()
|
||||
ctx.set("camera_y", 0)
|
||||
|
||||
# Small viewport - fewer items visible
|
||||
ctx.params = MockParams(viewport_width=80, viewport_height=12)
|
||||
result_small = stage.process(items, ctx)
|
||||
|
||||
# Larger viewport - more items visible
|
||||
ctx.params.viewport_height = 48
|
||||
result_large = stage.process(items, ctx)
|
||||
|
||||
# With larger viewport, more items should be visible
|
||||
assert len(result_large) >= len(result_small)
|
||||
|
||||
def test_camera_y_propagates_to_filter(self):
|
||||
"""Test that camera_y is read from context."""
|
||||
stage = ViewportFilterStage()
|
||||
items = [SourceItem(f"Headline {i}", "test", str(i)) for i in range(100)]
|
||||
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_width=80, viewport_height=24)
|
||||
|
||||
# Camera at y=0
|
||||
ctx.set("camera_y", 0)
|
||||
result_at_0 = stage.process(items, ctx)
|
||||
|
||||
# Camera at y=100
|
||||
ctx.set("camera_y", 100)
|
||||
result_at_100 = stage.process(items, ctx)
|
||||
|
||||
# With different camera positions, different items should be visible
|
||||
# (unless items are very short)
|
||||
first_item_at_0 = result_at_0[0].content if result_at_0 else None
|
||||
first_item_at_100 = result_at_100[0].content if result_at_100 else None
|
||||
|
||||
# The items at different positions should be different
|
||||
assert first_item_at_0 != first_item_at_100 or first_item_at_0 is None
|
||||
|
||||
def test_resize_handles_edge_case_small_width(self):
|
||||
"""Test that very narrow viewport doesn't crash."""
|
||||
stage = ViewportFilterStage()
|
||||
items = [SourceItem("Short", "test", "1")]
|
||||
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_width=10, viewport_height=5)
|
||||
ctx.set("camera_y", 0)
|
||||
|
||||
# Should not crash with very narrow viewport
|
||||
result = stage.process(items, ctx)
|
||||
assert result is not None
|
||||
assert len(result) > 0
|
||||
|
||||
Reference in New Issue
Block a user