""" Filename: main_unit_tests.py Description: Unit tests for functionality validation Author: Tyler de Zeeuw License: GPL-3.0 """ # Built-in imports import os import pickle import configparser from unittest.mock import MagicMock, patch # External library imports import pytest from PySide6.QtWidgets import QApplication, QMenu import main from updater import LocalPendingUpdateCheckThread, UpdateCheckThread ''' These test fluff currently. Very basic "Does the UI exist?" and not the functionality. main_test.py::test_save_project_actions_pass_correct_ask_parameter main_test.py::test_main_window_opens main_test.py::test_file_recent_submenus_exist main_test.py::test_view_reset_layout main_test.py::test_preferences_actions[2D Data Bypass-2d_data_bypass] main_test.py::test_preferences_actions[Incompatible Save Bypass-incompatible_save_bypass] main_test.py::test_preferences_actions[Missing Events Bypass-missing_events_bypass] main_test.py::test_preferences_actions[Analysis Clearing Bypass-analysis_clearing_bypass] main_test.py::test_preferences_actions[Folding Bypass-folding_bypass] main_test.py::test_preferences_actions[Show Advanced Parameters-advanced_parameters] ''' # ---------------------- HELPERS ---------------------- def get_menu_by_title(menu_bar, title): """Return the first QMenu with the given title, or None.""" for menu in menu_bar.findChildren(QMenu): if menu.title() == title: return menu return None # ---------------------- FIXTURES ---------------------- @pytest.fixture(autouse=True) def disable_updater_threads(): """Stops updater threads from running asynchronously during qtbot teardown.""" with patch.object(UpdateCheckThread, "start", return_value=None), \ patch.object(LocalPendingUpdateCheckThread, "start", return_value=None): yield @pytest.fixture(autouse=True) def setup_app_globals(): """Initializes global configuration objects that main.py expects at runtime.""" main.cfg_path = os.path.join(os.getcwd(), f"{main.APP_NAME}.cfg") main.file_cfg = configparser.ConfigParser() main.ref_cfg = configparser.ConfigParser() if hasattr(main, "config_init"): main.config_init() # ===================== FILE MENU ===================== def test_main_window_opens(qtbot): """Test 1: Verify MainApplication launches and becomes visible.""" window = main.MainApplication() qtbot.addWidget(window) window.show() assert window.isVisible() def test_open_file_dialog_with_mne_mock(qtbot, tmp_path): dummy_snirf = tmp_path / "test_data.snirf" dummy_snirf.write_text("dummy content") expected_path = os.path.normpath(str(dummy_snirf)) # Mock MNE Raw object returned by read_raw_snirf mock_raw = MagicMock() mock_raw.info = {"meas_date": "2026-01-01", "ch_names": ["S1_D1 760"], "dig": None} mock_raw.ch_names = ["S1_D1 760"] mock_raw.annotations = [] window = main.MainApplication() qtbot.addWidget(window) window.show() with patch("PySide6.QtWidgets.QFileDialog.getOpenFileName", return_value=(expected_path, "SNIRF Files (*.snirf)")), \ patch("mne.io.snirf.read_raw_snirf", return_value=mock_raw), \ patch("project_manager.source_detector_distances", return_value=[0.03]): window.project_manager.open_file_dialog() assert expected_path in window.selected_paths assert expected_path in window.bubble_widgets window.files_are_dirty = False window.is_saved = True def test_open_folder_dialog(qtbot, tmp_path): """Verify that open_folder_dialog recursively finds and loads all .snirf files.""" sub_dir = tmp_path / "sub_folder" sub_dir.mkdir() file1 = tmp_path / "root_file.snirf" file2 = sub_dir / "nested_file.snirf" ignored_file = tmp_path / "notes.txt" file1.write_text("dummy snirf 1") file2.write_text("dummy snirf 2") ignored_file.write_text("text note") folder_path = str(tmp_path) expected_paths = { os.path.normpath(str(file1)), os.path.normpath(str(file2)), } window = main.MainApplication() qtbot.addWidget(window) window.show() with patch( "PySide6.QtWidgets.QFileDialog.getExistingDirectory", return_value=folder_path, ): window.project_manager.open_folder_dialog() loaded_paths = set(window.selected_paths) assert expected_paths.issubset(loaded_paths) assert os.path.normpath(str(ignored_file)) not in loaded_paths window.files_are_dirty = False window.is_saved = True def test_load_project_dialog(qtbot, tmp_path): """Verify loading a valid pickled .flare project restores application state.""" project_file = tmp_path / "test_project.flare" dummy_project_data = { "version": "1.1.7", "file_metadata": {"rel_sample.snirf": {"channels": 4}}, "file_parameters": {"rel_sample.snirf": {"AGE": "25", "SEX": "M", "HAND": "R", "GROUP": "A"}}, "roi_channel_map_dict": {}, "file_list": ["rel_sample.snirf"], "progress_states": {"rel_sample.snirf": "completed"}, "current_ui_params": {}, } with open(project_file, "wb") as f: pickle.dump(dummy_project_data, f) file_path = str(project_file) window = main.MainApplication() qtbot.addWidget(window) window.show() if not hasattr(main, "DATA_SCHEMA"): main.DATA_SCHEMA = [] with patch( "PySide6.QtWidgets.QFileDialog.getOpenFileName", return_value=(file_path, "FLARE Project (*.flare)"), ), patch("PySide6.QtWidgets.QMessageBox.information") as mock_info, patch.object( window, "show_files_as_bubbles_from_list" ): window.project_manager.load_project_dialog() assert window.current_project_path == file_path mock_info.assert_called_once() def test_load_project_incompatible_version(qtbot, tmp_path): """Verify that a missing required key triggers an incompatibility error.""" invalid_file = tmp_path / "corrupt.flare" incomplete_data = { "file_metadata": {}, "file_parameters": {}, "roi_channel_map_dict": {}, } with open(invalid_file, "wb") as f: pickle.dump(incomplete_data, f) window = main.MainApplication() qtbot.addWidget(window) window.show() with patch("PySide6.QtWidgets.QMessageBox.critical") as mock_critical, \ patch("PySide6.QtWidgets.QMessageBox.warning") as mock_warning: window.project_manager.load_project(str(invalid_file)) assert mock_critical.called or mock_warning.called, "Expected a QMessageBox warning or critical popup." assert len(getattr(window, "selected_paths", [])) == 0 def test_save_project_no_data_shows_warning(qtbot): """Verify saving an empty project triggers a 'no data to save' warning.""" window = main.MainApplication() qtbot.addWidget(window) window.show() with patch("PySide6.QtWidgets.QMessageBox.warning") as mock_warning: window.project_manager.save_project(ask=True) mock_warning.assert_called_once() assert "no data" in mock_warning.call_args[0][2].lower() def test_save_project_success(qtbot, tmp_path): """Verify saving a loaded project outputs a valid pickled .flare file.""" save_file_path = tmp_path / "test_project.flare" dummy_snirf_path = str(tmp_path / "sample_subject.snirf") window = main.MainApplication() qtbot.addWidget(window) window.show() # 1. Satisfy 'has_files' check window.selected_paths = [dummy_snirf_path] # 2. Add mock bubble widget so step 4 populates file_list mock_bubble = MagicMock() mock_bubble.file_path = dummy_snirf_path mock_bubble.current_step = 0 window.bubble_widgets = {dummy_snirf_path: mock_bubble} with patch("PySide6.QtWidgets.QFileDialog.getSaveFileName", return_value=(str(save_file_path), "FLARE Project (*.flare)")), \ patch("PySide6.QtWidgets.QMessageBox.information") as mock_info: window.project_manager.save_project(ask=True) # Wait for SaveProjectThread to finish writing to disk qtbot.waitUntil(lambda: save_file_path.exists(), timeout=3000) mock_info.assert_called_once() # 3. Verify the saved payload structure assert save_file_path.is_file() with open(save_file_path, "rb") as f: data = pickle.load(f) assert "version" in data # file_list contains relative paths normalized by sanitize() assert "sample_subject.snirf" in data["file_list"] # Reset dirty state so teardown completes cleanly window.files_are_dirty = False window.is_saved = True def test_save_project_actions_pass_correct_ask_parameter(qtbot): """ Verify that the 'Save Project...' action calls save_project(ask=False) and 'Save Project As...' calls save_project(ask=True). """ window = main.MainApplication() qtbot.addWidget(window) window.show() file_menu = get_menu_by_title(window.menuBar(), "File") assert file_menu is not None, "File menu not found" save_action = next(a for a in file_menu.actions() if a.text() == "Save Project...") save_as_action = next(a for a in file_menu.actions() if a.text() == "Save Project As...") with patch.object(window.project_manager, 'save_project') as mock_save: save_action.trigger() mock_save.assert_called_once_with(ask=False) mock_save.reset_mock() save_as_action.trigger() mock_save.assert_called_once_with(ask=True) def test_file_exit(qtbot): """Verify that File → Exit calls QApplication.quit().""" with patch.object(QApplication, 'quit') as mock_quit: window = main.MainApplication() qtbot.addWidget(window) window.show() file_menu = get_menu_by_title(window.menuBar(), "File") assert file_menu is not None, "File menu not found" exit_action = next(a for a in file_menu.actions() if a.text() == "Exit") exit_action.trigger() mock_quit.assert_called_once() def test_file_recent_submenus_exist(qtbot): """Verify that the 'Recent Files' and 'Recent Projects' submenus are created.""" window = main.MainApplication() qtbot.addWidget(window) window.show() file_menu = get_menu_by_title(window.menuBar(), "File") assert file_menu is not None, "File menu not found" recent_files_action = next((a for a in file_menu.actions() if a.text() == "Recent Files"), None) assert recent_files_action is not None recent_files_menu = recent_files_action.menu() assert recent_files_menu is not None recent_projects_action = next((a for a in file_menu.actions() if a.text() == "Recent Projects"), None) assert recent_projects_action is not None recent_projects_menu = recent_projects_action.menu() assert recent_projects_menu is not None # ===================== EDIT MENU ===================== def test_edit_cut(qtbot): """Verify Edit → Cut calls top_left_widget.cut().""" window = main.MainApplication() qtbot.addWidget(window) window.show() edit_menu = get_menu_by_title(window.menuBar(), "Edit") assert edit_menu is not None, "Edit menu not found" cut_action = next(a for a in edit_menu.actions() if a.text() == "Cut") with patch.object(window.top_left_widget, 'cut') as mock_cut: cut_action.trigger() mock_cut.assert_called_once() def test_edit_copy(qtbot): """Verify Edit → Copy calls top_left_widget.copy().""" window = main.MainApplication() qtbot.addWidget(window) window.show() edit_menu = get_menu_by_title(window.menuBar(), "Edit") assert edit_menu is not None, "Edit menu not found" copy_action = next(a for a in edit_menu.actions() if a.text() == "Copy") with patch.object(window.top_left_widget, 'copy') as mock_copy: copy_action.trigger() mock_copy.assert_called_once() def test_edit_paste(qtbot): """Verify Edit → Paste calls top_left_widget.paste().""" window = main.MainApplication() qtbot.addWidget(window) window.show() edit_menu = get_menu_by_title(window.menuBar(), "Edit") assert edit_menu is not None, "Edit menu not found" paste_action = next(a for a in edit_menu.actions() if a.text() == "Paste") with patch.object(window.top_left_widget, 'paste') as mock_paste: paste_action.trigger() mock_paste.assert_called_once() # ===================== VIEW MENU ===================== def test_view_toggle_statusbar(qtbot): """Verify View → Toggle Status Bar toggles visibility and calls _update_config_setting.""" window = main.MainApplication() qtbot.addWidget(window) window.show() view_menu = get_menu_by_title(window.menuBar(), "View") assert view_menu is not None, "View menu not found" toggle_action = next(a for a in view_menu.actions() if a.text() == "Toggle Status Bar") assert toggle_action.isCheckable() is True # Initially checked (True in create_menu_bar) assert toggle_action.isChecked() is True assert window.statusbar.isVisible() is True # Trigger once to hide with patch.object(window, '_update_config_setting') as mock_update: toggle_action.trigger() assert not toggle_action.isChecked() assert not window.statusbar.isVisible() mock_update.assert_called_once_with("View", "status_bar", False) # Trigger again to show with patch.object(window, '_update_config_setting') as mock_update: toggle_action.trigger() assert toggle_action.isChecked() is True assert window.statusbar.isVisible() is True mock_update.assert_called_once_with("View", "status_bar", True) def test_view_reset_layout(qtbot): """Verify View → Reset Window Layout calls apply_splitter_ratios.""" window = main.MainApplication() qtbot.addWidget(window) window.show() view_menu = get_menu_by_title(window.menuBar(), "View") assert view_menu is not None, "View menu not found" reset_action = next(a for a in view_menu.actions() if a.text() == "Reset Window Layout") with patch.object(window, 'apply_splitter_ratios') as mock_apply: reset_action.trigger() mock_apply.assert_called_once() # ===================== OPTIONS MENU ===================== def test_about_window_opens(qtbot): """Verify AboutWindow opens and prevents duplicate instances.""" window = main.MainApplication() qtbot.addWidget(window) window.show() assert getattr(window, "about", None) is None window.about_window() assert window.about is not None assert window.about.isVisible() is True first_instance = window.about window.about_window() assert window.about is first_instance def test_user_guide_window_opens(qtbot): """Verify UserGuideWindow opens and prevents duplicate instances.""" window = main.MainApplication() qtbot.addWidget(window) window.show() assert getattr(window, "help", None) is None window.user_guide() assert window.help is not None assert window.help.isVisible() is True first_instance = window.help window.user_guide() assert window.help is first_instance def test_show_update_changelog(qtbot): """Verify WelcomeDialog is instantiated and shown.""" window = main.MainApplication() qtbot.addWidget(window) window.show() with patch.object(main, "WelcomeDialog") as mock_dialog_cls: mock_dialog_instance = MagicMock() mock_dialog_cls.return_value = mock_dialog_instance window.show_update_changelog() mock_dialog_cls.assert_called_once_with(window, direct=False) mock_dialog_instance.show.assert_called_once() def test_group_metadata_no_data_shows_msgbox(qtbot): """Verify group_metadata triggers an information QMessageBox when file_metadata is empty.""" window = main.MainApplication() qtbot.addWidget(window) window.show() window.file_metadata = {} with patch("PySide6.QtWidgets.QMessageBox.information") as mock_msgbox: window.group_metadata() mock_msgbox.assert_called_once() assert "No Data" in mock_msgbox.call_args[0] def test_group_metadata_with_data_applies_mappings(qtbot): """Verify group_metadata opens GroupAssignmentDialog and executes _apply_group_mappings on success.""" window = main.MainApplication() qtbot.addWidget(window) window.show() window.file_metadata = {"sub-01.snirf": {"age": "25"}} mock_result = ("Age", {"sub-01.snirf": "GroupA"}) with patch.object(main.GroupAssignmentDialog, "run", return_value=mock_result), \ patch.object(window, "_apply_group_mappings") as mock_apply: window.group_metadata() mock_apply.assert_called_once_with({"sub-01.snirf": "GroupA"}, field_name="Age") def test_manual_check_for_updates(qtbot): """Verify Options → Check for Updates triggers the updater method.""" window = main.MainApplication() qtbot.addWidget(window) window.show() options_menu = get_menu_by_title(window.menuBar(), "Options") assert options_menu is not None, "Options menu not found" update_action = next(a for a in options_menu.actions() if a.text() == "Check for Updates") assert update_action is not None assert update_action.isEnabled() is True # Patch the updater's manual_check_for_updates method with patch.object(window.updater, 'manual_check_for_updates') as mock_method: update_action.trigger() mock_method.assert_called_once() def test_update_optode_positions_opens(qtbot): """Verify UpdateOptodesWindow opens and prevents duplicate instances.""" window = main.MainApplication() qtbot.addWidget(window) window.show() assert getattr(window, "optodes", None) is None window.update_optode_positions() assert window.optodes is not None assert window.optodes.isVisible() is True first_instance = window.optodes window.update_optode_positions() assert window.optodes is first_instance def test_update_event_markers_opens(qtbot): """Verify UpdateEventsWindow opens and prevents duplicate instances.""" window = main.MainApplication() qtbot.addWidget(window) window.show() assert getattr(window, "events", None) is None window.update_event_markers() assert window.events is not None assert window.events.isVisible() is True first_instance = window.events window.update_event_markers() assert window.events is first_instance def test_update_event_markers_blazes_opens(qtbot): """Verify UpdateEventsBlazesWindow opens and prevents duplicate instances.""" window = main.MainApplication() qtbot.addWidget(window) window.show() assert getattr(window, "events_blazes", None) is None window.update_event_markers_blazes() assert window.events_blazes is not None assert window.events_blazes.isVisible() is True first_instance = window.events_blazes window.update_event_markers_blazes() assert window.events_blazes is first_instance def test_reset_to_default_configuration_user_cancels(qtbot): """Verify nothing is reset when the user clicks 'No' on the prompt.""" window = main.MainApplication() qtbot.addWidget(window) window.show() with patch("main.QMessageBox.question", return_value=main.QMessageBox.StandardButton.No), \ patch("main.open") as mock_open, \ patch.object(window, "sync_app_with_config") as mock_sync: window.reset_to_default_configuration() mock_open.assert_not_called() mock_sync.assert_not_called() def test_reset_to_default_configuration_success(qtbot): """Verify file write, widget resets, config sync, and singleShot timer call when confirmed.""" window = main.MainApplication() qtbot.addWidget(window) window.show() # Mock child ParamSection widgets mock_section1 = MagicMock() mock_section2 = MagicMock() with patch("main.QMessageBox.question", return_value=main.QMessageBox.StandardButton.Yes), \ patch("main.open") as mock_open, \ patch("main.file_cfg") as mock_cfg, \ patch.object(window, "findChildren", return_value=[mock_section1, mock_section2]), \ patch.object(window, "sync_app_with_config") as mock_sync, \ patch.object(window, "update_sections") as mock_update, \ patch("main.QTimer.singleShot") as mock_timer: window.reset_to_default_configuration() # Check file overwrite and parser reload mock_open.assert_called_once() mock_cfg.read.assert_called_once_with(main.cfg_path) # Check section UI resets and app syncing mock_section1.reset_to_defaults.assert_called_once() mock_section2.reset_to_defaults.assert_called_once() mock_sync.assert_called_once() mock_update.assert_called_once_with(0) # Verify post-reset dialog singleShot queue mock_timer.assert_called_once_with(100, window._show_reset_success_dialog) def test_reset_to_default_configuration_file_error_fallback(qtbot): """Verify fallback to in-memory read_string when file writing raises an Exception.""" window = main.MainApplication() qtbot.addWidget(window) window.show() with patch("main.QMessageBox.question", return_value=main.QMessageBox.StandardButton.Yes), \ patch("main.open", side_effect=PermissionError("Access denied")), \ patch("main.file_cfg") as mock_cfg, \ patch.object(window, "sync_app_with_config"), \ patch.object(window, "update_sections"), \ patch("main.QTimer.singleShot"): window.reset_to_default_configuration() # Verify fallback read_string execution mock_cfg.read_string.assert_called_once_with(main.DEFAULT_CONFIG) def test_show_reset_success_dialog(qtbot): """Verify success dialog pops up and statusbar updates.""" window = main.MainApplication() qtbot.addWidget(window) window.show() window.statusbar = MagicMock() with patch("main.QMessageBox.information") as mock_info: window._show_reset_success_dialog() mock_info.assert_called_once_with( window, "Reset Successful", "All application settings have been successfully restored to their default values." ) window.statusbar.showMessage.assert_called_once_with( "All settings have been reset to their default values.", 5000 ) # ===================== PREFERENCES MENU ===================== @pytest.mark.parametrize("action_text, config_key", [ ("2D Data Bypass", "2d_data_bypass"), ("Incompatible Save Bypass", "incompatible_save_bypass"), ("Missing Events Bypass", "missing_events_bypass"), ("Analysis Clearing Bypass", "analysis_clearing_bypass"), ("Folding Bypass", "folding_bypass"), ("Show Advanced Parameters", "advanced_parameters"), ]) def test_preferences_actions(qtbot, action_text, config_key): """ Verify each Preferences action toggles checked state and updates config. Uses the current checked state as a starting point and verifies toggling. """ window = main.MainApplication() qtbot.addWidget(window) window.show() pref_menu = get_menu_by_title(window.menuBar(), "Preferences") assert pref_menu is not None, "Preferences menu not found" action = next(a for a in pref_menu.actions() if a.text() == action_text) assert action.isCheckable() is True # Record the initial state initial_checked = action.isChecked() # Trigger once → state should toggle with patch.object(window, '_update_config_setting') as mock_update: action.trigger() assert action.isChecked() == (not initial_checked) mock_update.assert_called_once_with("Preferences", config_key, not initial_checked) # Trigger again → should toggle back to initial with patch.object(window, '_update_config_setting') as mock_update: action.trigger() assert action.isChecked() == initial_checked mock_update.assert_called_once_with("Preferences", config_key, initial_checked) # ===================== TERMINAL MENU ===================== def test_terminal_gui_opens(qtbot): """Verify TerminalWindow opens and prevents duplicate instances.""" window = main.MainApplication() qtbot.addWidget(window) window.show() assert getattr(window, "terminal", None) is None window.terminal_gui() assert window.terminal is not None assert window.terminal.isVisible() is True first_instance = window.terminal window.terminal_gui() assert window.terminal is first_instance if __name__ == "__main__": pytest.main([__file__, "-v"])