From ca6a7d232ac04a1ffc49b24f2f798c923a492437 Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Tue, 13 Oct 2026 13:43:45 +0200 Subject: [PATCH 1/6] Add tests for class filter dialog selection in WebUI The tests describe the expected behaviour of the class filter dialog: each checkbox toggles only its own class, an unchanged partial selection is applied as is, and without a selection all classes are checked. They fail until the dialog is fixed in the next commit. Co-Authored-By: Claude Opus 5.5 --- .../test_container.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py index af20070d2..68008a7f3 100644 --- a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py @@ -7,6 +7,8 @@ from unittest.mock import Mock import pytest +from nicegui import ui +from nicegui.testing import User from OTAnalytics.adapter_ui.dto import DateRangeDto from OTAnalytics.plugin_ui.nicegui_gui.pages.visualization_filters_form.container import ( # noqa @@ -83,3 +85,104 @@ def test_offers_no_general_buttons_yet(self) -> None: target = create_target(create_given()) assert target.get_general_buttons() == [] + + +BICYCLE = "bicycle" +CAR = "car" +TRUCK = "truck" +ALL_CLASSES = [BICYCLE, CAR, TRUCK] +APPLY = "Apply" + + +async def open_classification_dialog( + user: User, given: Given, selection: list[str] | None +) -> None: + given.view_model.get_classes.return_value = ALL_CLASSES + given.view_model.get_class_filter_selection.return_value = selection + target = create_target(given) + + @ui.page("/test") + def page() -> None: + target.open_classification_dialog() + + await user.open("/test") + await user.should_see(APPLY) + + +def checked_classes(user: User) -> list[str]: + return sorted( + checkbox.text + for checkbox in user.find(kind=ui.checkbox).elements + if checkbox.value + ) + + +class TestClassificationDialog: + async def test_toggling_a_checkbox_changes_only_its_own_class( + self, user: User + ) -> None: + given = create_given() + await open_classification_dialog(user, given, selection=None) + + user.find(kind=ui.checkbox, content=BICYCLE).click() + assert checked_classes(user) == [CAR, TRUCK] + user.find(APPLY).click() + + given.view_model.apply_filter_tracks_by_class.assert_called_once_with( + [CAR, TRUCK] + ) + + async def test_checking_an_unselected_class_adds_only_that_class( + self, user: User + ) -> None: + given = create_given() + await open_classification_dialog(user, given, selection=[CAR]) + + user.find(kind=ui.checkbox, content=TRUCK).click() + user.find(APPLY).click() + + given.view_model.apply_filter_tracks_by_class.assert_called_once_with( + [CAR, TRUCK] + ) + + async def test_applying_unchanged_partial_selection_keeps_it( + self, user: User + ) -> None: + given = create_given() + await open_classification_dialog(user, given, selection=[CAR, TRUCK]) + + assert checked_classes(user) == [CAR, TRUCK] + user.find(APPLY).click() + + given.view_model.apply_filter_tracks_by_class.assert_called_once_with( + [CAR, TRUCK] + ) + + async def test_without_selection_all_classes_are_selected(self, user: User) -> None: + given = create_given() + await open_classification_dialog(user, given, selection=None) + + assert checked_classes(user) == ALL_CLASSES + user.find(APPLY).click() + + given.view_model.apply_filter_tracks_by_class.assert_called_once_with( + ALL_CLASSES + ) + + async def test_empty_selection_selects_no_class(self, user: User) -> None: + given = create_given() + await open_classification_dialog(user, given, selection=[]) + + assert checked_classes(user) == [] + user.find(APPLY).click() + + given.view_model.apply_filter_tracks_by_class.assert_called_once_with([]) + + async def test_apply_closes_the_dialog(self, user: User) -> None: + given = create_given() + await open_classification_dialog(user, given, selection=None) + + user.find(APPLY).click() + + dialog = user.find(kind=ui.dialog).elements.pop() + assert dialog.value is False From db670aef79f85cd667e4821a136d4001de36389b Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Tue, 13 Oct 2026 13:43:49 +0200 Subject: [PATCH 2/6] Fix class filter dialog selection in WebUI The dialog never rendered its checkboxes, bound every checkbox to the last class created in the loop and always started from all classes, so "Apply" also passed classes that were not selected. Move the dialog into FilterClassificationForm, which derives its state from the current class filter selection (all classes if there is none, as in the customtkinter GUI), binds each checkbox to its own class and evaluates the checkbox value instead of toggling. Buttons are built in a dedicated method so further buttons can be added easily. Co-Authored-By: Claude Opus 5.5 --- .../visualization_filters_form/container.py | 94 +++++++++++++------ 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py index 54423a2bb..13cbe290a 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py @@ -1,3 +1,5 @@ +from typing import Callable + from nicegui import ui from nicegui.elements.button import Button from nicegui.events import ValueChangeEventArguments @@ -140,6 +142,68 @@ def _on_reset_button_clicked(self) -> None: self._dialog.close() +class FilterClassificationForm: + """Dialog to select the classes whose tracks are shown. + + The checkboxes show the current class filter selection. Without a selection, + every class counts as selected, as in the customtkinter GUI. + """ + + def __init__(self, viewmodel: ViewModel) -> None: + self._viewmodel = viewmodel + self._classes: list[str] = [] + self._selected_classes: set[str] = set() + + def open(self) -> None: + self._classes = self._viewmodel.get_classes() + self._selected_classes = set(self._current_selection()) + with ui.dialog() as self._dialog, ui.card(): + for classification in self._classes: + self._build_checkbox(classification) + with ui.row(): + self._build_buttons() + self._dialog.open() + + def _current_selection(self) -> list[str]: + selection = self._viewmodel.get_class_filter_selection() + return self._classes if selection is None else selection + + def _build_checkbox(self, classification: str) -> None: + FormFieldCheckbox( + label_text=classification, + initial_value=classification in self._selected_classes, + on_value_change=self._create_class_toggle(classification), + ).build() + + def _create_class_toggle( + self, classification: str + ) -> Callable[[ValueChangeEventArguments], None]: + def toggle(event: ValueChangeEventArguments) -> None: + self._select_class(classification, event.value) + + return toggle + + def _build_buttons(self) -> None: + ui.button("Apply", on_click=self._on_apply_button_clicked) + + def _select_class(self, classification: str, selected: bool) -> None: + if selected: + self._selected_classes.add(classification) + else: + self._selected_classes.discard(classification) + + def _on_apply_button_clicked(self) -> None: + self._viewmodel.apply_filter_tracks_by_class( + [ + classification + for classification in self._classes + if classification in self._selected_classes + ] + ) + logger().info("Filter tracks by classification applied") + self._dialog.close() + + class VisualizationFiltersForm(AbstractFrameFilter, ButtonForm): def __init__( @@ -151,7 +215,6 @@ def __init__( self._viewmodel = view_model self._filter_by_date: ui.checkbox self._filter_by_class: ui.checkbox - self._filter_by_class_list: list = [] self._button_previous_second: ui.button | None = None self._button_previous_frame: ui.button | None = None self._button_previous_event: ui.button | None = None @@ -180,6 +243,7 @@ def __init__( self._filter_by_date_popup = FilterDateRangeForm( self._resource_manager, self._viewmodel ) + self._filter_by_class_popup = FilterClassificationForm(self._viewmodel) self._input_seconds = FormFieldInteger( label_text=self._resource_manager.get( VisualizationFiltersKeys.LABEL_SECONDS @@ -320,33 +384,7 @@ def on_input_change(self, _: ValueChangeEventArguments) -> None: ) def open_classification_dialog(self) -> None: - self._filter_by_class_list = self._viewmodel.get_classes() - with ui.dialog() as dialog, ui.card(): - for classification_class in self._filter_by_class_list: - FormFieldCheckbox( - label_text=classification_class, - initial_value=classification_class in self._selected_classes(), - on_value_change=lambda e: self.remove_or_add_class( - classification_class - ), - ) - ui.button("Apply", on_click=lambda e: self.apply_new_classification(dialog)) - dialog.open() - - def _selected_classes(self) -> list[str]: - selected_classes = self._viewmodel.get_class_filter_selection() - return selected_classes if selected_classes else self._viewmodel.get_classes() - - def apply_new_classification(self, dialog: ui.dialog) -> None: - self._viewmodel.apply_filter_tracks_by_class(self._filter_by_class_list) - self._filter_by_class_list = [] - dialog.close() - - def remove_or_add_class(self, class_name: str) -> None: - if class_name in self._filter_by_class_list: - self._filter_by_class_list.remove(class_name) - else: - self._filter_by_class_list.append(class_name) + self._filter_by_class_popup.open() def open_range_dialog(self) -> None: self._filter_by_date_popup.open() From fc4eb81777c56c2bd2e29e392654477baf52c51d Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Tue, 13 Oct 2026 13:45:43 +0200 Subject: [PATCH 3/6] Add tests for reset button in WebUI class filter dialog Co-Authored-By: Claude Opus 5.5 --- .../test_container.py | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py index 68008a7f3..ca8df8ee7 100644 --- a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py @@ -11,10 +11,15 @@ from nicegui.testing import User from OTAnalytics.adapter_ui.dto import DateRangeDto +from OTAnalytics.application.resources.resource_manager import GeneralKeys from OTAnalytics.plugin_ui.nicegui_gui.pages.visualization_filters_form.container import ( # noqa VisualizationFiltersForm, ) +APPLY = "Apply from resources" +RESET = "Reset from resources" +LABELS = {GeneralKeys.LABEL_APPLY: APPLY, GeneralKeys.LABEL_RESET: RESET} + A_RANGE: DateRangeDto = {"start_date": "2023-05-24", "end_date": "2023-05-25"} NO_RANGE: DateRangeDto = {"start_date": "", "end_date": ""} @@ -27,7 +32,7 @@ class Given: def create_given() -> Given: resource_manager = Mock() - resource_manager.get.return_value = "label" + resource_manager.get.side_effect = lambda key: LABELS.get(key, "label") view_model = Mock() view_model.get_skip_seconds.return_value = 1 view_model.get_skip_frames.return_value = 1 @@ -91,7 +96,6 @@ def test_offers_no_general_buttons_yet(self) -> None: CAR = "car" TRUCK = "truck" ALL_CLASSES = [BICYCLE, CAR, TRUCK] -APPLY = "Apply" async def open_classification_dialog( @@ -186,3 +190,22 @@ async def test_apply_closes_the_dialog(self, user: User) -> None: dialog = user.find(kind=ui.dialog).elements.pop() assert dialog.value is False + + async def test_shows_reset_label_from_resources(self, user: User) -> None: + given = create_given() + await open_classification_dialog(user, given, selection=None) + + await user.should_see(RESET) + + async def test_reset_resets_the_class_filter_and_closes_the_dialog( + self, user: User + ) -> None: + given = create_given() + await open_classification_dialog(user, given, selection=[CAR]) + + user.find(RESET).click() + + given.view_model.reset_filter_tracks_by_class.assert_called_once_with() + given.view_model.apply_filter_tracks_by_class.assert_not_called() + dialog = user.find(kind=ui.dialog).elements.pop() + assert dialog.value is False From b99d75636867608e8b850bde05ebe41f964f9611 Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Tue, 13 Oct 2026 13:46:33 +0200 Subject: [PATCH 4/6] Add reset button to WebUI class filter dialog The class filter dialog now offers a Reset button that resets the class filter via the view model and closes the dialog, as in the customtkinter GUI. Apply and Reset take their labels from the ResourceManager. Co-Authored-By: Claude Opus 5.5 --- .../visualization_filters_form/container.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py index 13cbe290a..02a4e856b 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py @@ -149,7 +149,8 @@ class FilterClassificationForm: every class counts as selected, as in the customtkinter GUI. """ - def __init__(self, viewmodel: ViewModel) -> None: + def __init__(self, resource_manager: ResourceManager, viewmodel: ViewModel) -> None: + self._resource_manager = resource_manager self._viewmodel = viewmodel self._classes: list[str] = [] self._selected_classes: set[str] = set() @@ -184,7 +185,14 @@ def toggle(event: ValueChangeEventArguments) -> None: return toggle def _build_buttons(self) -> None: - ui.button("Apply", on_click=self._on_apply_button_clicked) + ui.button( + self._resource_manager.get(GeneralKeys.LABEL_APPLY), + on_click=self._on_apply_button_clicked, + ) + ui.button( + self._resource_manager.get(GeneralKeys.LABEL_RESET), + on_click=self._on_reset_button_clicked, + ) def _select_class(self, classification: str, selected: bool) -> None: if selected: @@ -203,6 +211,10 @@ def _on_apply_button_clicked(self) -> None: logger().info("Filter tracks by classification applied") self._dialog.close() + def _on_reset_button_clicked(self) -> None: + self._viewmodel.reset_filter_tracks_by_class() + self._dialog.close() + class VisualizationFiltersForm(AbstractFrameFilter, ButtonForm): @@ -243,7 +255,9 @@ def __init__( self._filter_by_date_popup = FilterDateRangeForm( self._resource_manager, self._viewmodel ) - self._filter_by_class_popup = FilterClassificationForm(self._viewmodel) + self._filter_by_class_popup = FilterClassificationForm( + self._resource_manager, self._viewmodel + ) self._input_seconds = FormFieldInteger( label_text=self._resource_manager.get( VisualizationFiltersKeys.LABEL_SECONDS From d1634bfe4bbbc8965e26914d3c20265c7a5b55af Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Thu, 15 Oct 2026 09:19:54 +0200 Subject: [PATCH 5/6] OP#10513: Fix review comments. --- .../pages/visualization_filters_form/container.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py index 02a4e856b..76cb86d1a 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py @@ -1,4 +1,4 @@ -from typing import Callable +from collections.abc import Callable from nicegui import ui from nicegui.elements.button import Button @@ -201,13 +201,7 @@ def _select_class(self, classification: str, selected: bool) -> None: self._selected_classes.discard(classification) def _on_apply_button_clicked(self) -> None: - self._viewmodel.apply_filter_tracks_by_class( - [ - classification - for classification in self._classes - if classification in self._selected_classes - ] - ) + self._viewmodel.apply_filter_tracks_by_class(list(self._selected_classes)) logger().info("Filter tracks by classification applied") self._dialog.close() From f53c5b4f5c511b1d29d2273964242d44ad008eab Mon Sep 17 00:00:00 2001 From: Jan <83372587+schubsen@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:40:38 +0100 Subject: [PATCH 6/6] Apply class filter on set of selected classes instead of list --- OTAnalytics/adapter_ui/dummy_viewmodel.py | 4 ++-- OTAnalytics/adapter_ui/view_model.py | 2 +- .../plugin_ui/customtkinter_gui/frame_filter.py | 2 +- .../pages/visualization_filters_form/container.py | 2 +- .../pages/visualization_filters_form/test_container.py | 10 +++++----- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/OTAnalytics/adapter_ui/dummy_viewmodel.py b/OTAnalytics/adapter_ui/dummy_viewmodel.py index 0d55a7614..676a2ed42 100644 --- a/OTAnalytics/adapter_ui/dummy_viewmodel.py +++ b/OTAnalytics/adapter_ui/dummy_viewmodel.py @@ -1605,8 +1605,8 @@ def apply_filter_tracks_by_date(self, date_range: DateRange) -> None: self._application.update_date_range_tracks_filter(date_range) self.frame_filter.set_active_color_on_filter_by_date_button() - def apply_filter_tracks_by_class(self, classes: list[str]) -> None: - self._application.update_class_tracks_filter(set(classes)) + def apply_filter_tracks_by_class(self, classes: set[str]) -> None: + self._application.update_class_tracks_filter(classes) self.frame_filter.set_active_color_on_filter_by_class_button() def reset_filter_tracks_by_date(self) -> None: diff --git a/OTAnalytics/adapter_ui/view_model.py b/OTAnalytics/adapter_ui/view_model.py index b93821f8f..1f59f2259 100644 --- a/OTAnalytics/adapter_ui/view_model.py +++ b/OTAnalytics/adapter_ui/view_model.py @@ -294,7 +294,7 @@ def apply_filter_tracks_by_date(self, date_range: DateRange) -> None: pass @abstractmethod - def apply_filter_tracks_by_class(self, classes: list[str]) -> None: + def apply_filter_tracks_by_class(self, classes: set[str]) -> None: pass @abstractmethod diff --git a/OTAnalytics/plugin_ui/customtkinter_gui/frame_filter.py b/OTAnalytics/plugin_ui/customtkinter_gui/frame_filter.py index e6d1b0f06..d07656afc 100644 --- a/OTAnalytics/plugin_ui/customtkinter_gui/frame_filter.py +++ b/OTAnalytics/plugin_ui/customtkinter_gui/frame_filter.py @@ -778,7 +778,7 @@ def _close(self, _: Any = None) -> None: def _on_apply_button_clicked(self) -> None: self._viewmodel.apply_filter_tracks_by_class( - list(self.treeview_classes.selection()) + set(self.treeview_classes.selection()) ) logger().info("Filter tracks by classification applied") self._close() diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py index 76cb86d1a..b08d231ce 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/container.py @@ -201,7 +201,7 @@ def _select_class(self, classification: str, selected: bool) -> None: self._selected_classes.discard(classification) def _on_apply_button_clicked(self) -> None: - self._viewmodel.apply_filter_tracks_by_class(list(self._selected_classes)) + self._viewmodel.apply_filter_tracks_by_class(self._selected_classes) logger().info("Filter tracks by classification applied") self._dialog.close() diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py index ca8df8ee7..760c7cec5 100644 --- a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/visualization_filters_form/test_container.py @@ -133,7 +133,7 @@ async def test_toggling_a_checkbox_changes_only_its_own_class( user.find(APPLY).click() given.view_model.apply_filter_tracks_by_class.assert_called_once_with( - [CAR, TRUCK] + {CAR, TRUCK} ) async def test_checking_an_unselected_class_adds_only_that_class( @@ -146,7 +146,7 @@ async def test_checking_an_unselected_class_adds_only_that_class( user.find(APPLY).click() given.view_model.apply_filter_tracks_by_class.assert_called_once_with( - [CAR, TRUCK] + {CAR, TRUCK} ) async def test_applying_unchanged_partial_selection_keeps_it( @@ -159,7 +159,7 @@ async def test_applying_unchanged_partial_selection_keeps_it( user.find(APPLY).click() given.view_model.apply_filter_tracks_by_class.assert_called_once_with( - [CAR, TRUCK] + {CAR, TRUCK} ) async def test_without_selection_all_classes_are_selected(self, user: User) -> None: @@ -170,7 +170,7 @@ async def test_without_selection_all_classes_are_selected(self, user: User) -> N user.find(APPLY).click() given.view_model.apply_filter_tracks_by_class.assert_called_once_with( - ALL_CLASSES + set(ALL_CLASSES) ) async def test_empty_selection_selects_no_class(self, user: User) -> None: @@ -180,7 +180,7 @@ async def test_empty_selection_selects_no_class(self, user: User) -> None: assert checked_classes(user) == [] user.find(APPLY).click() - given.view_model.apply_filter_tracks_by_class.assert_called_once_with([]) + given.view_model.apply_filter_tracks_by_class.assert_called_once_with(set()) async def test_apply_closes_the_dialog(self, user: User) -> None: given = create_given()