diff --git a/src/ess/livedata/dashboard/configuration_adapter.py b/src/ess/livedata/dashboard/configuration_adapter.py index 4d52c96a9..1e3acfe8c 100644 --- a/src/ess/livedata/dashboard/configuration_adapter.py +++ b/src/ess/livedata/dashboard/configuration_adapter.py @@ -136,6 +136,11 @@ def model_class(self) -> type[Model] | None: def source_names(self) -> list[str]: """Available source names.""" + @property + def allow_multiple_sources(self) -> bool: + """Whether multiple sources can be selected simultaneously.""" + return True + @property def initial_source_names(self) -> list[str]: """ diff --git a/src/ess/livedata/dashboard/plot_configuration_adapter.py b/src/ess/livedata/dashboard/plot_configuration_adapter.py index 70b03ab22..7d45b6f6c 100644 --- a/src/ess/livedata/dashboard/plot_configuration_adapter.py +++ b/src/ess/livedata/dashboard/plot_configuration_adapter.py @@ -75,6 +75,11 @@ def source_names(self) -> list[str]: """Get available source names.""" return self._source_names + @property + def allow_multiple_sources(self) -> bool: + """Whether multiple sources can be selected based on plotter requirements.""" + return self._plot_spec.data_requirements.multiple_datasets + def start_action( self, selected_sources: list[str], diff --git a/src/ess/livedata/dashboard/widgets/configuration_widget.py b/src/ess/livedata/dashboard/widgets/configuration_widget.py index 8a2a0f5a8..ff2dd0a3d 100644 --- a/src/ess/livedata/dashboard/widgets/configuration_widget.py +++ b/src/ess/livedata/dashboard/widgets/configuration_widget.py @@ -33,27 +33,47 @@ def __init__(self, config: ConfigurationAdapter) -> None: self._source_error_pane = pn.pane.HTML("", sizing_mode='stretch_width') self._widget = self._create_widget() - def _create_source_selector(self) -> pn.widgets.MultiChoice | None: + def _create_source_selector( + self, + ) -> pn.widgets.MultiChoice | pn.widgets.Select | None: """Create source selection widget, or None if no sources available.""" # No source selector needed when there are no sources (e.g., static overlays) if not self._config.source_names: return None - if ( - not self._config.initial_source_names - and len(self._config.source_names) == 1 - ): - initial_source_names = self._config.source_names + sorted_sources = sorted(self._config.source_names) + + if self._config.allow_multiple_sources: + # MultiChoice for multiple source selection + if ( + not self._config.initial_source_names + and len(self._config.source_names) == 1 + ): + initial_source_names = self._config.source_names + else: + initial_source_names = self._config.initial_source_names + return pn.widgets.MultiChoice( + name="Source Names", + options=sorted_sources, + value=sorted(initial_source_names), + placeholder="Select source names to apply workflow to", + sizing_mode='stretch_width', + margin=(0, 0, 0, 0), + ) else: - initial_source_names = self._config.initial_source_names - return pn.widgets.MultiChoice( - name="Source Names", - options=sorted(self._config.source_names), - value=sorted(initial_source_names), - placeholder="Select source names to apply workflow to", - sizing_mode='stretch_width', - margin=(0, 0, 0, 0), - ) + # Select dropdown for single source selection + initial_source = ( + self._config.initial_source_names[0] + if self._config.initial_source_names + else sorted_sources[0] + ) + return pn.widgets.Select( + name="Source Name", + options=sorted_sources, + value=initial_source, + sizing_mode='stretch_width', + margin=(0, 0, 0, 0), + ) def _create_aux_sources_widget(self) -> ParamWidget | None: """Create auxiliary sources widget using ParamWidget.""" @@ -156,6 +176,10 @@ def selected_sources(self) -> list[str]: """Get the selected source names.""" if self._source_selector is None: return [] + if isinstance(self._source_selector, pn.widgets.Select): + # Single selection returns a string, wrap in list + return [self._source_selector.value] if self._source_selector.value else [] + # MultiChoice returns a list return self._source_selector.value @property diff --git a/tests/dashboard/widgets/configuration_widget_source_selection_test.py b/tests/dashboard/widgets/configuration_widget_source_selection_test.py new file mode 100644 index 000000000..039ee8e95 --- /dev/null +++ b/tests/dashboard/widgets/configuration_widget_source_selection_test.py @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2025 Scipp contributors (https://github.com/scipp) +"""Test source selection behavior in ConfigurationWidget.""" + +import panel as pn +import pydantic +import pytest + +from ess.livedata.dashboard.configuration_adapter import ConfigurationAdapter +from ess.livedata.dashboard.plot_configuration_adapter import PlotConfigurationAdapter +from ess.livedata.dashboard.plotting import DataRequirements, PlotterSpec +from ess.livedata.dashboard.widgets.configuration_widget import ConfigurationWidget + + +class FakeAdapter(ConfigurationAdapter): + """Minimal adapter for testing source selection.""" + + def __init__( + self, + source_names: list[str], + allow_multiple: bool = True, + initial_source_names: list[str] | None = None, + ) -> None: + super().__init__(initial_source_names=initial_source_names) + self._source_names = source_names + self._allow_multiple = allow_multiple + + @property + def title(self) -> str: + return "Test" + + @property + def description(self) -> str: + return "Test description" + + def model_class(self) -> type[pydantic.BaseModel] | None: + return None + + @property + def source_names(self) -> list[str]: + return self._source_names + + @property + def allow_multiple_sources(self) -> bool: + return self._allow_multiple + + def start_action( + self, + selected_sources: list[str], + parameter_values: object, + ) -> None: + pass + + +class TestSourceSelectionWidget: + """Test ConfigurationWidget source selector behavior.""" + + def test_multiple_sources_creates_multichoice(self) -> None: + """When allow_multiple_sources=True, MultiChoice widget is created.""" + adapter = FakeAdapter( + source_names=['source_a', 'source_b', 'source_c'], + allow_multiple=True, + ) + widget = ConfigurationWidget(adapter) + + assert isinstance(widget._source_selector, pn.widgets.MultiChoice) + assert widget._source_selector.name == "Source Names" + + def test_single_source_creates_select(self) -> None: + """When allow_multiple_sources=False, Select widget is created.""" + adapter = FakeAdapter( + source_names=['source_a', 'source_b', 'source_c'], + allow_multiple=False, + ) + widget = ConfigurationWidget(adapter) + + assert isinstance(widget._source_selector, pn.widgets.Select) + assert widget._source_selector.name == "Source Name" + + def test_multichoice_selected_sources_returns_list(self) -> None: + """MultiChoice selected_sources returns the list directly.""" + adapter = FakeAdapter( + source_names=['source_a', 'source_b'], + allow_multiple=True, + initial_source_names=['source_a'], + ) + widget = ConfigurationWidget(adapter) + + assert widget.selected_sources == ['source_a'] + + def test_select_selected_sources_returns_list(self) -> None: + """Select selected_sources wraps single value in list.""" + adapter = FakeAdapter( + source_names=['source_a', 'source_b'], + allow_multiple=False, + initial_source_names=['source_a'], + ) + widget = ConfigurationWidget(adapter) + + # Select returns single value, but selected_sources should be a list + assert widget.selected_sources == ['source_a'] + assert isinstance(widget.selected_sources, list) + + def test_select_uses_first_initial_source(self) -> None: + """Select widget uses first initial source name as value.""" + adapter = FakeAdapter( + source_names=['source_a', 'source_b', 'source_c'], + allow_multiple=False, + initial_source_names=['source_b'], + ) + widget = ConfigurationWidget(adapter) + + assert widget._source_selector.value == 'source_b' + + def test_select_defaults_to_first_initial_source(self) -> None: + """Select widget uses first of initial_source_names when no explicit initial. + + When initial_source_names is None, ConfigurationAdapter returns all sources + as initial, so the first one (unsorted) gets selected. + """ + adapter = FakeAdapter( + source_names=['source_c', 'source_a', 'source_b'], + allow_multiple=False, + initial_source_names=None, + ) + widget = ConfigurationWidget(adapter) + + # initial_source_names returns all sources when not specified, + # first one is 'source_c' + assert widget._source_selector.value == 'source_c' + + def test_no_sources_creates_no_selector(self) -> None: + """When no sources available, no selector is created.""" + adapter = FakeAdapter( + source_names=[], + allow_multiple=True, + ) + widget = ConfigurationWidget(adapter) + + assert widget._source_selector is None + assert widget.selected_sources == [] + + +class TestPlotConfigurationAdapterMultipleSources: + """Test PlotConfigurationAdapter.allow_multiple_sources.""" + + @pytest.fixture + def make_plot_spec(self): + """Factory for creating PlotterSpec with configurable multiple_datasets.""" + + class EmptyParams(pydantic.BaseModel): + pass + + def _make_spec(multiple_datasets: bool) -> PlotterSpec: + return PlotterSpec( + name='test_plotter', + title='Test Plotter', + description='Test plotter description', + params=EmptyParams, + data_requirements=DataRequirements( + min_dims=2, + max_dims=2, + multiple_datasets=multiple_datasets, + ), + ) + + return _make_spec + + def test_allow_multiple_sources_true_when_multiple_datasets_true( + self, make_plot_spec + ) -> None: + """allow_multiple_sources is True when multiple_datasets is True.""" + spec = make_plot_spec(multiple_datasets=True) + adapter = PlotConfigurationAdapter( + plot_spec=spec, + source_names=['a', 'b'], + success_callback=lambda *args: None, + ) + + assert adapter.allow_multiple_sources is True + + def test_allow_multiple_sources_false_when_multiple_datasets_false( + self, make_plot_spec + ) -> None: + """allow_multiple_sources is False when multiple_datasets is False.""" + spec = make_plot_spec(multiple_datasets=False) + adapter = PlotConfigurationAdapter( + plot_spec=spec, + source_names=['a', 'b'], + success_callback=lambda *args: None, + ) + + assert adapter.allow_multiple_sources is False + + +class TestConfigurationAdapterDefaultBehavior: + """Test default allow_multiple_sources behavior.""" + + def test_default_allow_multiple_sources_is_true(self) -> None: + """ConfigurationAdapter.allow_multiple_sources defaults to True.""" + + class MinimalAdapter(ConfigurationAdapter): + @property + def title(self) -> str: + return "Test" + + @property + def description(self) -> str: + return "" + + def model_class(self) -> type[pydantic.BaseModel] | None: + return None + + @property + def source_names(self) -> list[str]: + return [] + + def start_action(self, selected_sources, parameter_values) -> None: + pass + + adapter = MinimalAdapter() + assert adapter.allow_multiple_sources is True