Files
flares/main_unit_tests.py
2026-08-21 00:12:29 -07:00

712 lines
25 KiB
Python

"""
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"])