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

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 

10 

11from main.ui.workspace_tree_view import WorkspaceTreeView 

12 

13 

14@pytest.fixture 

15def tree_view(qtbot, mock_context): 

16 view = WorkspaceTreeView(mock_context) 

17 qtbot.addWidget(view) 

18 return view 

19 

20 

21class TestWorkspaceTreeViewSetup: 

22 """Tests for WorkspaceTreeView initialization and full workflows.""" 

23 

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) 

28 

29 nifti_file = os.path.join(temp_workspace, "sub-01", "anat", "T1w.nii") 

30 

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

35 

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) 

40 

41 tree_view.handle_double_click(index) 

42 tree_view.context["open_nifti_viewer"].assert_called_once_with(nifti_file) 

43 

44 

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) 

50 

51 

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 

55 

56 

57def test_selection_mode(tree_view): 

58 """Check that extended selection mode is enabled.""" 

59 assert tree_view.selectionMode() == QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection 

60 

61 

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 

65 

66 

67def test_signals_connected(tree_view): 

68 """Verify that required signals are connected.""" 

69 assert hasattr(tree_view, 'new_thread') 

70 

71 

72class TestWorkspaceTreeViewSelection: 

73 """Tests for selection handling.""" 

74 

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) 

80 

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) 

86 

87 

88class TestWorkspaceTreeViewDoubleClick: 

89 """Tests for handling double-click actions.""" 

90 

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) 

97 

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) 

104 

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) 

112 

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

121 

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 

128 

129 def mock_critical(*args, **kwargs): 

130 nonlocal message_shown 

131 message_shown = True 

132 

133 monkeypatch.setattr(QMessageBox, 'critical', mock_critical) 

134 tree_view.handle_double_click(index) 

135 assert message_shown 

136 

137 

138class TestWorkspaceTreeViewColumns: 

139 """Tests for column visibility adjustments.""" 

140 

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) 

147 

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) 

154 

155 

156class TestWorkspaceTreeViewContextMenu: 

157 """Tests for context menu creation.""" 

158 

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

165 

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 

172 

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 

179 

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 

186 

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 

192 

193 

194class TestWorkspaceTreeViewFileOperations: 

195 """Tests for file-related operations.""" 

196 

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) 

205 

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

214 

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) 

222 

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

230 

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

238 

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

246 

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

257 

258 

259class TestWorkspaceTreeViewNIfTIOperations: 

260 """Tests specific to NIfTI file handling.""" 

261 

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) 

267 

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 

273 

274 def mock_critical(*args, **kwargs): 

275 nonlocal message_shown 

276 message_shown = True 

277 

278 monkeypatch.setattr(QMessageBox, 'critical', mock_critical) 

279 tree_view._open_nifti(nifti_file) 

280 assert message_shown 

281 

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 

285 

286 def mock_information(*args, **kwargs): 

287 nonlocal asked 

288 asked = True 

289 return QMessageBox.StandardButton.Ok 

290 

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

295 

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 == "" 

302 

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 == "" 

309 

310 

311class TestWorkspaceTreeViewExplorer: 

312 """Tests for file opening in system explorer.""" 

313 

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

321 

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 

328 

329 def mock_warning(*args, **kwargs): 

330 nonlocal warning_shown 

331 warning_shown = True 

332 

333 monkeypatch.setattr(QMessageBox, 'warning', mock_warning) 

334 tree_view._open_in_explorer(test_file) 

335 assert warning_shown 

336 

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 

343 

344 def mock_critical(*args, **kwargs): 

345 nonlocal error_shown 

346 error_shown = True 

347 

348 monkeypatch.setattr(QMessageBox, 'critical', mock_critical) 

349 tree_view._open_in_explorer(test_file) 

350 assert error_shown 

351 

352 

353class TestWorkspaceTreeViewRoleDialog: 

354 """Tests for role selection dialog behavior.""" 

355 

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

368 

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 

376 

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 ) 

383 

384 

385if __name__ == "__main__": 

386 pytest.main([__file__, "-v", "--tb=short"])