Coverage for tests/ui/test_pipeline_execution_page.py: 99%
637 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 json
2import os
3import pytest
4import time
5from unittest.mock import Mock, patch, MagicMock, call
6from PyQt6.QtWidgets import QMessageBox
7from PyQt6.QtCore import QProcess, QTimer, QCoreApplication
9from main.ui.pipeline_execution_page import PipelineExecutionPage
12@pytest.fixture
13def mock_get_bin_path():
14 """Mock for get_bin_path."""
15 with patch("main.ui.pipeline_execution_page.get_bin_path") as mock:
16 mock.return_value = "/fake/path/pipeline_runner"
17 yield mock
20@pytest.fixture
21def pipeline_config_exec(temp_workspace):
22 """Create a config for pipeline execution."""
23 config = {
24 "sub-01": {
25 "mri": os.path.join(temp_workspace, "sub-01", "anat", "T1w.nii"),
26 "pet": os.path.join(temp_workspace, "sub-01", "pet", "pet.nii"),
27 },
28 "sub-02": {
29 "mri": "path/to/mri2.nii",
30 }
31 }
33 pipeline_dir = os.path.join(temp_workspace, "pipeline")
34 os.makedirs(pipeline_dir, exist_ok=True)
35 config_path = os.path.join(pipeline_dir, "01_config.json")
37 with open(config_path, "w", encoding="utf-8") as f:
38 json.dump(config, f, indent=4)
40 return config_path, config
43@pytest.fixture
44def mock_tree_view():
45 """Mock for tree_view."""
46 tree_view = Mock()
47 tree_view._open_in_explorer = Mock()
48 return tree_view
51@pytest.fixture
52def mock_context_exec(temp_workspace, signal_emitter, mock_tree_view):
53 """Context for PipelineExecutionPage."""
54 context = {
55 "language_changed": signal_emitter.language_changed,
56 "workspace_path": temp_workspace,
57 "update_main_buttons": Mock(),
58 "return_to_import": Mock(),
59 "tree_view": mock_tree_view,
60 "history": []
61 }
62 return context
65class TestPipelineExecutionPageInitialization:
66 """Tests for page initialization."""
68 def test_initialization_basic(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
69 """Test basic initialization."""
70 config_path, config = pipeline_config_exec
71 page = PipelineExecutionPage(mock_context_exec)
72 qtbot.addWidget(page)
74 assert page.context == mock_context_exec
75 assert page.workspace_path == mock_context_exec["workspace_path"]
76 assert page.previous_page is None
77 assert page.next_page is None
78 assert page.pipeline_process is None
79 assert page.pipeline_completed is False
80 assert page.pipeline_error is None
81 assert page.config_path == config_path
83 def test_initialization_with_previous_page(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
84 """Test initialization with previous page."""
85 previous = Mock()
86 page = PipelineExecutionPage(mock_context_exec, previous_page=previous)
87 qtbot.addWidget(page)
89 assert page.previous_page == previous
91 def test_pipeline_output_dir_created(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
92 """Test that the output directory is created."""
93 page = PipelineExecutionPage(mock_context_exec)
94 qtbot.addWidget(page)
96 assert os.path.exists(page.pipeline_output_dir)
97 assert "01_output" in page.pipeline_output_dir
99 def test_ui_elements_created(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
100 """Test that all UI elements are created."""
101 page = PipelineExecutionPage(mock_context_exec)
102 qtbot.addWidget(page)
104 assert page.header is not None
105 assert page.current_operation is not None
106 assert page.progress_bar is not None
107 assert page.log_text is not None
108 assert page.log_label is not None
109 assert page.stop_button is not None
110 assert isinstance(page.folder_cards, dict)
112 def test_folder_cards_created(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
113 """Test folder cards creation for each patient."""
114 page = PipelineExecutionPage(mock_context_exec)
115 qtbot.addWidget(page)
117 page.on_enter()
119 assert len(page.folder_cards) == 2
121 def test_get_bin_path_called(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
122 """Test that get_bin_path is called."""
123 page = PipelineExecutionPage(mock_context_exec)
124 qtbot.addWidget(page)
126 mock_get_bin_path.assert_called_once_with("pipeline_runner")
127 assert page.pipeline_bin_path == "/fake/path/pipeline_runner"
129 def test_get_bin_path_error(self, qtbot, mock_context_exec, pipeline_config_exec):
130 """Test error handling when get_bin_path fails."""
131 with patch("main.ui.pipeline_execution_page.get_bin_path") as mock:
132 mock.side_effect = FileNotFoundError("Binary not found")
134 with pytest.raises(RuntimeError):
135 page = PipelineExecutionPage(mock_context_exec)
138class TestConfigFileDetection:
139 """Tests for config file detection."""
141 def test_find_latest_config_single(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
142 """Test detection with a single config."""
143 config_path, _ = pipeline_config_exec
144 page = PipelineExecutionPage(mock_context_exec)
145 qtbot.addWidget(page)
147 assert page.config_path == config_path
149 def test_find_latest_config_multiple(self, qtbot, mock_context_exec, temp_workspace, mock_get_bin_path):
150 """Test detection with multiple configs (should take the last one)."""
151 pipeline_dir = os.path.join(temp_workspace, "pipeline")
152 os.makedirs(pipeline_dir, exist_ok=True)
154 # Create multiple configs
155 configs = []
156 for i in [1, 2, 5, 3]: # Non-sequential order
157 config_path = os.path.join(pipeline_dir, f"{i:02d}_config.json")
158 config = {f"sub-{i:02d}": {"mri": "path.nii"}}
159 with open(config_path, "w") as f:
160 json.dump(config, f)
161 configs.append((i, config_path))
163 page = PipelineExecutionPage(mock_context_exec)
164 qtbot.addWidget(page)
166 # Should take 05_config.json
167 assert "05_config.json" in page.config_path
169 def test_find_latest_config_no_files(self, qtbot, mock_context_exec, temp_workspace, mock_get_bin_path):
170 """Test when there are no config files."""
172 pipeline_dir = os.path.join(temp_workspace, "pipeline")
173 os.makedirs(pipeline_dir, exist_ok=True)
175 with patch.object(PipelineExecutionPage, "get_sub_list", return_value=[]):
176 page = PipelineExecutionPage(mock_context_exec)
177 qtbot.addWidget(page)
179 expected = os.path.join(pipeline_dir, "pipeline_config.json")
180 assert page.config_path == expected
183class TestGetSubList:
184 """Tests for the get_sub_list method."""
186 def test_get_sub_list_basic(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
187 """Test patient list extraction."""
188 config_path, config = pipeline_config_exec
189 page = PipelineExecutionPage(mock_context_exec)
190 qtbot.addWidget(page)
192 sub_list = page.get_sub_list(config_path)
194 assert len(sub_list) == 2
195 assert "sub-01" in sub_list
196 assert "sub-02" in sub_list
198 def test_get_sub_list_filters_non_sub(self, qtbot, mock_context_exec, temp_workspace, mock_get_bin_path):
199 """Test that it correctly filters only sub-XX."""
200 pipeline_dir = os.path.join(temp_workspace, "pipeline")
201 config_path = os.path.join(pipeline_dir, "01_config.json")
203 config = {
204 "sub-01": {"mri": "path1.nii"},
205 "sub-02": {"mri": "path2.nii"},
206 "metadata": {"version": "1.0"},
207 "settings": {"param": "value"}
208 }
210 with open(config_path, "w") as f:
211 json.dump(config, f)
213 page = PipelineExecutionPage(mock_context_exec)
214 qtbot.addWidget(page)
216 sub_list = page.get_sub_list(config_path)
218 assert len(sub_list) == 2
219 assert "metadata" not in sub_list
220 assert "settings" not in sub_list
223class TestPipelineExecution:
224 """Tests for pipeline execution."""
226 def test_start_pipeline_basic(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
227 """Test basic pipeline start."""
228 page = PipelineExecutionPage(mock_context_exec)
229 qtbot.addWidget(page)
231 with patch.object(QProcess, 'start') as mock_start, \
232 patch.object(QProcess, 'waitForStarted', return_value=True):
233 page._start_pipeline()
235 assert page.pipeline_process is not None
236 mock_start.assert_called_once()
237 assert page.stop_button.isEnabled()
239 def test_start_pipeline_already_running(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
240 """Test that it does not start the pipeline if already running."""
241 page = PipelineExecutionPage(mock_context_exec)
242 qtbot.addWidget(page)
244 # Simulate process already running
245 page.pipeline_process = Mock()
247 with patch.object(QProcess, 'start') as mock_start:
248 page._start_pipeline()
250 mock_start.assert_not_called()
252 def test_start_pipeline_arguments(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
253 """Test that correct parameters are passed."""
254 config_path, _ = pipeline_config_exec
255 page = PipelineExecutionPage(mock_context_exec)
256 qtbot.addWidget(page)
258 with patch.object(QProcess, 'start') as mock_start, \
259 patch.object(QProcess, 'waitForStarted', return_value=True):
260 page._start_pipeline()
262 # Verify passed arguments
263 call_args = mock_start.call_args
264 assert '--config' in call_args[0][1]
265 assert config_path in call_args[0][1]
266 assert '--work-dir' in call_args[0][1]
267 assert '--out-dir' in call_args[0][1]
269 def test_start_pipeline_failed_to_start(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
270 """Test process start error handling."""
271 page = PipelineExecutionPage(mock_context_exec)
272 qtbot.addWidget(page)
274 with patch.object(QProcess, 'start'), \
275 patch.object(QProcess, 'waitForStarted', return_value=False), \
276 patch.object(page, '_on_pipeline_error') as mock_error:
277 page._start_pipeline()
279 mock_error.assert_called_once()
282class TestProcessOutput:
283 """Tests for process output handling."""
285 def test_process_pipeline_output_log(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
286 """Test processing LOG line."""
287 page = PipelineExecutionPage(mock_context_exec)
288 qtbot.addWidget(page)
290 with patch.object(page, '_log_message') as mock_log, \
291 patch.object(page, '_update_current_operation') as mock_update:
292 page._process_pipeline_output("LOG: Processing started")
294 mock_log.assert_called_once_with("Processing started")
295 mock_update.assert_called_once_with("Processing started")
297 def test_process_pipeline_output_error(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
298 """Test processing ERROR line."""
299 page = PipelineExecutionPage(mock_context_exec)
300 qtbot.addWidget(page)
302 with patch.object(page, '_log_message') as mock_log:
303 page._process_pipeline_output("ERROR: File not found")
305 mock_log.assert_called_once()
306 assert "ERROR:" in mock_log.call_args[0][0]
308 def test_process_pipeline_output_progress(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
309 """Test processing PROGRESS line."""
310 page = PipelineExecutionPage(mock_context_exec)
311 qtbot.addWidget(page)
313 with patch.object(page, '_update_progress') as mock_progress:
314 page._process_pipeline_output("PROGRESS: 5/10")
316 mock_progress.assert_called_once_with("5/10")
318 def test_process_pipeline_output_finished(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
319 """Test processing FINISHED line."""
320 page = PipelineExecutionPage(mock_context_exec)
321 qtbot.addWidget(page)
323 with patch.object(page, '_log_message') as mock_log:
324 page._process_pipeline_output("FINISHED: All done")
326 mock_log.assert_called_once()
328 def test_process_pipeline_output_generic(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
329 """Test processing generic line."""
330 page = PipelineExecutionPage(mock_context_exec)
331 qtbot.addWidget(page)
333 with patch.object(page, '_log_message') as mock_log:
334 page._process_pipeline_output("Some generic output")
336 mock_log.assert_called_once_with("Some generic output")
338 def test_on_stdout_ready(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
339 """Test stdout reading."""
340 page = PipelineExecutionPage(mock_context_exec)
341 qtbot.addWidget(page)
343 mock_process = Mock()
344 mock_data = Mock()
345 mock_data.data.return_value.decode.return_value = "LOG: Test message\nLOG: Another message"
346 mock_process.readAllStandardOutput.return_value = mock_data
347 page.pipeline_process = mock_process
349 with patch.object(page, '_process_pipeline_output') as mock_process_output:
350 page._on_stdout_ready()
352 assert mock_process_output.call_count == 2
354 def test_on_stderr_ready(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
355 """Test stderr reading."""
356 page = PipelineExecutionPage(mock_context_exec)
357 qtbot.addWidget(page)
359 mock_process = Mock()
360 mock_data = Mock()
361 mock_data.data.return_value.decode.return_value = "Error message"
362 mock_process.readAllStandardError.return_value = mock_data
363 page.pipeline_process = mock_process
365 with patch.object(page, '_log_message') as mock_log:
366 page._on_stderr_ready()
368 mock_log.assert_called_once()
369 assert "STDERR:" in mock_log.call_args[0][0]
372class TestProgressUpdates:
373 """Tests for progress updates."""
375 def test_update_progress_valid(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
376 """Test valid progress update."""
377 page = PipelineExecutionPage(mock_context_exec)
378 qtbot.addWidget(page)
380 with patch.object(page, 'check_new_files') as mock_check:
381 page._update_progress("5/10")
383 assert page.progress_bar.value == 50
384 mock_check.assert_called_once()
386 def test_update_progress_invalid(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path,
387 mock_logger):
388 """Test progress update with invalid format."""
389 page = PipelineExecutionPage(mock_context_exec)
390 qtbot.addWidget(page)
392 initial_value = page.progress_bar.value
394 with patch("main.ui.pipeline_execution_page.log", mock_logger):
395 page._update_progress("invalid")
396 # The value should not change
397 assert page.progress_bar.value == initial_value
399 def test_update_current_operation_starting(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
400 """Test operation update: starting."""
401 page = PipelineExecutionPage(mock_context_exec)
402 qtbot.addWidget(page)
404 page._update_current_operation("Starting pipeline execution")
406 assert "Initializing" in page.current_operation.text()
408 def test_update_current_operation_patient(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
409 """Test operation update: processing patient."""
410 page = PipelineExecutionPage(mock_context_exec)
411 qtbot.addWidget(page)
413 page._update_current_operation("Processing patient sub-01")
415 assert "sub-01" in page.current_operation.text()
417 def test_update_current_operation_analysis(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
418 """Test operation update: analysis."""
419 page = PipelineExecutionPage(mock_context_exec)
420 qtbot.addWidget(page)
422 page._update_current_operation("Performing statistical analysis")
424 assert "analysis" in page.current_operation.text().lower()
426 def test_update_current_operation_saving(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
427 """Test operation update: saving."""
428 page = PipelineExecutionPage(mock_context_exec)
429 qtbot.addWidget(page)
431 page._update_current_operation("Saving results to CSV")
433 assert "Saving" in page.current_operation.text()
435 def test_extract_patient_id_from_log(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
436 """Test patient ID extraction from log."""
437 page = PipelineExecutionPage(mock_context_exec)
438 qtbot.addWidget(page)
440 patient_id = page._extract_patient_id_from_log("Processing sub-01 data")
441 assert patient_id == "sub-01"
443 patient_id = page._extract_patient_id_from_log("sub-123 completed")
444 assert patient_id == "sub-123"
446 patient_id = page._extract_patient_id_from_log("No patient here")
447 assert patient_id is None
450class TestProcessCompletion:
451 """Tests for process completion."""
453 def test_on_process_finished_success(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
454 """Test successful completion."""
455 page = PipelineExecutionPage(mock_context_exec)
456 qtbot.addWidget(page)
458 with patch.object(page, '_on_pipeline_finished') as mock_finished:
459 page._on_process_finished(0, QProcess.ExitStatus.NormalExit)
461 mock_finished.assert_called_once()
463 def test_on_process_finished_error(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
464 """Test completion with error."""
465 page = PipelineExecutionPage(mock_context_exec)
466 qtbot.addWidget(page)
468 with patch.object(page, '_on_pipeline_error') as mock_error:
469 page._on_process_finished(1, QProcess.ExitStatus.NormalExit)
471 mock_error.assert_called_once()
473 def test_on_pipeline_finished(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
474 """Test pipeline finished handling."""
475 page = PipelineExecutionPage(mock_context_exec)
476 qtbot.addWidget(page)
478 page._on_pipeline_finished()
480 assert page.pipeline_completed is True
481 assert page.progress_bar.value == 100
482 assert not page.stop_button.isEnabled()
483 assert page.pipeline_process is None
484 mock_context_exec["update_main_buttons"].assert_called_once()
486 def test_on_pipeline_error(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
487 """Test pipeline error handling."""
488 page = PipelineExecutionPage(mock_context_exec)
489 qtbot.addWidget(page)
491 page._on_pipeline_error("Test error message")
493 assert page.pipeline_error == "Test error message"
494 assert not page.stop_button.isEnabled()
495 assert page.pipeline_process is None
496 mock_context_exec["update_main_buttons"].assert_called_once()
498 def test_on_process_error(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
499 """Test handling of different process errors."""
500 page = PipelineExecutionPage(mock_context_exec)
501 qtbot.addWidget(page)
503 with patch.object(page, '_on_pipeline_error') as mock_error:
504 page._on_process_error(QProcess.ProcessError.FailedToStart)
505 mock_error.assert_called_once()
507 with patch.object(page, '_on_pipeline_error') as mock_error:
508 page._on_process_error(QProcess.ProcessError.Crashed)
509 mock_error.assert_called_once()
512class TestStopButton:
513 """Tests for the Stop button."""
515 def test_stop_button_running(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
516 """Test stop while process is running."""
517 page = PipelineExecutionPage(mock_context_exec)
518 qtbot.addWidget(page)
520 mock_process = Mock()
521 mock_process.state.return_value = QProcess.ProcessState.Running
522 mock_process.waitForFinished.return_value = True
523 page.pipeline_process = mock_process
525 page._on_stop_clicked()
527 mock_process.terminate.assert_called_once()
528 assert page.pipeline_process is None
529 assert not page.stop_button.isEnabled()
530 mock_context_exec["update_main_buttons"].assert_called_once()
532 def test_stop_button_forced_kill(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
533 """Test stop with forced kill if terminate fails."""
534 page = PipelineExecutionPage(mock_context_exec)
535 qtbot.addWidget(page)
537 mock_process = Mock()
538 mock_process.state.return_value = QProcess.ProcessState.Running
539 mock_process.waitForFinished.side_effect = [False, True] # First False, then True
540 page.pipeline_process = mock_process
542 page._on_stop_clicked()
544 mock_process.terminate.assert_called_once()
545 mock_process.kill.assert_called_once()
547 def test_stop_button_not_running(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
548 """Test stop when process is not running."""
549 page = PipelineExecutionPage(mock_context_exec)
550 qtbot.addWidget(page)
552 mock_process = Mock()
553 mock_process.state.return_value = QProcess.ProcessState.NotRunning
554 page.pipeline_process = mock_process
556 page._on_stop_clicked()
558 mock_process.terminate.assert_not_called()
561class TestLogMessages:
562 """Tests for logging."""
564 def test_log_message_basic(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
565 """Test basic log message."""
566 page = PipelineExecutionPage(mock_context_exec)
567 qtbot.addWidget(page)
569 initial_text = page.log_text.toPlainText()
571 page._log_message("Test message")
573 log_content = page.log_text.toPlainText()
574 assert "Test message" in log_content
575 assert "[" in log_content # Timestamp
577 def test_log_message_multiple(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
578 """Test multiple messages."""
579 page = PipelineExecutionPage(mock_context_exec)
580 qtbot.addWidget(page)
582 messages = ["First message", "Second message", "Third message"]
583 for msg in messages:
584 page._log_message(msg)
586 log_content = page.log_text.toPlainText()
587 for msg in messages:
588 assert msg in log_content
590 def test_log_message_autoscroll(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
591 """Test autoscroll to the bottom of the log."""
592 page = PipelineExecutionPage(mock_context_exec)
593 qtbot.addWidget(page)
595 # Add many messages to force scrolling
596 for i in range(50):
597 page._log_message(f"Message {i}")
599 scrollbar = page.log_text.verticalScrollBar()
600 # Should be scrolled to the bottom
601 assert scrollbar.value() == scrollbar.maximum()
604class TestNavigation:
605 """Tests for navigation."""
607 def test_on_enter_starts_pipeline(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
608 """Test that on_enter automatically starts the pipeline."""
609 page = PipelineExecutionPage(mock_context_exec)
610 qtbot.addWidget(page)
612 with patch.object(page, '_start_pipeline') as mock_start:
613 page.on_enter()
615 # Wait for QTimer to call the method
616 qtbot.wait(600)
618 mock_start.assert_called_once()
620 def test_on_enter_not_started_twice(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
621 """Test that on_enter does not start twice if already completed."""
622 page = PipelineExecutionPage(mock_context_exec)
623 qtbot.addWidget(page)
625 page.pipeline_completed = True
627 with patch.object(page, '_start_pipeline') as mock_start:
628 page.on_enter()
629 qtbot.wait(600)
631 mock_start.assert_not_called()
633 def test_back_calls_reset(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
634 """Test that back calls reset_page."""
635 page = PipelineExecutionPage(mock_context_exec)
636 qtbot.addWidget(page)
638 previous = Mock()
639 page.previous_page = previous
641 with patch.object(page, 'reset_page') as mock_reset:
642 result = page.back()
644 mock_reset.assert_called_once()
645 assert result == previous
647 def test_back_no_previous_page(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
648 """Test back without previous page."""
649 page = PipelineExecutionPage(mock_context_exec)
650 qtbot.addWidget(page)
652 result = page.back()
654 assert result is None
656 def test_next_with_confirmation(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
657 """Test next with user confirmation."""
658 page = PipelineExecutionPage(mock_context_exec)
659 qtbot.addWidget(page)
661 with patch.object(QMessageBox, 'exec', return_value=QMessageBox.StandardButton.Ok):
662 result = page.next(mock_context_exec)
664 mock_context_exec["return_to_import"].assert_called()
666 def test_next_cancelled(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
667 """Test next with user cancellation."""
668 page = PipelineExecutionPage(mock_context_exec)
669 qtbot.addWidget(page)
671 with patch.object(QMessageBox, 'exec', return_value=QMessageBox.StandardButton.Cancel):
672 result = page.next(mock_context_exec)
674 assert result is None
675 mock_context_exec["return_to_import"].assert_not_called()
677 def test_is_ready_to_go_back_completed(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
678 """Test is_ready_to_go_back when completed."""
679 page = PipelineExecutionPage(mock_context_exec)
680 qtbot.addWidget(page)
682 page.pipeline_completed = True
683 assert page.is_ready_to_go_back() is True
685 def test_is_ready_to_go_back_error(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
686 """Test is_ready_to_go_back when there is an error."""
687 page = PipelineExecutionPage(mock_context_exec)
688 qtbot.addWidget(page)
690 page.pipeline_error = "Some error"
691 assert page.is_ready_to_go_back() is True
693 def test_is_ready_to_go_back_running(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
694 """Test is_ready_to_go_back during execution."""
695 page = PipelineExecutionPage(mock_context_exec)
696 qtbot.addWidget(page)
698 assert page.is_ready_to_go_back() is False
700 def test_is_ready_to_advance(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
701 """Test is_ready_to_advance."""
702 page = PipelineExecutionPage(mock_context_exec)
703 qtbot.addWidget(page)
705 assert page.is_ready_to_advance() is False
707 page.pipeline_completed = True
708 assert page.is_ready_to_advance() is True
710 page.pipeline_completed = False
711 page.pipeline_error = "Error"
712 assert page.is_ready_to_advance() is True
715class TestResetPage:
716 """Tests for page reset."""
718 def test_reset_page_basic(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
719 """Test basic page reset."""
720 page = PipelineExecutionPage(mock_context_exec)
721 qtbot.addWidget(page)
723 # Modify state
724 page.progress_bar.setValue(50)
725 page.pipeline_error = "Some error"
726 page.pipeline_completed = True
727 page.pipeline_process = Mock()
728 page.log_text.setText("Some logs")
730 page.reset_page()
732 assert page.progress_bar.value == 0
733 assert page.pipeline_error is None
734 assert page.pipeline_completed is False
735 assert page.pipeline_process is None
736 assert page.log_text.toPlainText() == ""
737 assert not page.stop_button.isEnabled()
739 def test_reset_page_color(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
740 """Test that reset restores the progress bar color."""
741 page = PipelineExecutionPage(mock_context_exec)
742 qtbot.addWidget(page)
744 page.progress_bar.setColor("#FF0000")
746 page.reset_page()
748 # Verify it returned to default color
749 assert page.progress_bar.color.name() == "#3498db"
751 def test_reset_page_current_operation(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
752 """Test that reset restores the current operation text."""
753 page = PipelineExecutionPage(mock_context_exec)
754 qtbot.addWidget(page)
756 page.current_operation.setText("Processing...")
758 page.reset_page()
760 assert "Preparing to start" in page.current_operation.text()
763class TestCheckNewFiles:
764 """Tests for check_new_files."""
766 def test_check_new_files(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
767 """Test that check_new_files calls the method on all cards."""
768 page = PipelineExecutionPage(mock_context_exec)
769 qtbot.addWidget(page)
771 # Mock folder cards
772 for card in page.folder_cards.values():
773 card.check_new_files = Mock()
775 page.check_new_files()
777 # Verify it was called on all cards
778 for card in page.folder_cards.values():
779 card.check_new_files.assert_called_once()
782class TestDestructor:
783 """Tests for the destructor."""
785 def test_destructor_kills_process(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
786 """Test that the destructor kills the process if running."""
787 page = PipelineExecutionPage(mock_context_exec)
788 qtbot.addWidget(page)
790 mock_process = Mock()
791 mock_process.state.return_value = QProcess.ProcessState.Running
792 mock_process.waitForFinished.return_value = True
793 page.pipeline_process = mock_process
795 # Simulate destruction
796 page.__del__()
798 mock_process.kill.assert_called_once()
799 mock_process.waitForFinished.assert_called_once()
801 def test_destructor_no_process(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
802 """Test that the destructor does not crash if there is no process."""
803 page = PipelineExecutionPage(mock_context_exec)
804 qtbot.addWidget(page)
806 page.pipeline_process = None
808 # Should not crash
809 page.__del__()
812class TestTranslation:
813 """Tests for translations."""
815 def test_translate_ui_called_on_init(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
816 """Test that _translate_ui is called during init."""
817 with patch.object(PipelineExecutionPage, '_translate_ui') as mock_translate:
818 page = PipelineExecutionPage(mock_context_exec)
819 qtbot.addWidget(page)
821 mock_translate.assert_called()
823 def test_translate_ui_updates_labels(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
824 """Test that _translate_ui updates labels."""
825 page = PipelineExecutionPage(mock_context_exec)
826 qtbot.addWidget(page)
828 page._translate_ui()
830 assert page.header.text() is not None
831 assert page.stop_button.text() is not None
832 assert page.log_label.text() is not None
834 def test_language_changed_signal(self, qtbot, mock_context_exec, signal_emitter, pipeline_config_exec,
835 mock_get_bin_path):
836 """Test that the language_changed signal updates the UI."""
837 page = PipelineExecutionPage(mock_context_exec)
838 qtbot.addWidget(page)
840 with patch.object(page, '_translate_ui') as mock_translate:
841 mock_context_exec["language_changed"].connect(mock_translate)
842 mock_context_exec["language_changed"].emit("it")
843 mock_translate.assert_called()
846class TestFolderCards:
847 """Tests for folder cards."""
849 def test_folder_cards_connected_to_tree_view(self, qtbot, mock_context_exec, pipeline_config_exec,
850 mock_get_bin_path, mock_tree_view):
851 """Test that folder cards are connected to tree_view."""
852 page = PipelineExecutionPage(mock_context_exec)
853 qtbot.addWidget(page)
855 # Simulate request to open folder
856 for card in page.folder_cards.values():
857 # Difficult to test signal connection directly, but we can verify cards exist
858 assert card is not None
860 def test_folder_cards_paths(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
861 """Test that folder cards paths are correct."""
862 page = PipelineExecutionPage(mock_context_exec)
863 qtbot.addWidget(page)
865 page.on_enter()
867 for watch_dir in page.watch_dirs:
868 assert page.pipeline_output_dir in watch_dir
869 assert watch_dir in page.folder_cards
872class TestEdgeCases:
873 """Tests for edge cases."""
875 def test_empty_config(self, qtbot, mock_context_exec, temp_workspace, mock_get_bin_path):
876 """Test with empty config."""
877 pipeline_dir = os.path.join(temp_workspace, "pipeline")
878 os.makedirs(pipeline_dir, exist_ok=True)
879 config_path = os.path.join(pipeline_dir, "01_config.json")
881 with open(config_path, "w") as f:
882 json.dump({}, f)
884 page = PipelineExecutionPage(mock_context_exec)
885 qtbot.addWidget(page)
887 assert len(page.folder_cards) == 0
889 def test_invalid_config_id(self, qtbot, mock_context_exec, temp_workspace, mock_get_bin_path, mock_logger):
890 """Test with invalid config ID."""
891 pipeline_dir = os.path.join(temp_workspace, "pipeline")
892 os.makedirs(pipeline_dir, exist_ok=True)
893 config_path = os.path.join(pipeline_dir, "invalid_config.json")
895 with open(config_path, "w") as f:
896 json.dump({"sub-01": {"mri": "path.nii"}}, f)
898 with (patch("main.ui.pipeline_execution_page.log", mock_logger),
899 patch.object(PipelineExecutionPage, "get_sub_list", return_value=[])):
900 page = PipelineExecutionPage(mock_context_exec)
901 qtbot.addWidget(page)
903 # Should use default
904 assert page.pipeline_output_dir == os.path.join(temp_workspace, "pipeline", "pipeline_output")
905 mock_logger.debug.assert_called()
907 def test_process_output_empty_lines(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
908 """Test that empty lines are ignored."""
909 page = PipelineExecutionPage(mock_context_exec)
910 qtbot.addWidget(page)
912 mock_process = Mock()
913 mock_data = Mock()
914 mock_data.data.return_value.decode.return_value = "\n\n \nLOG: Valid message\n\n"
915 mock_process.readAllStandardOutput.return_value = mock_data
916 page.pipeline_process = mock_process
918 with patch.object(page, '_process_pipeline_output') as mock_process_output:
919 page._on_stdout_ready()
921 # Only the valid line should be processed
922 assert mock_process_output.call_count == 1
923 mock_process_output.assert_called_with("LOG: Valid message")
925 def test_multiple_progress_updates(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
926 """Test multiple progress updates."""
927 page = PipelineExecutionPage(mock_context_exec)
928 qtbot.addWidget(page)
930 with patch.object(page, 'check_new_files'):
931 for i in range(1, 11):
932 page._update_progress(f"{i}/10")
933 assert page.progress_bar.value == i*10
935 def test_unicode_in_logs(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
936 """Test with unicode characters in logs."""
937 page = PipelineExecutionPage(mock_context_exec)
938 qtbot.addWidget(page)
940 unicode_messages = [
941 "Processing àèéìòù",
942 "Patient données françaises",
943 "Εργασία ελληνικά",
944 "处理中文"
945 ]
947 for msg in unicode_messages:
948 page._log_message(msg)
950 log_content = page.log_text.toPlainText()
951 for msg in unicode_messages:
952 assert msg in log_content
954 def test_very_long_log_messages(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
955 """Test with very long log messages."""
956 page = PipelineExecutionPage(mock_context_exec)
957 qtbot.addWidget(page)
959 long_message = "A" * 10000
960 page._log_message(long_message)
962 log_content = page.log_text.toPlainText()
963 assert long_message in log_content
965 def test_rapid_process_output(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
966 """Test with rapid process output."""
967 page = PipelineExecutionPage(mock_context_exec)
968 qtbot.addWidget(page)
970 messages = [f"LOG: Message {i}" for i in range(100)]
972 with patch.object(page, '_log_message'):
973 for msg in messages:
974 page._process_pipeline_output(msg)
976 assert True
978 def test_concurrent_stop_and_finish(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
979 """Test concurrent stop and finish."""
980 page = PipelineExecutionPage(mock_context_exec)
981 qtbot.addWidget(page)
983 mock_process = Mock()
984 mock_process.state.return_value = QProcess.ProcessState.Running
985 mock_process.waitForFinished.return_value = True
986 page.pipeline_process = mock_process
988 # Call stop
989 page._on_stop_clicked()
991 # Then finish (as if the process finished just as we were stopping)
992 page._on_pipeline_finished()
994 # Should handle the situation without crashing
995 assert page.pipeline_completed is True
996 assert page.pipeline_process is None
999class TestReturnToImport:
1000 """Tests for _return_to_import."""
1002 def test_return_to_import_not_running(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
1003 """Test return_to_import when not running."""
1004 page = PipelineExecutionPage(mock_context_exec)
1005 qtbot.addWidget(page)
1007 page._return_to_import()
1009 mock_context_exec["return_to_import"].assert_called_once()
1011 def test_return_to_import_while_running(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
1012 """Test that return_to_import is blocked during execution."""
1013 page = PipelineExecutionPage(mock_context_exec)
1014 qtbot.addWidget(page)
1016 mock_process = Mock()
1017 mock_process.state.return_value = QProcess.ProcessState.Running
1018 page.pipeline_process = mock_process
1020 page._return_to_import()
1022 # Should not call return_to_import
1023 mock_context_exec["return_to_import"].assert_not_called()
1026class TestIntegration:
1027 """Integration tests for complete flows."""
1029 def test_full_pipeline_success_flow(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
1030 """Test full flow: start -> execution -> success."""
1031 page = PipelineExecutionPage(mock_context_exec)
1032 qtbot.addWidget(page)
1034 # Simulate start
1035 with patch.object(QProcess, 'start'), \
1036 patch.object(QProcess, 'waitForStarted', return_value=True):
1037 page._start_pipeline()
1038 assert page.pipeline_process is not None
1039 assert page.stop_button.isEnabled()
1041 # Simulate output
1042 page._process_pipeline_output("LOG: Starting pipeline")
1043 page._process_pipeline_output("PROGRESS: 5/10")
1044 page._process_pipeline_output("LOG: Processing sub-01")
1045 page._process_pipeline_output("PROGRESS: 10/10")
1047 # Simulate completion
1048 page._on_pipeline_finished()
1050 assert page.pipeline_completed is True
1051 assert page.progress_bar.value == 100
1052 assert not page.stop_button.isEnabled()
1054 def test_full_pipeline_error_flow(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
1055 """Test full flow: start -> execution -> error."""
1056 page = PipelineExecutionPage(mock_context_exec)
1057 qtbot.addWidget(page)
1059 # Simulate start
1060 with patch.object(QProcess, 'start'), \
1061 patch.object(QProcess, 'waitForStarted', return_value=True):
1062 page._start_pipeline()
1064 # Simulate output
1065 page._process_pipeline_output("LOG: Starting pipeline")
1066 page._process_pipeline_output("ERROR: File not found")
1068 # Simulate error
1069 page._on_pipeline_error("Fatal error occurred")
1071 assert page.pipeline_error == "Fatal error occurred"
1072 assert not page.stop_button.isEnabled()
1073 assert page.pipeline_completed is False
1075 def test_full_pipeline_stop_flow(self, qtbot, mock_context_exec, pipeline_config_exec, mock_get_bin_path):
1076 """Test full flow: start -> execution -> user stop."""
1077 page = PipelineExecutionPage(mock_context_exec)
1078 qtbot.addWidget(page)
1080 # Simulate start
1081 with patch.object(QProcess, 'start'), \
1082 patch.object(QProcess, 'waitForStarted', return_value=True):
1083 page._start_pipeline()
1085 mock_process = Mock()
1086 mock_process.state.return_value = QProcess.ProcessState.Running
1087 mock_process.waitForFinished.return_value = True
1088 page.pipeline_process = mock_process
1090 # Simulate stop
1091 page._on_stop_clicked()
1093 mock_process.terminate.assert_called_once()
1094 assert page.pipeline_process is None
1095 assert not page.stop_button.isEnabled()
1096 assert page.progress_bar.value == 0