fix(performance): use simple height estimation instead of PIL rendering

- Replace estimate_block_height (PIL-based) with estimate_simple_height (word wrap)
- Update viewport filter tests to match new height-based filtering (~4 items vs 24)
- Fix CI task duplication in mise.toml (remove redundant depends)

Closes #38
Closes #36
This commit is contained in:
2026-03-18 22:33:36 -07:00
parent abe49ba7d7
commit c57617bb3d
26 changed files with 3938 additions and 1956 deletions

View File

@@ -1307,4 +1307,470 @@ class TestInletOutletTypeValidation:
pipeline.build()
assert "display" in str(exc_info.value).lower()
assert "TEXT_BUFFER" in str(exc_info.value)
class TestPipelineMutation:
"""Tests for Pipeline Mutation API - dynamic stage modification."""
def setup_method(self):
"""Set up test fixtures."""
StageRegistry._discovered = False
StageRegistry._categories.clear()
StageRegistry._instances.clear()
discover_stages()
def _create_mock_stage(
self,
name: str = "test",
category: str = "test",
capabilities: set | None = None,
dependencies: set | None = None,
):
"""Helper to create a mock stage."""
from engine.pipeline.core import DataType
mock = MagicMock(spec=Stage)
mock.name = name
mock.category = category
mock.stage_type = category
mock.render_order = 0
mock.is_overlay = False
mock.inlet_types = {DataType.ANY}
mock.outlet_types = {DataType.TEXT_BUFFER}
mock.capabilities = capabilities or {f"{category}.{name}"}
mock.dependencies = dependencies or set()
mock.process = lambda data, ctx: data
mock.init = MagicMock(return_value=True)
mock.cleanup = MagicMock()
mock.is_enabled = MagicMock(return_value=True)
mock.set_enabled = MagicMock()
mock._enabled = True
return mock
def test_add_stage_initializes_when_pipeline_initialized(self):
"""add_stage() initializes stage when pipeline already initialized."""
pipeline = Pipeline()
mock_stage = self._create_mock_stage("test")
pipeline.build()
pipeline._initialized = True
pipeline.add_stage("test", mock_stage, initialize=True)
mock_stage.init.assert_called_once()
def test_add_stage_skips_initialize_when_pipeline_not_initialized(self):
"""add_stage() skips initialization when pipeline not built."""
pipeline = Pipeline()
mock_stage = self._create_mock_stage("test")
pipeline.add_stage("test", mock_stage, initialize=False)
mock_stage.init.assert_not_called()
def test_remove_stage_returns_removed_stage(self):
"""remove_stage() returns the removed stage."""
pipeline = Pipeline()
mock_stage = self._create_mock_stage("test")
pipeline.add_stage("test", mock_stage, initialize=False)
removed = pipeline.remove_stage("test", cleanup=False)
assert removed is mock_stage
assert "test" not in pipeline.stages
def test_remove_stage_calls_cleanup_when_requested(self):
"""remove_stage() calls cleanup when cleanup=True."""
pipeline = Pipeline()
mock_stage = self._create_mock_stage("test")
pipeline.add_stage("test", mock_stage, initialize=False)
pipeline.remove_stage("test", cleanup=True)
mock_stage.cleanup.assert_called_once()
def test_remove_stage_skips_cleanup_when_requested(self):
"""remove_stage() skips cleanup when cleanup=False."""
pipeline = Pipeline()
mock_stage = self._create_mock_stage("test")
pipeline.add_stage("test", mock_stage, initialize=False)
pipeline.remove_stage("test", cleanup=False)
mock_stage.cleanup.assert_not_called()
def test_remove_nonexistent_stage_returns_none(self):
"""remove_stage() returns None for nonexistent stage."""
pipeline = Pipeline()
result = pipeline.remove_stage("nonexistent", cleanup=False)
assert result is None
def test_replace_stage_preserves_state(self):
"""replace_stage() copies _enabled from old to new stage."""
pipeline = Pipeline()
old_stage = self._create_mock_stage("test")
old_stage._enabled = False
new_stage = self._create_mock_stage("test")
pipeline.add_stage("test", old_stage, initialize=False)
pipeline.replace_stage("test", new_stage, preserve_state=True)
assert new_stage._enabled is False
old_stage.cleanup.assert_called_once()
new_stage.init.assert_called_once()
def test_replace_stage_without_preserving_state(self):
"""replace_stage() without preserve_state doesn't copy state."""
pipeline = Pipeline()
old_stage = self._create_mock_stage("test")
old_stage._enabled = False
new_stage = self._create_mock_stage("test")
new_stage._enabled = True
pipeline.add_stage("test", old_stage, initialize=False)
pipeline.replace_stage("test", new_stage, preserve_state=False)
assert new_stage._enabled is True
def test_replace_nonexistent_stage_returns_none(self):
"""replace_stage() returns None for nonexistent stage."""
pipeline = Pipeline()
mock_stage = self._create_mock_stage("test")
result = pipeline.replace_stage("nonexistent", mock_stage)
assert result is None
def test_swap_stages_swaps_stages(self):
"""swap_stages() swaps two stages."""
pipeline = Pipeline()
stage_a = self._create_mock_stage("stage_a", "a")
stage_b = self._create_mock_stage("stage_b", "b")
pipeline.add_stage("a", stage_a, initialize=False)
pipeline.add_stage("b", stage_b, initialize=False)
result = pipeline.swap_stages("a", "b")
assert result is True
assert pipeline.stages["a"].name == "stage_b"
assert pipeline.stages["b"].name == "stage_a"
def test_swap_stages_fails_for_nonexistent(self):
"""swap_stages() fails if either stage doesn't exist."""
pipeline = Pipeline()
stage = self._create_mock_stage("test")
pipeline.add_stage("test", stage, initialize=False)
result = pipeline.swap_stages("test", "nonexistent")
assert result is False
def test_move_stage_after(self):
"""move_stage() moves stage after another."""
pipeline = Pipeline()
stage_a = self._create_mock_stage("a")
stage_b = self._create_mock_stage("b")
stage_c = self._create_mock_stage("c")
pipeline.add_stage("a", stage_a, initialize=False)
pipeline.add_stage("b", stage_b, initialize=False)
pipeline.add_stage("c", stage_c, initialize=False)
pipeline.build()
result = pipeline.move_stage("a", after="c")
assert result is True
idx_a = pipeline.execution_order.index("a")
idx_c = pipeline.execution_order.index("c")
assert idx_a > idx_c
def test_move_stage_before(self):
"""move_stage() moves stage before another."""
pipeline = Pipeline()
stage_a = self._create_mock_stage("a")
stage_b = self._create_mock_stage("b")
stage_c = self._create_mock_stage("c")
pipeline.add_stage("a", stage_a, initialize=False)
pipeline.add_stage("b", stage_b, initialize=False)
pipeline.add_stage("c", stage_c, initialize=False)
pipeline.build()
result = pipeline.move_stage("c", before="a")
assert result is True
idx_a = pipeline.execution_order.index("a")
idx_c = pipeline.execution_order.index("c")
assert idx_c < idx_a
def test_move_stage_fails_for_nonexistent(self):
"""move_stage() fails if stage doesn't exist."""
pipeline = Pipeline()
stage = self._create_mock_stage("test")
pipeline.add_stage("test", stage, initialize=False)
pipeline.build()
result = pipeline.move_stage("nonexistent", after="test")
assert result is False
def test_move_stage_fails_when_not_initialized(self):
"""move_stage() fails if pipeline not built."""
pipeline = Pipeline()
stage = self._create_mock_stage("test")
pipeline.add_stage("test", stage, initialize=False)
result = pipeline.move_stage("test", after="other")
assert result is False
def test_enable_stage(self):
"""enable_stage() enables a stage."""
pipeline = Pipeline()
stage = self._create_mock_stage("test")
pipeline.add_stage("test", stage, initialize=False)
result = pipeline.enable_stage("test")
assert result is True
stage.set_enabled.assert_called_with(True)
def test_enable_nonexistent_stage_returns_false(self):
"""enable_stage() returns False for nonexistent stage."""
pipeline = Pipeline()
result = pipeline.enable_stage("nonexistent")
assert result is False
def test_disable_stage(self):
"""disable_stage() disables a stage."""
pipeline = Pipeline()
stage = self._create_mock_stage("test")
pipeline.add_stage("test", stage, initialize=False)
result = pipeline.disable_stage("test")
assert result is True
stage.set_enabled.assert_called_with(False)
def test_disable_nonexistent_stage_returns_false(self):
"""disable_stage() returns False for nonexistent stage."""
pipeline = Pipeline()
result = pipeline.disable_stage("nonexistent")
assert result is False
def test_get_stage_info_returns_correct_info(self):
"""get_stage_info() returns correct stage information."""
pipeline = Pipeline()
stage = self._create_mock_stage(
"test_stage",
"effect",
capabilities={"effect.test"},
dependencies={"source"},
)
stage.render_order = 5
stage.is_overlay = False
stage.optional = True
pipeline.add_stage("test", stage, initialize=False)
info = pipeline.get_stage_info("test")
assert info is not None
assert info["name"] == "test" # Dict key, not stage.name
assert info["category"] == "effect"
assert info["stage_type"] == "effect"
assert info["enabled"] is True
assert info["optional"] is True
assert info["capabilities"] == ["effect.test"]
assert info["dependencies"] == ["source"]
assert info["render_order"] == 5
assert info["is_overlay"] is False
def test_get_stage_info_returns_none_for_nonexistent(self):
"""get_stage_info() returns None for nonexistent stage."""
pipeline = Pipeline()
info = pipeline.get_stage_info("nonexistent")
assert info is None
def test_get_pipeline_info_returns_complete_info(self):
"""get_pipeline_info() returns complete pipeline state."""
pipeline = Pipeline()
stage1 = self._create_mock_stage("stage1")
stage2 = self._create_mock_stage("stage2")
pipeline.add_stage("s1", stage1, initialize=False)
pipeline.add_stage("s2", stage2, initialize=False)
pipeline.build()
info = pipeline.get_pipeline_info()
assert "stages" in info
assert "execution_order" in info
assert info["initialized"] is True
assert info["stage_count"] == 2
assert "s1" in info["stages"]
assert "s2" in info["stages"]
def test_rebuild_after_mutation(self):
"""_rebuild() updates execution order after mutation."""
pipeline = Pipeline()
source = self._create_mock_stage(
"source", "source", capabilities={"source"}, dependencies=set()
)
effect = self._create_mock_stage(
"effect", "effect", capabilities={"effect"}, dependencies={"source"}
)
display = self._create_mock_stage(
"display", "display", capabilities={"display"}, dependencies={"effect"}
)
pipeline.add_stage("source", source, initialize=False)
pipeline.add_stage("effect", effect, initialize=False)
pipeline.add_stage("display", display, initialize=False)
pipeline.build()
assert pipeline.execution_order == ["source", "effect", "display"]
pipeline.remove_stage("effect", cleanup=False)
pipeline._rebuild()
assert "effect" not in pipeline.execution_order
assert "source" in pipeline.execution_order
assert "display" in pipeline.execution_order
def test_add_stage_after_build(self):
"""add_stage() can add stage after build with initialization."""
pipeline = Pipeline()
source = self._create_mock_stage(
"source", "source", capabilities={"source"}, dependencies=set()
)
display = self._create_mock_stage(
"display", "display", capabilities={"display"}, dependencies={"source"}
)
pipeline.add_stage("source", source, initialize=False)
pipeline.add_stage("display", display, initialize=False)
pipeline.build()
new_stage = self._create_mock_stage(
"effect", "effect", capabilities={"effect"}, dependencies={"source"}
)
pipeline.add_stage("effect", new_stage, initialize=True)
assert "effect" in pipeline.stages
new_stage.init.assert_called_once()
def test_mutation_preserves_execution_for_remaining_stages(self):
"""Removing a stage doesn't break execution of remaining stages."""
from engine.pipeline.core import DataType
call_log = []
class TestSource(Stage):
name = "source"
category = "source"
@property
def inlet_types(self):
return {DataType.NONE}
@property
def outlet_types(self):
return {DataType.SOURCE_ITEMS}
@property
def capabilities(self):
return {"source"}
@property
def dependencies(self):
return set()
def process(self, data, ctx):
call_log.append("source")
return ["item"]
class TestEffect(Stage):
name = "effect"
category = "effect"
@property
def inlet_types(self):
return {DataType.SOURCE_ITEMS}
@property
def outlet_types(self):
return {DataType.TEXT_BUFFER}
@property
def capabilities(self):
return {"effect"}
@property
def dependencies(self):
return {"source"}
def process(self, data, ctx):
call_log.append("effect")
return data
class TestDisplay(Stage):
name = "display"
category = "display"
@property
def inlet_types(self):
return {DataType.TEXT_BUFFER}
@property
def outlet_types(self):
return {DataType.NONE}
@property
def capabilities(self):
return {"display"}
@property
def dependencies(self):
return {"effect"}
def process(self, data, ctx):
call_log.append("display")
return data
pipeline = Pipeline()
pipeline.add_stage("source", TestSource(), initialize=False)
pipeline.add_stage("effect", TestEffect(), initialize=False)
pipeline.add_stage("display", TestDisplay(), initialize=False)
pipeline.build()
pipeline.initialize()
result = pipeline.execute(None)
assert result.success
assert call_log == ["source", "effect", "display"]
call_log.clear()
pipeline.remove_stage("effect", cleanup=True)
pipeline._rebuild()
result = pipeline.execute(None)
assert result.success
assert call_log == ["source", "display"]