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:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user