Skip to content
4 changes: 2 additions & 2 deletions OTAnalytics/adapter_ui/dummy_viewmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion OTAnalytics/adapter_ui/view_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion OTAnalytics/plugin_ui/customtkinter_gui/frame_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections.abc import Callable

from nicegui import ui
from nicegui.elements.button import Button
from nicegui.events import ValueChangeEventArguments
Expand Down Expand Up @@ -140,6 +142,74 @@ 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, resource_manager: ResourceManager, viewmodel: ViewModel) -> None:
self._resource_manager = resource_manager
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(
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:
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(self._selected_classes)
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):

def __init__(
Expand All @@ -151,7 +221,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
Expand Down Expand Up @@ -180,6 +249,9 @@ def __init__(
self._filter_by_date_popup = FilterDateRangeForm(
self._resource_manager, 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
Expand Down Expand Up @@ -320,33 +392,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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@
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.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": ""}

Expand All @@ -25,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
Expand Down Expand Up @@ -83,3 +90,122 @@ 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]


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(
set(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(set())

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

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
Loading