Coverage for tests/ui/test_workspace_tree_view.py: 97%
243 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
1import pytest
2import os
3import tempfile
4import shutil
5from unittest.mock import Mock, MagicMock, patch, call
6from PyQt6 import QtCore, QtWidgets
7from PyQt6.QtCore import QSettings, pyqtSignal, QObject, Qt, QModelIndex
8from PyQt6.QtWidgets import QMessageBox, QFileDialog, QMenu
9from PyQt6.QtGui import QFileSystemModel
11from main.ui.workspace_tree_view import WorkspaceTreeView
14@pytest.fixture
15def tree_view(qtbot, mock_context):
16 view = WorkspaceTreeView(mock_context)
17 qtbot.addWidget(view)
18 return view
21class TestWorkspaceTreeViewSetup:
22 """Tests for WorkspaceTreeView initialization and full workflows."""
24 def test_full_workflow_selection_and_export(self, tree_view, temp_workspace):
25 """Full workflow: file selection and export."""
26 tree_view.handle_workspace_click()
27 assert isinstance(tree_view.selected_files, list)
29 nifti_file = os.path.join(temp_workspace, "sub-01", "anat", "T1w.nii")
31 with patch('main.ui.workspace_tree_view.QFileDialog') as MockDialog:
32 MockDialog.getSaveFileName.return_value = ("", "")
33 tree_view.export_files([nifti_file], is_dir=False)
34 MockDialog.getSaveFileName.assert_called_once()
36 def test_full_workflow_double_click_nifti(self, tree_view, temp_workspace):
37 """Full workflow: double-click on NIfTI file should open viewer."""
38 nifti_file = os.path.join(temp_workspace, "sub-01", "anat", "T1w.nii")
39 index = tree_view.tree_model.index(nifti_file)
41 tree_view.handle_double_click(index)
42 tree_view.context["open_nifti_viewer"].assert_called_once_with(nifti_file)
45def test_initialization(tree_view, temp_workspace):
46 """Verify proper initialization of the tree view."""
47 assert tree_view.workspace_path == temp_workspace
48 assert tree_view.selected_files == []
49 assert isinstance(tree_view.tree_model, QFileSystemModel)
52def test_model_root_path(tree_view, temp_workspace):
53 """Ensure the model points to the workspace path."""
54 assert tree_view.tree_model.rootPath() == temp_workspace
57def test_selection_mode(tree_view):
58 """Check that extended selection mode is enabled."""
59 assert tree_view.selectionMode() == QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection
62def test_context_menu_policy(tree_view):
63 """Check that the context menu policy is set correctly."""
64 assert tree_view.contextMenuPolicy() == Qt.ContextMenuPolicy.CustomContextMenu
67def test_signals_connected(tree_view):
68 """Verify that required signals are connected."""
69 assert hasattr(tree_view, 'new_thread')
72class TestWorkspaceTreeViewSelection:
73 """Tests for selection handling."""
75 def test_handle_workspace_click_updates_selection(self, tree_view, qtbot):
76 """Clicking in the workspace should update selected files."""
77 with qtbot.waitSignal(tree_view.context["selected_files_signal"], timeout=1000):
78 tree_view.handle_workspace_click()
79 assert isinstance(tree_view.selected_files, list)
81 def test_selected_files_emits_signal(self, tree_view, signal_emitter, qtbot):
82 """Ensure that selection emits the correct signal."""
83 with qtbot.waitSignal(signal_emitter.selected_files, timeout=1000) as blocker:
84 tree_view.handle_workspace_click()
85 assert isinstance(blocker.args[0], list)
88class TestWorkspaceTreeViewDoubleClick:
89 """Tests for handling double-click actions."""
91 def test_double_click_on_nifti_opens_viewer(self, tree_view, temp_workspace):
92 """Double-clicking a .nii file should open the viewer."""
93 nifti_file = os.path.join(temp_workspace, "brain.nii")
94 index = tree_view.tree_model.index(nifti_file)
95 tree_view.handle_double_click(index)
96 tree_view.context["open_nifti_viewer"].assert_called_once_with(nifti_file)
98 def test_double_click_on_nifti_gz_opens_viewer(self, tree_view, temp_workspace):
99 """Double-clicking a .nii.gz file should open the viewer."""
100 nifti_file = os.path.join(temp_workspace, "scan.nii.gz")
101 index = tree_view.tree_model.index(nifti_file)
102 tree_view.handle_double_click(index)
103 tree_view.context["open_nifti_viewer"].assert_called_once_with(nifti_file)
105 def test_double_click_on_regular_file_opens_in_explorer(self, tree_view, temp_workspace):
106 """Double-clicking a regular file should open it with the default app."""
107 regular_file = os.path.join(temp_workspace, "test.txt")
108 index = tree_view.tree_model.index(regular_file)
109 with patch.object(tree_view, '_open_in_explorer') as mock_open:
110 tree_view.handle_double_click(index)
111 mock_open.assert_called_once_with(regular_file)
113 def test_double_click_on_folder_does_nothing(self, tree_view, temp_workspace):
114 """Double-clicking a folder should not trigger any action."""
115 folder = os.path.join(temp_workspace, "subfolder")
116 os.makedirs(folder)
117 index = tree_view.tree_model.index(folder)
118 with patch.object(tree_view, '_open_in_explorer') as mock_open:
119 tree_view.handle_double_click(index)
120 mock_open.assert_not_called()
122 def test_double_click_nifti_error_shows_message(self, tree_view, temp_workspace, monkeypatch):
123 """If opening NIfTI fails, an error message should be shown."""
124 tree_view.context["open_nifti_viewer"].side_effect = Exception("Viewer error")
125 nifti_file = os.path.join(temp_workspace, "brain.nii")
126 index = tree_view.tree_model.index(nifti_file)
127 message_shown = False
129 def mock_critical(*args, **kwargs):
130 nonlocal message_shown
131 message_shown = True
133 monkeypatch.setattr(QMessageBox, 'critical', mock_critical)
134 tree_view.handle_double_click(index)
135 assert message_shown
138class TestWorkspaceTreeViewColumns:
139 """Tests for column visibility adjustments."""
141 def test_adjust_tree_columns_wide(self, tree_view):
142 """Columns should be visible when the window is wide."""
143 tree_view.resize(500, 300)
144 tree_view.adjust_tree_columns()
145 for i in range(1, tree_view.tree_model.columnCount()):
146 assert not tree_view.isColumnHidden(i)
148 def test_adjust_tree_columns_narrow(self, tree_view):
149 """Columns should be hidden when the window is narrow."""
150 tree_view.resize(300, 300)
151 tree_view.adjust_tree_columns()
152 for i in range(1, tree_view.tree_model.columnCount()):
153 assert tree_view.isColumnHidden(i)
156class TestWorkspaceTreeViewContextMenu:
157 """Tests for context menu creation."""
159 def test_workspace_actions_created(self, tree_view):
160 """Workspace-level context actions should be created."""
161 menu = QMenu()
162 actions = tree_view._add_workspace_actions(menu)
163 assert len(actions) == 4
164 assert all(isinstance(action, Mock) or hasattr(action, 'text') for action in actions.keys())
166 def test_folder_actions_created(self, tree_view, temp_workspace):
167 """Folder-level context actions should be created."""
168 menu = QMenu()
169 folder_path = os.path.join(temp_workspace, "folder1")
170 actions = tree_view._add_folder_actions(menu, folder_path)
171 assert len(actions) == 4
173 def test_file_actions_created(self, tree_view, temp_workspace):
174 """File-level context actions should be created."""
175 menu = QMenu()
176 file_path = os.path.join(temp_workspace, "test.txt")
177 actions = tree_view._add_file_actions(menu, file_path, is_nifty=False)
178 assert len(actions) >= 4
180 def test_nifti_file_actions_include_viewer(self, tree_view, temp_workspace):
181 """NIfTI files should include the viewer action."""
182 menu = QMenu()
183 nifti_path = os.path.join(temp_workspace, "brain.nii")
184 actions = tree_view._add_file_actions(menu, nifti_path, is_nifty=True)
185 assert len(actions) >= 5
187 def test_multi_file_actions_created(self, tree_view):
188 """Multi-file context actions should be created."""
189 menu = QMenu()
190 actions = tree_view._add_multi_file_actions(menu)
191 assert len(actions) == 2
194class TestWorkspaceTreeViewFileOperations:
195 """Tests for file-related operations."""
197 @pytest.fixture
198 def temp_source_file(self):
199 temp_dir = tempfile.mkdtemp()
200 test_file = os.path.join(temp_dir, "source.txt")
201 with open(test_file, "w") as f:
202 f.write("source content")
203 yield test_file
204 shutil.rmtree(temp_dir, ignore_errors=True)
206 def test_add_file_to_workspace_opens_dialog(self, tree_view):
207 """Adding a file to the workspace should open a dialog."""
208 with patch('main.ui.workspace_tree_view.QFileDialog') as MockDialog:
209 mock_dialog = Mock()
210 mock_dialog.exec.return_value = False
211 MockDialog.return_value = mock_dialog
212 tree_view.add_file_to_workspace(None, False)
213 MockDialog.assert_called_once()
215 def test_remove_from_workspace_confirms(self, tree_view, temp_workspace, monkeypatch):
216 """Removing a file should ask for confirmation."""
217 test_file = os.path.join(temp_workspace, "test.txt")
218 monkeypatch.setattr(QMessageBox, 'question',
219 lambda *args, **kwargs: QMessageBox.StandardButton.No)
220 tree_view.remove_from_workspace([test_file])
221 assert os.path.exists(test_file)
223 def test_remove_from_workspace_emits_thread(self, tree_view, temp_workspace, monkeypatch, qtbot):
224 """Confirmed removal should emit a thread signal."""
225 test_file = os.path.join(temp_workspace, "test.txt")
226 monkeypatch.setattr(QMessageBox, 'question',
227 lambda *args, **kwargs: QMessageBox.StandardButton.Yes)
228 with qtbot.waitSignal(tree_view.new_thread, timeout=1000):
229 tree_view.remove_from_workspace([test_file])
231 def test_export_single_file_dialog(self, tree_view, temp_workspace):
232 """Exporting a single file should open a save dialog."""
233 test_file = os.path.join(temp_workspace, "test.txt")
234 with patch('main.ui.workspace_tree_view.QFileDialog') as MockDialog:
235 MockDialog.getSaveFileName.return_value = ("", "")
236 tree_view.export_files([test_file], is_dir=False)
237 MockDialog.getSaveFileName.assert_called_once()
239 def test_export_folder_dialog(self, tree_view, temp_workspace):
240 """Exporting a folder should open a directory dialog."""
241 folder = os.path.join(temp_workspace, "sub-01")
242 with patch('main.ui.workspace_tree_view.QFileDialog') as MockDialog:
243 MockDialog.getExistingDirectory.return_value = ""
244 tree_view.export_files([folder], is_dir=True)
245 MockDialog.getExistingDirectory.assert_called_once()
247 def test_export_multiple_files_dialog(self, tree_view, temp_workspace):
248 """Exporting multiple files should open a directory dialog."""
249 file1 = os.path.join(temp_workspace, "test.txt")
250 file2 = os.path.join(temp_workspace, "test2.txt")
251 with open(file2, "w") as f:
252 f.write("test2")
253 with patch('main.ui.workspace_tree_view.QFileDialog') as MockDialog:
254 MockDialog.getExistingDirectory.return_value = ""
255 tree_view.export_files([file1, file2], is_dir=False)
256 MockDialog.getExistingDirectory.assert_called_once()
259class TestWorkspaceTreeViewNIfTIOperations:
260 """Tests specific to NIfTI file handling."""
262 def test_open_nifti_calls_viewer(self, tree_view, temp_workspace):
263 """Opening a NIfTI file should call the viewer."""
264 nifti_file = os.path.join(temp_workspace, "brain.nii")
265 tree_view._open_nifti(nifti_file)
266 tree_view.context["open_nifti_viewer"].assert_called_once_with(nifti_file)
268 def test_open_nifti_error_shows_message(self, tree_view, temp_workspace, monkeypatch):
269 """An error while opening NIfTI should show a critical message."""
270 tree_view.context["open_nifti_viewer"].side_effect = Exception("Error")
271 nifti_file = os.path.join(temp_workspace, "brain.nii")
272 message_shown = False
274 def mock_critical(*args, **kwargs):
275 nonlocal message_shown
276 message_shown = True
278 monkeypatch.setattr(QMessageBox, 'critical', mock_critical)
279 tree_view._open_nifti(nifti_file)
280 assert message_shown
282 def test_including_json_asks_user(self, tree_view, temp_workspace, monkeypatch):
283 """User should be prompted about including a related JSON file."""
284 asked = False
286 def mock_information(*args, **kwargs):
287 nonlocal asked
288 asked = True
289 return QMessageBox.StandardButton.Ok
291 monkeypatch.setattr(QMessageBox, 'information', mock_information)
292 result = tree_view._including_json("brain", temp_workspace)
293 assert asked
294 assert result.endswith("brain.json")
296 def test_including_json_returns_empty_on_cancel(self, tree_view, temp_workspace, monkeypatch):
297 """If user cancels, the function should return an empty string."""
298 monkeypatch.setattr(QMessageBox, 'information',
299 lambda *args, **kwargs: QMessageBox.StandardButton.Cancel)
300 result = tree_view._including_json("brain", temp_workspace)
301 assert result == ""
303 def test_including_json_returns_empty_if_not_exists(self, tree_view, temp_workspace, monkeypatch):
304 """If JSON does not exist, return an empty string."""
305 monkeypatch.setattr(QMessageBox, 'information',
306 lambda *args, **kwargs: QMessageBox.StandardButton.Ok)
307 result = tree_view._including_json("nonexistent", temp_workspace)
308 assert result == ""
311class TestWorkspaceTreeViewExplorer:
312 """Tests for file opening in system explorer."""
314 def test_open_in_explorer_success(self, tree_view, temp_workspace):
315 """Opening a file should succeed if an app is available."""
316 test_file = os.path.join(temp_workspace, "test.txt")
317 with patch('main.ui.workspace_tree_view.QDesktopServices') as MockServices:
318 MockServices.openUrl.return_value = True
319 tree_view._open_in_explorer(test_file)
320 MockServices.openUrl.assert_called_once()
322 def test_open_in_explorer_no_app_shows_warning(self, tree_view, temp_workspace, monkeypatch):
323 """If no default app exists, a warning should be shown."""
324 test_file = os.path.join(temp_workspace, "test.txt")
325 with patch('main.ui.workspace_tree_view.QDesktopServices') as MockServices:
326 MockServices.openUrl.return_value = False
327 warning_shown = False
329 def mock_warning(*args, **kwargs):
330 nonlocal warning_shown
331 warning_shown = True
333 monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
334 tree_view._open_in_explorer(test_file)
335 assert warning_shown
337 def test_open_in_explorer_exception_shows_error(self, tree_view, temp_workspace, monkeypatch):
338 """If an exception occurs, a critical message should be shown."""
339 test_file = os.path.join(temp_workspace, "test.txt")
340 with patch('main.ui.workspace_tree_view.QDesktopServices') as MockServices:
341 MockServices.openUrl.side_effect = Exception("Error")
342 error_shown = False
344 def mock_critical(*args, **kwargs):
345 nonlocal error_shown
346 error_shown = True
348 monkeypatch.setattr(QMessageBox, 'critical', mock_critical)
349 tree_view._open_in_explorer(test_file)
350 assert error_shown
353class TestWorkspaceTreeViewRoleDialog:
354 """Tests for role selection dialog behavior."""
356 def test_open_role_dialog_creates_dialog(self, tree_view, temp_workspace):
357 """Opening the role dialog should instantiate the dialog."""
358 with patch('main.ui.workspace_tree_view.FileRoleDialog') as MockDialog:
359 mock_dialog_instance = Mock()
360 mock_dialog_instance.exec.return_value = False
361 MockDialog.return_value = mock_dialog_instance
362 tree_view.open_role_dialog(
363 files=["test.nii"],
364 folder_path=temp_workspace,
365 subj="sub-01"
366 )
367 MockDialog.assert_called_once()
369 def test_open_role_dialog_on_accept_emits_thread(self, tree_view, temp_workspace, qtbot):
370 """Accepting the role dialog should emit a thread signal."""
371 with patch('main.ui.workspace_tree_view.FileRoleDialog') as MockDialog:
372 mock_dialog_instance = Mock()
373 mock_dialog_instance.exec.return_value = True
374 mock_dialog_instance.get_relative_path.return_value = "anat"
375 MockDialog.return_value = mock_dialog_instance
377 with qtbot.waitSignal(tree_view.new_thread, timeout=1000):
378 tree_view.open_role_dialog(
379 files=["test.nii"],
380 folder_path=temp_workspace,
381 subj="sub-01"
382 )
385if __name__ == "__main__":
386 pytest.main([__file__, "-v", "--tb=short"])