Skip to content

Commit 5cf064e

Browse files
committed
test(plugins): load every shipped example plugin in DataLab (#415)
Examples were only imported, so registering their processing could fail unnoticed. A new application test loads every plugin of `plugins/examples` in DataLab from a plugin directory, checks their stable IDs and the menu of the custom function example, and runs its processing on an image, also after reloading the plugins. It fails without the fix. Unit tests cover wrapped NumPy functions registered as signal and image 1-to-1 features, and their refusal for other patterns, as well as the refusal of plain functions. Assisted-by: Claude Opus 5.5
1 parent 1b4a52c commit 5cf064e

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

‎datalab/tests/backbone/processor_registry_unit_test.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
from __future__ import annotations
66

7+
import numpy as np
78
import pytest
9+
import sigima.proc.image as sipi
810
import sigima.proc.signal as sips
911

1012
from datalab.gui.processor.base import BaseProcessor, ComputingFeature
@@ -146,3 +148,46 @@ def test_implicit_plugin_feature_id_is_namespaced() -> None:
146148

147149
assert feature.feature_id == "org.example.plugin:derivative"
148150
assert processor.get_feature("derivative") is feature
151+
152+
153+
def positive_part(values: np.ndarray) -> np.ndarray:
154+
"""NumPy function of a plugin, wrapped to process signals or images."""
155+
return np.maximum(values, 0.0)
156+
157+
158+
@pytest.mark.parametrize("module", [sips, sipi], ids=["signal", "image"])
159+
def test_wrapped_numpy_function_is_a_1_to_1_feature(module) -> None:
160+
"""Plugins may register a NumPy function wrapped with ``Wrap1to1Func``."""
161+
processor = ProcessorRegistryHarness()
162+
function = module.Wrap1to1Func(positive_part)
163+
feature = ComputingFeature(
164+
pattern="1_to_1",
165+
function=function,
166+
title="Positive part",
167+
)
168+
169+
processor.add_feature(feature, owner="org.example.plugin")
170+
171+
assert feature.name == "positive_part"
172+
assert feature.feature_id == "org.example.plugin:positive_part"
173+
assert processor.get_feature(function) is feature
174+
175+
176+
@pytest.mark.parametrize("pattern", ["1_to_0", "1_to_n", "n_to_1", "2_to_1"])
177+
def test_wrapped_numpy_function_is_only_a_1_to_1_feature(pattern: str) -> None:
178+
"""``Wrap1to1Func`` produces one object from one object only."""
179+
with pytest.raises(ValueError, match="only applies to 1-to-1 features"):
180+
ComputingFeature(
181+
pattern=pattern,
182+
function=sips.Wrap1to1Func(positive_part),
183+
title="Positive part",
184+
)
185+
186+
187+
def test_plain_function_is_refused_with_accepted_forms() -> None:
188+
"""Other callables are refused when registered, naming the accepted forms."""
189+
with pytest.raises(ValueError, match="'positive_part' is not a valid") as error:
190+
ComputingFeature(pattern="1_to_1", function=positive_part, title="Positive")
191+
192+
assert "computation_function()" in str(error.value)
193+
assert "Wrap1to1Func" in str(error.value)

‎datalab/tests/features/plugins/plugins_app_test.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@
1414
import importlib.util
1515
import os
1616
import os.path as osp
17+
import shutil
1718
from contextlib import contextmanager
1819
from pathlib import Path
1920
from unittest.mock import patch
2021

22+
import numpy as np
2123
import pytest
2224
from qtpy import QtWidgets as QW
25+
from sigima.objects import create_image
2326
from sigima.tests.data import create_paracetamol_signal
2427

2528
from datalab.config import Conf
@@ -814,6 +817,77 @@ def test_plugin_examples_import() -> None:
814817
spec.loader.exec_module(module)
815818

816819

820+
#: Shipped example plugins, by module, with the stable ID of their plugin class
821+
#: (the image format example only declares a file format)
822+
SHIPPED_EXAMPLES = {
823+
"datalab_custom_func": "org.datalab.examples.custom-filters",
824+
"datalab_example_dialogs": "org.datalab.examples.dialog-methods",
825+
"datalab_example_empty": "org.datalab.examples.empty",
826+
"datalab_example_imageformat": None,
827+
"datalab_example_imageproc": "org.datalab.examples.extract-blobs",
828+
"datalab_example_nested_menus": "org.datalab.examples.nested-menus",
829+
"datalab_exemple_contour_plot": "org.datalab.examples.contour-plot",
830+
}
831+
832+
833+
def _run_custom_filter(win: DLMainWindow) -> np.ndarray:
834+
"""Run the processing of the custom function example on a new image.
835+
836+
Returns:
837+
Data of the processed image
838+
"""
839+
menus = [
840+
item
841+
for item in win.imagepanel.get_category_actions(ActionCategory.PLUGINS)
842+
if isinstance(item, QW.QMenu) and item.title() == "My custom filters"
843+
]
844+
assert len(menus) == 1, "'My custom filters' menu not found"
845+
menus[0].aboutToShow.emit()
846+
assert [action.text() for action in menus[0].actions()] == [
847+
"Weighted average denoise"
848+
]
849+
data = np.arange(64, dtype=float).reshape(8, 8)
850+
win.imagepanel.add_object(create_image("Ramp", data))
851+
count = len(win.imagepanel)
852+
win.imagepanel.processor.run_feature(
853+
"org.datalab.examples.custom-filters:weighted-average-denoise"
854+
)
855+
assert len(win.imagepanel) == count + 1
856+
return win.imagepanel[len(win.imagepanel)].data
857+
858+
859+
@enabled_plugins_context()
860+
def test_shipped_plugin_examples_load_in_datalab() -> None:
861+
"""All shipped examples load in DataLab, and their processing runs."""
862+
examples_dir = Path(__file__).parents[4] / "plugins" / "examples"
863+
assert {path.stem for path in examples_dir.glob("*.py")} == set(SHIPPED_EXAMPLES)
864+
with temporary_plugin_dir(module_prefixes=tuple(SHIPPED_EXAMPLES)) as plugin_dir:
865+
for module_name in SHIPPED_EXAMPLES:
866+
shutil.copy(examples_dir / f"{module_name}.py", plugin_dir)
867+
spec = importlib.util.spec_from_file_location(
868+
"custom_func_reference", examples_dir / "datalab_custom_func.py"
869+
)
870+
reference = importlib.util.module_from_spec(spec)
871+
spec.loader.exec_module(reference)
872+
expected = reference.weighted_average_denoise(
873+
np.arange(64, dtype=float).reshape(8, 8)
874+
)
875+
876+
with datalab_test_app_context(console=False) as win:
877+
for _reload in range(2):
878+
failed = [
879+
failed.name
880+
for failed in PluginRegistry.get_failed_plugins()
881+
if failed.name.split(".")[0] in SHIPPED_EXAMPLES
882+
]
883+
assert not failed, f"Example plugins failed to load: {failed}"
884+
for plugin_id in filter(None, SHIPPED_EXAMPLES.values()):
885+
assert PluginRegistry.get_plugin(plugin_id) is not None, plugin_id
886+
assert np.allclose(_run_custom_filter(win), expected)
887+
win.reload_plugins()
888+
QW.QApplication.processEvents()
889+
890+
817891
if __name__ == "__main__":
818892
_launch_path = osp.join(osp.dirname(__file__), "launch_with_test_plugins.py")
819893
_spec = importlib.util.spec_from_file_location(

0 commit comments

Comments
 (0)