diff --git a/docs/user-guide/gui.md b/docs/user-guide/gui.md index 6949314e..f03c7677 100644 --- a/docs/user-guide/gui.md +++ b/docs/user-guide/gui.md @@ -28,6 +28,7 @@ Right-click a video name to open a context menu: - **Get Info:** Open a dialog showing details about the video and its associated pose file. - **Copy Video Name:** Copy the video's filename to the clipboard. +- **Classify Video:** Run the trained classifier for the current behavior on just this video, rather than every video the way the **Classify** button does. This item is only available once a classifier has been trained for the current behavior (if none is ready, JABS prompts you to train one first). When classification finishes, JABS displays the new predictions, switching to the video if it is not already the active one. - **Exclude from Training:** Toggle whether the video is held out of classifier training (unchecked by default). When a video is excluded: - its labels are **not** used to train the classifier, and - it does **not** count toward the labeling thresholds that enable the **Train** button, so excluding too many videos can disable training. @@ -39,7 +40,7 @@ Right-click a video name to open a context menu: JABS Classifier Controls - **Train Button:** Train the classifier with the current parameters. This button is disabled until minimum number of frames have been labeled for a minimum number of mice (increasing the cross validation k parameter increases the minimum number of labeled mice). When training completes, a training report dialog will display performance metrics including cross-validation results and feature importance rankings. -- **Classify Button:** Infer class of unlabeled frames. Disabled until classifier is trained. Changing classifier parameters may require retraining before the Classify button becomes active again. +- **Classify Button:** Infer class of unlabeled frames for every video in the project. Disabled until classifier is trained. Changing classifier parameters may require retraining before the Classify button becomes active again. To classify a single video instead, use **Classify Video** in the video list's right-click menu. - **Classifier Type Selection:** Users can select from a list of supported classifiers. - **Window Size Selection:** Number of frames on each side of the current frame to include in window feature calculations for that frame. A "window size" of 5 means that 11 frames are included into the window feature calculations for each frame (5 previous frames, current frame, 5 following frames). - **New Window Size:** Add a new window size to the project. diff --git a/src/jabs/resources/docs/user_guide/gui.md b/src/jabs/resources/docs/user_guide/gui.md index 6949314e..f03c7677 100644 --- a/src/jabs/resources/docs/user_guide/gui.md +++ b/src/jabs/resources/docs/user_guide/gui.md @@ -28,6 +28,7 @@ Right-click a video name to open a context menu: - **Get Info:** Open a dialog showing details about the video and its associated pose file. - **Copy Video Name:** Copy the video's filename to the clipboard. +- **Classify Video:** Run the trained classifier for the current behavior on just this video, rather than every video the way the **Classify** button does. This item is only available once a classifier has been trained for the current behavior (if none is ready, JABS prompts you to train one first). When classification finishes, JABS displays the new predictions, switching to the video if it is not already the active one. - **Exclude from Training:** Toggle whether the video is held out of classifier training (unchecked by default). When a video is excluded: - its labels are **not** used to train the classifier, and - it does **not** count toward the labeling thresholds that enable the **Train** button, so excluding too many videos can disable training. @@ -39,7 +40,7 @@ Right-click a video name to open a context menu: JABS Classifier Controls - **Train Button:** Train the classifier with the current parameters. This button is disabled until minimum number of frames have been labeled for a minimum number of mice (increasing the cross validation k parameter increases the minimum number of labeled mice). When training completes, a training report dialog will display performance metrics including cross-validation results and feature importance rankings. -- **Classify Button:** Infer class of unlabeled frames. Disabled until classifier is trained. Changing classifier parameters may require retraining before the Classify button becomes active again. +- **Classify Button:** Infer class of unlabeled frames for every video in the project. Disabled until classifier is trained. Changing classifier parameters may require retraining before the Classify button becomes active again. To classify a single video instead, use **Classify Video** in the video list's right-click menu. - **Classifier Type Selection:** Users can select from a list of supported classifiers. - **Window Size Selection:** Number of frames on each side of the current frame to include in window feature calculations for that frame. A "window size" of 5 means that 11 frames are included into the window feature calculations for each frame (5 previous frames, current frame, 5 following frames). - **New Window Size:** Add a new window size to the project. diff --git a/src/jabs/scripts/cli/cli.py b/src/jabs/scripts/cli/cli.py index 4d920658..61715397 100644 --- a/src/jabs/scripts/cli/cli.py +++ b/src/jabs/scripts/cli/cli.py @@ -33,7 +33,7 @@ from .update_pose import update_pose_command # find out which classifiers are supported in this environment -CLASSIFIER_CHOICES: list[ClassifierType] = Classifier().classifier_choices() +CLASSIFIER_CHOICES = Classifier().classifier_choices() DEFAULT_CLASSIFIER: str = ( ClassifierType.XGBOOST.value.lower() if ClassifierType.XGBOOST in CLASSIFIER_CHOICES diff --git a/src/jabs/ui/classification_thread.py b/src/jabs/ui/classification_thread.py index 42748a0d..40ff4edd 100644 --- a/src/jabs/ui/classification_thread.py +++ b/src/jabs/ui/classification_thread.py @@ -42,6 +42,9 @@ class ClassifyThread(QThread): project (Project): The project containing data and settings. behavior (str): The behavior label to classify. current_video (str): The video currently loaded in the video player. + videos (list[str] | None, optional): Restrict classification to these video + filenames. When ``None`` (the default) every video in the project is + classified, preserving the "classify all" behavior. parent (QWidget or None, optional): Optional parent widget. Note: @@ -62,6 +65,7 @@ def __init__( project: Project, behavior: str, current_video: str, + videos: list[str] | None = None, parent: QWidget | None = None, ): super().__init__(parent=parent) @@ -70,6 +74,7 @@ def __init__( self._behavior = behavior self._tasks_complete = 0 self._current_video = current_video + self._videos = videos self._should_terminate = False def request_termination(self) -> None: @@ -118,7 +123,10 @@ def check_termination_requested() -> None: strategy = self._build_strategy() project_settings = strategy.project_settings() - for video in self._project.video_manager.videos: + videos = ( + self._videos if self._videos is not None else self._project.video_manager.videos + ) + for video in videos: check_termination_requested() video_path = self._project.video_manager.video_path(video) diff --git a/src/jabs/ui/main_window/central_widget.py b/src/jabs/ui/main_window/central_widget.py index d0f5168a..84dacf69 100644 --- a/src/jabs/ui/main_window/central_widget.py +++ b/src/jabs/ui/main_window/central_widget.py @@ -48,6 +48,14 @@ class CentralWidget(QtWidgets.QWidget): search_hit_loaded = QtCore.Signal(SearchHit) bbox_overlay_supported = QtCore.Signal(bool) + # Emitted whenever the classify button's enabled state changes, so the video + # list's "Classify Video" context-menu action can track classifier readiness. + classify_availability_changed = QtCore.Signal(bool) + + # Requests that the video list select (and thus load) the given video, used to + # auto-switch to a single video after it is classified from the context menu. + request_video_selection = QtCore.Signal(str) + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -92,6 +100,9 @@ def __init__(self, *args, **kwargs) -> None: self._classifier = Classifier(n_jobs=-1) self._training_thread: TrainingThread | None = None self._classify_thread: ClassifyThread | None = None + # videos targeted by the in-flight classification run (None == all videos), + # used to decide how the completion handler updates the display + self._classification_targets: list[str] | None = None self._training_report_markdown: str | None = None self._training_report_dialog: TrainingReportDialog | None = None @@ -906,6 +917,19 @@ def _get_label_track(self) -> TrackLabels: str(self._controls.current_identity_index), self._controls.current_behavior ) + def _set_classify_enabled(self, enabled: bool) -> None: + """Set the classify button's enabled state and notify listeners. + + Centralizes updates to the classify button so the video list's + "Classify Video" context-menu action can track classifier readiness via + the ``classify_availability_changed`` signal. + + Args: + enabled: True to enable classification, False to disable it. + """ + self._controls.classify_button_enabled = enabled + self.classify_availability_changed.emit(enabled) + def _update_classifier_controls(self) -> None: """Called when settings related to a loaded classifier should be updated""" self._controls.set_classifier_selection(self._classifier.classifier_type) @@ -920,11 +944,11 @@ def _update_classifier_controls(self) -> None: and classifier_settings.get("symmetric_behavior", None) == self._controls.use_symmetric ): # if yes, we can enable the classify button - self._controls.classify_button_enabled = True + self._set_classify_enabled(True) else: # if not, the classify button needs to be disabled until the # user retrains - self._controls.classify_button_enabled = False + self._set_classify_enabled(False) def _train_button_clicked(self) -> None: """handle user click on "Train" button""" @@ -1028,7 +1052,7 @@ def _training_thread_complete(self, elapsed_ms) -> None: self.status_message.emit( f"Training Complete. Elapsed time: {elapsed_ms / 1000:.1f}s", 20000 ) - self._controls.classify_button_enabled = True + self._set_classify_enabled(True) # Display training report if available if self._training_report_markdown: @@ -1100,7 +1124,7 @@ def _training_thread_error_callback(self, error: Exception) -> None: message=f"An exception occurred during training:\n{error}", details="".join(traceback.format_exception(error)), ) - self._controls.classify_button_enabled = False + self._set_classify_enabled(False) def _classify_thread_error_callback(self, error: Exception) -> None: """handle an error in the classification thread""" @@ -1158,26 +1182,62 @@ def _update_training_progress(self, step: int) -> None: self._progress_dialog.setValue(step) def _classify_button_clicked(self) -> None: - """handle user click on "Classify" button""" + """handle user click on "Classify" button (classifies every video)""" + self._start_classification(None) + + def classify_single_video(self, video_name: str) -> None: + """Classify a single video, e.g. from the video list context menu. + + Runs the same classification pipeline as the "Classify" button, but + restricted to ``video_name``. If no trained classifier is ready for the + current behavior, a warning is shown and nothing runs. + + Args: + video_name: The video filename to classify. + """ + if not self._controls.classify_button_enabled: + MessageDialog.warning( + self, + message="Train a classifier for this behavior before classifying a video.", + ) + return + self._start_classification([video_name]) + + def _start_classification(self, videos: list[str] | None) -> None: + """Launch the classification thread for the given videos. + + Args: + videos: Video filenames to classify, or ``None`` to classify every + video in the project. + """ + # ignore the request if a classification run is already in progress + if self._classify_thread is not None: + return + # make sure video playback is stopped self._player_widget.stop() self._ensure_classifier_for_mode() + self._classification_targets = videos + current_video = self._loaded_video.name if self._loaded_video else "" + total_videos = ( + len(videos) if videos is not None else self._project.video_manager.num_videos + ) + # setup classification thread self._classify_thread = ClassifyThread( self._classifier, self._project, self._controls.current_behavior, - self._loaded_video.name, + current_video, + videos=videos, parent=self, ) self._classify_thread.classification_complete.connect(self._classify_thread_complete) self._classify_thread.error_callback.connect(self._classify_thread_error_callback) self._classify_thread.update_progress.connect(self._update_classify_progress) self._classify_thread.current_status.connect(lambda m: self.status_message.emit(m, 0)) - self._progress_dialog = create_cancelable_progress_dialog( - self, "Predicting", self._project.video_manager.num_videos - ) + self._progress_dialog = create_cancelable_progress_dialog(self, "Predicting", total_videos) self._progress_dialog.show() self._progress_dialog.canceled.connect(self._classify_thread.request_termination) @@ -1186,18 +1246,29 @@ def _classify_button_clicked(self) -> None: def _classify_thread_complete(self, output: dict, elapsed_ms: int) -> None: """update the gui when the classification is complete""" - # display the new predictions - self._predictions = output["predictions"] - self._probabilities = output["probabilities"] - self._predictions_postprocessed = output["predictions_postprocessed"] - if self._project.settings_manager.classifier_mode == ClassifierMode.MULTICLASS: - self._multiclass_class_names = output.get("class_names") + targets = self._classification_targets + loaded_video = self._loaded_video.name if self._loaded_video else None + self._cleanup_progress_dialog() self._cleanup_classify_thread() + self._classification_targets = None self.status_message.emit( f"Classification Complete. Elapsed time: {elapsed_ms / 1000:.1f}s", 20000 ) - self._set_prediction_vis() + + # if the classified set includes the currently loaded video (or we + # classified every video), refresh the display with the new predictions + if targets is None or (loaded_video is not None and loaded_video in targets): + self._predictions = output["predictions"] + self._probabilities = output["probabilities"] + self._predictions_postprocessed = output["predictions_postprocessed"] + if self._project.settings_manager.classifier_mode == ClassifierMode.MULTICLASS: + self._multiclass_class_names = output.get("class_names") + self._set_prediction_vis() + elif len(targets) == 1: + # a single, non-loaded video was classified from the context menu; + # switch to it so its freshly-saved predictions are displayed + self.request_video_selection.emit(targets[0]) def _update_classify_progress(self, step: int) -> None: """update progress bar with the number of completed tasks""" @@ -1560,7 +1631,7 @@ def _classifier_changed(self) -> None: """handle classifier selection change""" if self._classifier.classifier_type != self._controls.classifier_type: # changing classifier type, disable until retrained - self._controls.classify_button_enabled = False + self._set_classify_enabled(False) self._classifier.set_classifier(self._controls.classifier_type) def _on_pixmap_clicked(self, event: dict[str, int]) -> None: @@ -1664,7 +1735,7 @@ def _load_cached_classifier(self) -> None: if classifier_loaded: self._update_classifier_controls() else: - self._controls.classify_button_enabled = False + self._set_classify_enabled(False) def _on_search_hit_changed_later(self, _: SearchHit | None) -> None: """Update the search hit after a short delay to allow UI updates to complete.""" diff --git a/src/jabs/ui/main_window/main_window.py b/src/jabs/ui/main_window/main_window.py index 1431a275..5bef9268 100644 --- a/src/jabs/ui/main_window/main_window.py +++ b/src/jabs/ui/main_window/main_window.py @@ -123,6 +123,17 @@ def _setup_dock_widgets(self) -> None: # Handle event where user selects a different video in the playlist self.video_list.selectionChanged.connect(self._video_list_selection) + # Classify a single video from the video list context menu, and keep the + # menu action's availability in sync with the classifier's readiness + self.video_list.classify_video_requested.connect( + self._central_widget.classify_single_video + ) + self._central_widget.classify_availability_changed.connect( + self.video_list.set_classify_available + ) + # After a single video is classified, switch to it so its predictions show + self._central_widget.request_video_selection.connect(self.video_list.select_video) + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: """override keyPressEvent so we can pass some key press events on to the centralWidget""" key = event.key() diff --git a/src/jabs/ui/main_window/video_list_widget.py b/src/jabs/ui/main_window/video_list_widget.py index bc3e3bb1..53aae497 100644 --- a/src/jabs/ui/main_window/video_list_widget.py +++ b/src/jabs/ui/main_window/video_list_widget.py @@ -133,6 +133,8 @@ class VideoListDockWidget(QtWidgets.QDockWidget): """ selectionChanged = QtCore.Signal(str) + # Emitted when the user chooses "Classify Video" for a video (its filename). + classify_video_requested = QtCore.Signal(str) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -140,6 +142,9 @@ def __init__(self, *args, **kwargs): self._project = None self._suppress_selection_event = False self._pending_selection = None # Track the pending video selection + # whether a trained classifier is ready to classify a single video; + # kept in sync by the main window and gates the context-menu action + self._classify_available = False # Debounce timer to delay video loading when rapidly switching videos self._debounce_timer = QtCore.QTimer(self) @@ -222,6 +227,11 @@ def _show_context_menu(self, pos: QtCore.QPoint) -> None: menu = QtWidgets.QMenu(self) get_info_action = menu.addAction("Get Info") copy_video_name_action = menu.addAction("Copy Video Name") + menu.addSeparator() + classify_action = menu.addAction("Classify Video") + + # only offer single-video classification when a trained classifier is ready + classify_action.setEnabled(self._project is not None and self._classify_available) exclude_action = menu.addAction("Exclude from Training") exclude_action.setCheckable(True) exclude_action.setChecked(bool(item.data(_EXCLUDED_ROLE))) @@ -232,6 +242,8 @@ def _show_context_menu(self, pos: QtCore.QPoint) -> None: self._show_video_info(video_name) elif action == copy_video_name_action: QtWidgets.QApplication.clipboard().setText(video_name) + elif action == classify_action: + self.classify_video_requested.emit(video_name) elif action == exclude_action: self._set_video_excluded(item, video_name, exclude_action.isChecked()) @@ -268,6 +280,14 @@ def _show_video_info(self, video_name: str) -> None: dialog = VideoInfoDialog(video_path, pose_path, identity_count=identity_count, parent=self) dialog.exec() + def set_classify_available(self, available: bool) -> None: + """Set whether the "Classify Video" context-menu action is available. + + Args: + available: True if a trained classifier is ready to classify a video. + """ + self._classify_available = available + def set_project(self, project): """Update the video list with the active project's videos and select first video in list.""" self._suppress_selection_event = True diff --git a/tests/ui/test_central_widget.py b/tests/ui/test_central_widget.py index f96c3e4d..90f5eac2 100644 --- a/tests/ui/test_central_widget.py +++ b/tests/ui/test_central_widget.py @@ -1,10 +1,12 @@ """Tests for CentralWidget helpers that don't require instantiating the widget.""" from types import SimpleNamespace +from unittest.mock import MagicMock import pytest try: + from jabs.core.enums import ClassifierMode from jabs.ui.main_window.central_widget import CentralWidget SKIP_UI_TESTS = False @@ -75,3 +77,127 @@ def test_included_project_bout_totals_handles_none_counts(): """No counts yet -> zero totals (no crash).""" stub = _bout_stub_widget(None, set()) assert CentralWidget._included_project_bout_totals(stub) == (0, 0) + + +# --------------------------------------------------------------------------- +# Single-video classification (context-menu path) +# --------------------------------------------------------------------------- + + +def test_set_classify_enabled_updates_control_and_emits(): + """_set_classify_enabled updates the control and emits classify_availability_changed.""" + controls = SimpleNamespace() + emitted: list[bool] = [] + stub = SimpleNamespace( + _controls=controls, + classify_availability_changed=SimpleNamespace(emit=emitted.append), + ) + + CentralWidget._set_classify_enabled(stub, True) + + assert controls.classify_button_enabled is True + assert emitted == [True] + + +def test_classify_single_video_warns_when_not_ready(monkeypatch): + """With no classifier ready, classify_single_video warns and does not start a run.""" + warn = MagicMock() + monkeypatch.setattr("jabs.ui.main_window.central_widget.MessageDialog.warning", warn) + stub = SimpleNamespace( + _controls=SimpleNamespace(classify_button_enabled=False), + _start_classification=MagicMock(), + ) + + CentralWidget.classify_single_video(stub, "v.avi") + + warn.assert_called_once() + stub._start_classification.assert_not_called() + + +def test_classify_single_video_starts_when_ready(): + """When a classifier is ready, classify_single_video starts a single-video run.""" + stub = SimpleNamespace( + _controls=SimpleNamespace(classify_button_enabled=True), + _start_classification=MagicMock(), + ) + + CentralWidget.classify_single_video(stub, "v.avi") + + stub._start_classification.assert_called_once_with(["v.avi"]) + + +def test_start_classification_ignored_when_thread_running(): + """A second classification request is ignored while one is already in flight.""" + stub = SimpleNamespace( + _classify_thread=object(), # a run is already active + _player_widget=MagicMock(), + ) + + CentralWidget._start_classification(stub, None) + + # early return: playback is not stopped and no new thread work begins + stub._player_widget.stop.assert_not_called() + + +def _completion_stub(targets, loaded_video_name): + """Build a stub self for _classify_thread_complete with mocked collaborators.""" + return SimpleNamespace( + _classification_targets=targets, + _loaded_video=( + SimpleNamespace(name=loaded_video_name) if loaded_video_name is not None else None + ), + _cleanup_progress_dialog=MagicMock(), + _cleanup_classify_thread=MagicMock(), + status_message=SimpleNamespace(emit=MagicMock()), + request_video_selection=SimpleNamespace(emit=MagicMock()), + _set_prediction_vis=MagicMock(), + _project=SimpleNamespace( + settings_manager=SimpleNamespace(classifier_mode=ClassifierMode.BINARY) + ), + _predictions={"original": 1}, + _probabilities={}, + _predictions_postprocessed={}, + ) + + +_COMPLETION_OUTPUT = { + "predictions": {0: "p"}, + "probabilities": {0: "q"}, + "predictions_postprocessed": {0: "r"}, + "class_names": None, +} + + +def test_classify_complete_refreshes_when_all_videos_classified(): + """Classifying all videos refreshes the display from the completion payload.""" + stub = _completion_stub(targets=None, loaded_video_name="loaded.avi") + + CentralWidget._classify_thread_complete(stub, _COMPLETION_OUTPUT, 1234) + + assert stub._predictions == {0: "p"} + stub._set_prediction_vis.assert_called_once() + stub.request_video_selection.emit.assert_not_called() + assert stub._classification_targets is None + + +def test_classify_complete_refreshes_when_loaded_video_in_subset(): + """Classifying a subset that includes the loaded video refreshes the display.""" + stub = _completion_stub(targets=["loaded.avi"], loaded_video_name="loaded.avi") + + CentralWidget._classify_thread_complete(stub, _COMPLETION_OUTPUT, 1234) + + assert stub._predictions == {0: "p"} + stub._set_prediction_vis.assert_called_once() + stub.request_video_selection.emit.assert_not_called() + + +def test_classify_complete_autoswitches_to_other_video(): + """Classifying a single non-loaded video switches to it without touching the current view.""" + stub = _completion_stub(targets=["other.avi"], loaded_video_name="loaded.avi") + + CentralWidget._classify_thread_complete(stub, _COMPLETION_OUTPUT, 1234) + + # current predictions are left untouched; we request a switch to the classified video + assert stub._predictions == {"original": 1} + stub._set_prediction_vis.assert_not_called() + stub.request_video_selection.emit.assert_called_once_with("other.avi") diff --git a/tests/ui/test_classification_thread.py b/tests/ui/test_classification_thread.py index 18b694b5..e23d4aad 100644 --- a/tests/ui/test_classification_thread.py +++ b/tests/ui/test_classification_thread.py @@ -111,3 +111,64 @@ def __init__(self, _config): assert kwargs["class_names"] == ["None", "Walk", "Run"] assert kwargs["postprocessed_predictions"] == {} assert fake_features_cls.op_settings_seen[0]["window_size"] == 7 + + +def _binary_thread_env(monkeypatch) -> None: + """Patch IdentityFeatures / PostprocessingPipeline for a binary ClassifyThread run.""" + + class _FakePostprocessingPipeline: + def __init__(self, _config): + pass + + @staticmethod + def run(predictions, _probabilities): + return predictions.copy() + + monkeypatch.setattr( + "jabs.ui.classification_thread.IdentityFeatures", + make_fake_identity_features(), + ) + monkeypatch.setattr( + "jabs.ui.classify_strategy.PostprocessingPipeline", + _FakePostprocessingPipeline, + ) + + +def test_classify_thread_classifies_only_the_video_subset(monkeypatch) -> None: + """A ``videos`` subset drives iteration instead of the project's video list.""" + _binary_thread_env(monkeypatch) + + project = FakeClassifyingProject(ClassifierMode.BINARY) + classifier = FakeClassifyingClassifier(multiclass=False) + # the project only lists "video.avi", but we ask for a different two-video set + thread = ClassifyThread(classifier, project, "Walk", "a.avi", videos=["a.avi", "b.avi"]) + errors: list[Exception] = [] + thread.error_callback.connect(errors.append) + + thread.run() + + assert errors == [] + # one save per requested video, keyed by the subset names (not the project list) + saved_videos = [call.args[1] for call in project.save_predictions.call_args_list] + assert saved_videos == ["a.avi", "b.avi"] + + +def test_classify_thread_empty_subset_saves_nothing(monkeypatch) -> None: + """An empty ``videos`` subset classifies no videos but still completes.""" + _binary_thread_env(monkeypatch) + + project = FakeClassifyingProject(ClassifierMode.BINARY) + classifier = FakeClassifyingClassifier(multiclass=False) + thread = ClassifyThread(classifier, project, "Walk", "video.avi", videos=[]) + completions: list[dict] = [] + errors: list[Exception] = [] + thread.classification_complete.connect(lambda output, _elapsed: completions.append(output)) + thread.error_callback.connect(errors.append) + + thread.run() + + assert errors == [] + project.save_predictions.assert_not_called() + # completion still fires (with no current-video predictions) + assert len(completions) == 1 + assert completions[0]["predictions"] == {} diff --git a/tests/ui/test_video_list_widget.py b/tests/ui/test_video_list_widget.py index 6393e55e..b68b0151 100644 --- a/tests/ui/test_video_list_widget.py +++ b/tests/ui/test_video_list_widget.py @@ -3,7 +3,8 @@ import pytest try: - from PySide6.QtCore import Qt + from PySide6 import QtWidgets + from PySide6.QtCore import QPoint, Qt from PySide6.QtGui import QColor, QPalette from PySide6.QtWidgets import QApplication @@ -92,3 +93,64 @@ def test_text_pen_color_dims_excluded_rows_in_all_states(): assert pen(palette, selected=True, excluded=True).name() == QColor("green").name() # selected included -> normal highlighted text assert pen(palette, selected=True, excluded=False).name() == QColor("blue").name() + + +def _patch_menu(monkeypatch, chooser): + """Replace QMenu with a non-modal subclass whose exec() delegates to chooser. + + Args: + monkeypatch: pytest monkeypatch fixture. + chooser: callable taking the menu's action list and returning the action + to treat as "chosen" (or None for no selection). + """ + + class _NonModalMenu(QtWidgets.QMenu): + def exec(self, *_args, **_kwargs): + return chooser(self.actions()) + + monkeypatch.setattr(QtWidgets, "QMenu", _NonModalMenu) + + +def _classify_action(actions): + """Return the "Classify Video" action from a list of menu actions.""" + for action in actions: + if action.text() == "Classify Video": + return action + raise AssertionError("no 'Classify Video' action in menu") + + +@pytest.mark.parametrize("available", [True, False], ids=["ready", "not-ready"]) +def test_classify_action_enabled_reflects_availability(monkeypatch, available): + """The Classify Video action is enabled only when a classifier is available.""" + widget = VideoListDockWidget() + widget.set_project(_mock_project(["a.avi"], excluded=set())) + widget.set_classify_available(available) + item = _item_for(widget, "a.avi") + monkeypatch.setattr(widget._file_list, "itemAt", lambda _pos: item) + + captured = {} + + def chooser(actions): + captured["enabled"] = _classify_action(actions).isEnabled() + return None # choose nothing + + _patch_menu(monkeypatch, chooser) + widget._show_context_menu(QPoint(0, 0)) + + assert captured["enabled"] is available + + +def test_choosing_classify_action_emits_request(monkeypatch): + """Choosing Classify Video emits classify_video_requested with the video name.""" + widget = VideoListDockWidget() + widget.set_project(_mock_project(["a.avi"], excluded=set())) + widget.set_classify_available(True) + item = _item_for(widget, "a.avi") + monkeypatch.setattr(widget._file_list, "itemAt", lambda _pos: item) + _patch_menu(monkeypatch, _classify_action) + + requested = [] + widget.classify_video_requested.connect(requested.append) + widget._show_context_menu(QPoint(0, 0)) + + assert requested == ["a.avi"]