Files
sideline/tests/test_performance_regression.py
David Gwilliam 57de835ae0 feat: Implement scrolling camera with layout-aware filtering
- Rename VERTICAL camera mode to FEED (rapid single-item view)
- Add SCROLL camera mode with float accumulation for smooth movie-credits style scrolling
- Add estimate_block_height() for cheap layout calculation without full rendering
- Replace ViewportFilterStage with layout-aware filtering that tracks camera position
- Add render caching to FontStage to avoid re-rendering items
- Fix CameraStage to use global canvas height for scrolling bounds
- Add horizontal padding in Camera.apply() to prevent ghosting
- Add get_dimensions() to MultiDisplay for proper viewport sizing
- Fix PygameDisplay to auto-detect viewport from window size
- Update presets to use scroll camera with appropriate speeds
2026-03-17 00:21:18 -07:00

193 lines
6.6 KiB
Python

"""Performance regression tests for pipeline stages with realistic data volumes.
These tests verify that the pipeline maintains performance with large datasets
by ensuring ViewportFilterStage prevents FontStage from rendering excessive items.
Uses pytest-benchmark for statistical benchmarking with automatic regression detection.
"""
import pytest
from engine.data_sources.sources import SourceItem
from engine.pipeline.adapters import FontStage, ViewportFilterStage
from engine.pipeline.core import PipelineContext
class MockParams:
"""Mock parameters object for testing."""
def __init__(self, viewport_width: int = 80, viewport_height: int = 24):
self.viewport_width = viewport_width
self.viewport_height = viewport_height
class TestViewportFilterPerformance:
"""Test ViewportFilterStage performance with realistic data volumes."""
@pytest.mark.benchmark
def test_filter_2000_items_to_viewport(self, benchmark):
"""Benchmark: Filter 2000 items to viewport size.
Performance threshold: Must complete in < 1ms per iteration
This tests the filtering overhead is negligible.
"""
# Create 2000 test items (more than real headline sources)
test_items = [
SourceItem(f"Headline {i}", f"source-{i % 10}", str(i)) for i in range(2000)
]
stage = ViewportFilterStage()
ctx = PipelineContext()
ctx.params = MockParams(viewport_height=24)
result = benchmark(stage.process, test_items, ctx)
# Verify result is correct
assert len(result) <= 5
assert len(result) > 0
@pytest.mark.benchmark
def test_font_stage_with_filtered_items(self, benchmark):
"""Benchmark: FontStage rendering filtered (5) items.
Performance threshold: Must complete in < 50ms per iteration
This tests that filtering saves significant time by reducing FontStage work.
"""
# Create filtered items (what ViewportFilterStage outputs)
filtered_items = [
SourceItem(f"Headline {i}", "source", str(i))
for i in range(5) # Filtered count
]
font_stage = FontStage()
ctx = PipelineContext()
ctx.params = MockParams()
result = benchmark(font_stage.process, filtered_items, ctx)
# Should render successfully
assert result is not None
assert isinstance(result, list)
assert len(result) > 0
def test_filter_reduces_work_by_288x(self):
"""Verify ViewportFilterStage achieves expected performance improvement.
With 1438 items and 24-line viewport:
- Without filter: FontStage renders all 1438 items
- With filter: FontStage renders ~3 items (layout-based)
- Expected improvement: 1438 / 3 ≈ 479x
"""
test_items = [
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1438)
]
stage = ViewportFilterStage()
ctx = PipelineContext()
ctx.params = MockParams(viewport_height=24)
filtered = stage.process(test_items, ctx)
improvement_factor = len(test_items) / len(filtered)
# Verify we get expected ~479x improvement (better than old ~288x)
assert 400 < improvement_factor < 600
# Verify filtered count is reasonable (layout-based is more precise)
assert 2 <= len(filtered) <= 5
class TestPipelinePerformanceWithRealData:
"""Integration tests for full pipeline performance with large datasets."""
def test_pipeline_handles_large_item_count(self):
"""Test that pipeline doesn't hang with 2000+ items due to filtering."""
# Create large dataset
large_items = [
SourceItem(f"Headline {i}", f"source-{i % 5}", str(i)) for i in range(2000)
]
filter_stage = ViewportFilterStage()
font_stage = FontStage()
ctx = PipelineContext()
ctx.params = MockParams(viewport_height=24)
# Filter should reduce items quickly
filtered = filter_stage.process(large_items, ctx)
assert len(filtered) < len(large_items)
# FontStage should process filtered items quickly
rendered = font_stage.process(filtered, ctx)
assert rendered is not None
def test_multiple_viewports_filter_correctly(self):
"""Test that filter respects different viewport configurations."""
large_items = [
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1000)
]
stage = ViewportFilterStage()
# Test different viewport heights
test_cases = [
(12, 3), # 12px height -> ~3 items
(24, 5), # 24px height -> ~5 items
(48, 9), # 48px height -> ~9 items
]
for viewport_height, expected_max_items in test_cases:
ctx = PipelineContext()
ctx.params = MockParams(viewport_height=viewport_height)
filtered = stage.process(large_items, ctx)
# Verify filtering is proportional to viewport
assert len(filtered) <= expected_max_items + 1
assert len(filtered) > 0
class TestPerformanceRegressions:
"""Tests that catch common performance regressions."""
def test_filter_doesnt_render_all_items(self):
"""Regression test: Ensure filter doesn't accidentally render all items.
This would indicate that ViewportFilterStage is broken or bypassed.
"""
large_items = [
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1438)
]
stage = ViewportFilterStage()
ctx = PipelineContext()
ctx.params = MockParams()
filtered = stage.process(large_items, ctx)
# Should NOT have all items (regression detection)
assert len(filtered) != len(large_items)
# Should have drastically fewer items
assert len(filtered) < 10
def test_font_stage_doesnt_hang_with_filter(self):
"""Regression test: FontStage shouldn't hang when receiving filtered data.
Previously, FontStage would render all items, causing 10+ second hangs.
Now it should receive only ~5 items and complete quickly.
"""
# Simulate what happens after ViewportFilterStage
filtered_items = [
SourceItem(f"Headline {i}", "source", str(i))
for i in range(5) # What filter outputs
]
font_stage = FontStage()
ctx = PipelineContext()
ctx.params = MockParams()
# Should complete instantly (not hang)
result = font_stage.process(filtered_items, ctx)
# Verify it actually worked
assert result is not None
assert isinstance(result, list)