Coverage for tests/components/test_collapsible_patient_frame.py: 99%
517 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
1"""
2test_collapsible_patient_frame.py - Test Suite for CollapsiblePatientFrame
4This suite tests all functionalities of the collapsible patient frame:
5- Locked/unlocked initialization
6- UI building and population
7- Expand/collapse animation
8- File selection (single/multiple)
9- PET4D JSON detection
10- Save configuration
11- Translation/localization
12"""
14import os
15import json
16import glob
17from unittest.mock import Mock, patch, MagicMock
18import pytest
19from PyQt6.QtCore import Qt, QSize
20from PyQt6.QtWidgets import QComboBox, QLabel, QPushButton
21from PyQt6.QtTest import QTest
23from main.components.collapsible_patient_frame import ClickableFrame, CollapsiblePatientFrame
26class TestClickableFrame:
27 """Tests for ClickableFrame helper"""
29 def test_clickable_frame_initialization(self, qtbot):
30 """Test ClickableFrame initialization"""
31 frame = ClickableFrame()
32 qtbot.addWidget(frame)
34 assert hasattr(frame, 'clicked')
36 def test_clickable_frame_emits_signal(self, qtbot):
37 """Test that ClickableFrame emits signal on click"""
38 frame = ClickableFrame()
39 qtbot.addWidget(frame)
41 clicked_count = [0]
42 frame.clicked.connect(lambda: clicked_count.__setitem__(0, clicked_count[0] + 1))
44 QTest.mouseClick(frame, Qt.MouseButton.LeftButton)
46 assert clicked_count[0] == 1
49class TestCollapsiblePatientFrameInitialization:
50 """Tests for CollapsiblePatientFrame initialization"""
52 def test_init_locked_single_choice(self, qtbot, mock_context, temp_workspace):
53 """Test initialization in locked mode (single choice)"""
54 patient_id = "sub-01"
55 files = {"ct": "sub-01/anat/ct.nii.gz"}
56 patterns = {"ct": [os.path.join(temp_workspace, "sub-01/anat/ct*.nii.gz")]}
58 frame = CollapsiblePatientFrame(
59 context=mock_context,
60 patient_id=patient_id,
61 files=files,
62 patterns=patterns,
63 multiple_choice=False
64 )
65 qtbot.addWidget(frame)
67 assert frame.patient_id == patient_id
68 assert frame.files == files
69 assert frame.patterns == patterns
70 assert frame.multiple_choice is False
71 assert frame.locked is True
72 assert frame.is_expanded is False
74 def test_init_unlocked_multiple_choice(self, qtbot, mock_context, temp_workspace):
75 """Test initialization in unlocked mode (multiple choice)"""
76 patient_id = "sub-02"
77 files = {"pet4d": "sub-02/pet/pet4d.nii.gz"}
78 patterns = {"pet4d": [os.path.join(temp_workspace, "sub-02/pet/*.nii.gz")]}
80 frame = CollapsiblePatientFrame(
81 context=mock_context,
82 patient_id=patient_id,
83 files=files,
84 patterns=patterns,
85 multiple_choice=True
86 )
87 qtbot.addWidget(frame)
89 assert frame.multiple_choice is True
90 assert frame.locked is False
92 def test_init_with_save_callback(self, qtbot, mock_context, temp_workspace):
93 """Test initialization with save callback"""
94 save_callback = Mock()
96 frame = CollapsiblePatientFrame(
97 context=mock_context,
98 patient_id="sub-03",
99 files={},
100 patterns={},
101 multiple_choice=True,
102 save_callback=save_callback
103 )
104 qtbot.addWidget(frame)
106 assert frame.save_callback is save_callback
108 def test_workspace_path_assignment(self, qtbot, mock_context, temp_workspace):
109 """Test workspace_path assignment from context"""
110 frame = CollapsiblePatientFrame(
111 context=mock_context,
112 patient_id="sub-04",
113 files={},
114 patterns={},
115 multiple_choice=False
116 )
117 qtbot.addWidget(frame)
119 assert frame.workspace_path == mock_context["workspace_path"]
121 def test_category_widgets_initialized_empty(self, qtbot, mock_context, temp_workspace):
122 """Test that category_widgets is initialized empty"""
123 frame = CollapsiblePatientFrame(
124 context=mock_context,
125 patient_id="sub-05",
126 files={},
127 patterns={},
128 multiple_choice=False
129 )
130 qtbot.addWidget(frame)
132 assert frame.category_widgets == {}
135class TestUIBuilding:
136 """Tests for UI construction"""
138 def test_header_widgets_created(self, qtbot, mock_context, temp_workspace):
139 """Test header widget creation"""
140 frame = CollapsiblePatientFrame(
141 context=mock_context,
142 patient_id="sub-06",
143 files={},
144 patterns={},
145 multiple_choice=False
146 )
147 qtbot.addWidget(frame)
149 assert hasattr(frame, 'subject_name')
150 assert hasattr(frame, 'toggle_button')
151 assert isinstance(frame.subject_name, QLabel)
153 def test_toggle_button_configuration(self, qtbot, mock_context, temp_workspace):
154 """Test toggle button configuration"""
155 frame = CollapsiblePatientFrame(
156 context=mock_context,
157 patient_id="sub-07",
158 files={},
159 patterns={},
160 multiple_choice=False
161 )
162 qtbot.addWidget(frame)
164 assert frame.toggle_button.isCheckable()
165 assert not frame.toggle_button.isChecked()
166 assert frame.toggle_button.arrowType() == Qt.ArrowType.RightArrow
168 def test_content_frame_created(self, qtbot, mock_context, temp_workspace):
169 """Test creation of the content frame"""
170 frame = CollapsiblePatientFrame(
171 context=mock_context,
172 patient_id="sub-08",
173 files={},
174 patterns={},
175 multiple_choice=False
176 )
177 qtbot.addWidget(frame)
179 assert hasattr(frame, 'content_frame')
180 assert frame.content_frame.maximumHeight() == 0 # Starts collapsed
182 def test_animation_setup(self, qtbot, mock_context, temp_workspace):
183 """Test animation setup"""
184 frame = CollapsiblePatientFrame(
185 context=mock_context,
186 patient_id="sub-09",
187 files={},
188 patterns={},
189 multiple_choice=False
190 )
191 qtbot.addWidget(frame)
193 assert hasattr(frame, 'animation')
194 assert frame.animation.duration() == 300
197class TestStyleApplication:
198 """Tests for style application"""
200 def test_locked_style(self, qtbot, mock_context, temp_workspace):
201 """Test style for locked frame"""
202 frame = CollapsiblePatientFrame(
203 context=mock_context,
204 patient_id="sub-10",
205 files={},
206 patterns={},
207 multiple_choice=False # locked
208 )
209 qtbot.addWidget(frame)
211 stylesheet = frame.styleSheet()
212 assert "white" in stylesheet.lower() or "#fff" in stylesheet.lower()
213 assert "#4CAF50" in stylesheet # Green border
215 def test_unlocked_style(self, qtbot, mock_context, temp_workspace):
216 """Test style for unlocked frame"""
217 frame = CollapsiblePatientFrame(
218 context=mock_context,
219 patient_id="sub-11",
220 files={},
221 patterns={},
222 multiple_choice=True # unlocked
223 )
224 qtbot.addWidget(frame)
226 stylesheet = frame.styleSheet()
227 assert "#FFC107" in stylesheet # Yellow border
228 assert "#FFF8E1" in stylesheet # Yellow background
230 def test_style_changes_after_lock(self, qtbot, mock_context, temp_workspace):
231 """Test style update after locking"""
232 frame = CollapsiblePatientFrame(
233 context=mock_context,
234 patient_id="sub-12",
235 files={},
236 patterns={},
237 multiple_choice=True
238 )
239 qtbot.addWidget(frame)
241 # Initially unlocked (yellow)
242 initial_style = frame.styleSheet()
243 assert "#FFC107" in initial_style
245 frame.locked = True
246 frame._apply_style()
248 new_style = frame.styleSheet()
249 assert "#4CAF50" in new_style
252class TestExpandCollapse:
253 """Tests for expand/collapse functionality"""
255 def test_initial_state_collapsed(self, qtbot, mock_context, temp_workspace):
256 """Test initial collapsed state"""
257 frame = CollapsiblePatientFrame(
258 context=mock_context,
259 patient_id="sub-13",
260 files={},
261 patterns={},
262 multiple_choice=False
263 )
264 qtbot.addWidget(frame)
266 assert frame.is_expanded is False
267 assert frame.content_frame.maximumHeight() == 0
269 def test_toggle_expand_programmatically(self, qtbot, mock_context, temp_workspace):
270 """Test programmatic expand"""
271 frame = CollapsiblePatientFrame(
272 context=mock_context,
273 patient_id="sub-14",
274 files={},
275 patterns={},
276 multiple_choice=False
277 )
278 qtbot.addWidget(frame)
280 frame._toggle_expand(True)
282 assert frame.is_expanded is True
283 assert frame.toggle_button.arrowType() == Qt.ArrowType.DownArrow
285 def test_toggle_collapse_programmatically(self, qtbot, mock_context, temp_workspace):
286 """Test programmatic collapse"""
287 frame = CollapsiblePatientFrame(
288 context=mock_context,
289 patient_id="sub-15",
290 files={},
291 patterns={},
292 multiple_choice=False
293 )
294 qtbot.addWidget(frame)
296 frame._toggle_expand(True)
297 frame._toggle_expand(False)
299 assert frame.is_expanded is False
300 assert frame.toggle_button.arrowType() == Qt.ArrowType.RightArrow
302 def test_header_click_toggles_expansion(self, qtbot, mock_context, temp_workspace):
303 """Test that clicking the header toggles expand/collapse"""
304 frame = CollapsiblePatientFrame(
305 context=mock_context,
306 patient_id="sub-16",
307 files={},
308 patterns={},
309 multiple_choice=False
310 )
311 qtbot.addWidget(frame)
313 header_frame = frame.findChild(ClickableFrame)
314 assert header_frame is not None
316 initial_state = frame.is_expanded
317 header_frame.clicked.emit()
319 assert frame.is_expanded != initial_state
322class TestContentPopulation:
323 """Tests for content population"""
325 @patch('glob.glob')
326 def test_populate_single_file_locked(self, mock_glob, qtbot, mock_context, temp_workspace):
327 """Test population with a single file in locked mode"""
328 mock_glob.return_value = [os.path.join(temp_workspace, "sub-17/anat/ct.nii.gz")]
330 files = {"ct": "sub-17/anat/ct.nii.gz"}
331 patterns = {"ct": [os.path.join(temp_workspace, "sub-17/anat/*.nii.gz")]}
333 frame = CollapsiblePatientFrame(
334 context=mock_context,
335 patient_id="sub-17",
336 files=files,
337 patterns=patterns,
338 multiple_choice=False # locked
339 )
340 qtbot.addWidget(frame)
342 assert hasattr(frame, 'file_label')
343 assert frame.file_label.text() == "sub-17/anat/ct.nii.gz"
345 @patch('glob.glob')
346 def test_populate_multiple_files_unlocked(self, mock_glob, qtbot, mock_context, temp_workspace):
347 """Test population with multiple files in unlocked mode"""
348 mock_glob.return_value = [
349 os.path.join(temp_workspace, "sub-18/pet/pet1.nii.gz"),
350 os.path.join(temp_workspace, "sub-18/pet/pet2.nii.gz"),
351 ]
353 files = {"pet4d": "sub-18/pet/pet1.nii.gz"}
354 patterns = {"pet4d": [os.path.join(temp_workspace, "sub-18/pet/*.nii.gz")]}
356 frame = CollapsiblePatientFrame(
357 context=mock_context,
358 patient_id="sub-18",
359 files=files,
360 patterns=patterns,
361 multiple_choice=True # unlocked
362 )
363 qtbot.addWidget(frame)
365 assert "pet4d" in frame.category_widgets
366 combo = frame.category_widgets["pet4d"]
367 assert isinstance(combo, QComboBox)
368 assert combo.count() == 2
370 @patch('glob.glob')
371 def test_populate_no_files_found(self, mock_glob, qtbot, mock_context, temp_workspace):
372 """Test population when no files are found"""
373 mock_glob.return_value = []
375 files = {}
376 patterns = {"ct": [os.path.join(temp_workspace, "sub-19/anat/*.nii.gz")]}
378 frame = CollapsiblePatientFrame(
379 context=mock_context,
380 patient_id="sub-19",
381 files=files,
382 patterns=patterns,
383 multiple_choice=False
384 )
385 qtbot.addWidget(frame)
387 assert hasattr(frame, 'file_label')
388 assert "no file" in frame.file_label.text().lower()
390 @patch('glob.glob')
391 def test_populate_saves_button_in_unlocked_mode(self, mock_glob, qtbot, mock_context, temp_workspace):
392 """Test presence of Save button in unlocked mode"""
393 mock_glob.return_value = [os.path.join(temp_workspace, "sub-20/anat/t1.nii.gz")]
395 files = {}
396 patterns = {"ct": [os.path.join(temp_workspace, "sub-20/anat/*.nii.gz")]}
398 frame = CollapsiblePatientFrame(
399 context=mock_context,
400 patient_id="sub-20",
401 files=files,
402 patterns=patterns,
403 multiple_choice=True # unlocked
404 )
405 qtbot.addWidget(frame)
407 assert hasattr(frame, 'save_btn')
408 assert isinstance(frame.save_btn, QPushButton)
411class TestPET4DJSONDetection:
412 """Tests for detecting JSON associated with PET4D"""
414 @patch('glob.glob')
415 @patch('os.path.exists')
416 def test_pet4d_json_found_locked_mode(self, mock_exists, mock_glob, qtbot, mock_context, temp_workspace):
417 """Test JSON detection in locked mode"""
418 pet_file = os.path.join(temp_workspace, "sub-21/pet/pet4d.nii.gz")
419 json_file = os.path.join(temp_workspace, "sub-21/pet/pet4d.json")
421 mock_glob.return_value = [pet_file]
422 mock_exists.side_effect = lambda path: path == json_file
424 files = {"pet4d": "sub-21/pet/pet4d.nii.gz"}
425 patterns = {"pet4d": [os.path.join(temp_workspace, "sub-21/pet/*.nii.gz")]}
427 frame = CollapsiblePatientFrame(
428 context=mock_context,
429 patient_id="sub-21",
430 files=files,
431 patterns=patterns,
432 multiple_choice=False
433 )
434 qtbot.addWidget(frame)
436 # Should have detected JSON
437 assert "pet4d_json" in frame.files
438 assert "pet4d.json" in frame.files["pet4d_json"]
440 @patch('glob.glob')
441 @patch('os.path.exists')
442 def test_pet4d_json_not_found_locked_mode(self, mock_exists, mock_glob, qtbot, mock_context, temp_workspace):
443 """Test JSON not found in locked mode"""
444 pet_file = os.path.join(temp_workspace, "sub-22/pet/pet4d.nii.gz")
446 mock_glob.return_value = [pet_file]
447 mock_exists.return_value = False # JSON does not exist
449 files = {"pet4d": "sub-22/pet/pet4d.nii.gz"}
450 patterns = {"pet4d": [os.path.join(temp_workspace, "sub-22/pet/*.nii.gz")]}
452 frame = CollapsiblePatientFrame(
453 context=mock_context,
454 patient_id="sub-22",
455 files=files,
456 patterns=patterns,
457 multiple_choice=False
458 )
459 qtbot.addWidget(frame)
461 # JSON should be empty
462 assert frame.files.get("pet4d_json", "") == ""
464 @patch('glob.glob')
465 @patch('os.path.exists')
466 def test_pet4d_json_dynamic_update_unlocked(self, mock_exists, mock_glob, qtbot, mock_context, temp_workspace):
467 """Test dynamic JSON update in unlocked mode"""
468 pet1 = os.path.join(temp_workspace, "sub-23/pet/pet1.nii.gz")
469 pet2 = os.path.join(temp_workspace, "sub-23/pet/pet2.nii.gz")
470 json1 = os.path.join(temp_workspace, "sub-23/pet/pet1.json")
472 mock_glob.return_value = [pet1, pet2]
473 mock_exists.side_effect = lambda path: path == json1
475 files = {"pet4d": "sub-23/pet/pet1.nii.gz"}
476 patterns = {"pet4d": [os.path.join(temp_workspace, "sub-23/pet/*.nii.gz")]}
478 frame = CollapsiblePatientFrame(
479 context=mock_context,
480 patient_id="sub-23",
481 files=files,
482 patterns=patterns,
483 multiple_choice=True
484 )
485 qtbot.addWidget(frame)
487 # Change combo selection
488 combo = frame.category_widgets["pet4d"]
489 combo.setCurrentIndex(1) # pet2
491 # JSON label should update
492 assert hasattr(frame, 'pet4d_json_label')
495class TestSaveConfiguration:
496 """Tests for configuration saving"""
498 @patch('glob.glob')
499 def test_save_patient_calls_callback(self, mock_glob, qtbot, mock_context, temp_workspace):
500 """Test that save calls the callback"""
501 mock_glob.return_value = [
502 os.path.join(temp_workspace, "sub-24/anat/t1.nii.gz"),
503 os.path.join(temp_workspace, "sub-24/anat/t2.nii.gz")
504 ]
506 save_callback = Mock()
507 files = {}
508 patterns = {"ct": [os.path.join(temp_workspace, "sub-24/anat/*.nii.gz")]}
510 frame = CollapsiblePatientFrame(
511 context=mock_context,
512 patient_id="sub-24",
513 files=files,
514 patterns=patterns,
515 multiple_choice=True,
516 save_callback=save_callback
517 )
518 qtbot.addWidget(frame)
520 # Save
521 frame._save_patient()
523 # Callback should be called
524 save_callback.assert_called_once()
525 args = save_callback.call_args[0]
526 assert args[0] == "sub-24" # patient_id
527 assert isinstance(args[1], dict) # files dict
529 @patch('glob.glob')
530 def test_save_patient_marks_not_needing_revision(self, mock_glob, qtbot, mock_context, temp_workspace):
531 """Test that save removes need_revision flag"""
532 mock_glob.return_value = [os.path.join(temp_workspace, "sub-25/anat/scan.nii.gz")]
534 files = {"need_revision": True}
535 patterns = {"ct": [os.path.join(temp_workspace, "sub-25/anat/*.nii.gz")]}
537 frame = CollapsiblePatientFrame(
538 context=mock_context,
539 patient_id="sub-25",
540 files=files,
541 patterns=patterns,
542 multiple_choice=True
543 )
544 qtbot.addWidget(frame)
546 frame._save_patient()
548 assert frame.files["need_revision"] is False
550 @patch('glob.glob')
551 def test_save_patient_locks_frame(self, mock_glob, qtbot, mock_context, temp_workspace):
552 """Test that save locks the frame"""
553 mock_glob.return_value = [os.path.join(temp_workspace, "sub-26/anat/data.nii.gz")]
555 files = {}
556 patterns = {"ct": [os.path.join(temp_workspace, "sub-26/anat/*.nii.gz")]}
558 frame = CollapsiblePatientFrame(
559 context=mock_context,
560 patient_id="sub-26",
561 files=files,
562 patterns=patterns,
563 multiple_choice=True
564 )
565 qtbot.addWidget(frame)
567 assert frame.locked is False
569 frame._save_patient()
571 assert frame.locked is True
573 @patch('glob.glob')
574 def test_save_patient_updates_files_from_combos(self, mock_glob, qtbot, mock_context, temp_workspace):
575 """Test that save updates files from combo selections"""
576 mock_glob.return_value = [
577 os.path.join(temp_workspace, "sub-27/anat/file1.nii.gz"),
578 os.path.join(temp_workspace, "sub-27/anat/file2.nii.gz")
579 ]
581 files = {}
582 patterns = {"ct": [os.path.join(temp_workspace, "sub-27/anat/*.nii.gz")]}
584 frame = CollapsiblePatientFrame(
585 context=mock_context,
586 patient_id="sub-27",
587 files=files,
588 patterns=patterns,
589 multiple_choice=True
590 )
591 qtbot.addWidget(frame)
593 # Change selection
594 combo = frame.category_widgets["ct"]
595 combo.setCurrentIndex(1)
597 frame._save_patient()
599 # files should be updated
600 assert "file2.nii.gz" in frame.files["ct"]
603class TestTranslation:
604 """Tests for translation/localization"""
606 def test_translate_ui_called_on_init(self, qtbot, mock_context, temp_workspace):
607 """Test that _translate_ui is called during init"""
608 with patch.object(CollapsiblePatientFrame, '_translate_ui') as mock_translate:
609 frame = CollapsiblePatientFrame(
610 context=mock_context,
611 patient_id="sub-28",
612 files={},
613 patterns={},
614 multiple_choice=False
615 )
616 qtbot.addWidget(frame)
618 # Should be called during __init__
619 assert mock_translate.called
621 def test_language_changed_signal_connected(self, qtbot, signal_emitter, temp_workspace):
622 """Test connection to language_changed signal"""
623 context = {
624 "workspace_path": temp_workspace,
625 "language_changed": signal_emitter.language_changed
626 }
628 frame = CollapsiblePatientFrame(
629 context=context,
630 patient_id="sub-29",
631 files={},
632 patterns={},
633 multiple_choice=False
634 )
635 qtbot.addWidget(frame)
637 # Emit signal
638 with patch.object(frame, '_translate_ui') as mock_translate:
639 context["language_changed"].connect(mock_translate)
640 context["language_changed"].emit("it")
642 # _translate_ui should be called
643 mock_translate.assert_called()
645 def test_patient_label_translation(self, qtbot, mock_context, temp_workspace):
646 """Test patient label translation"""
647 frame = CollapsiblePatientFrame(
648 context=mock_context,
649 patient_id="sub-30",
650 files={},
651 patterns={},
652 multiple_choice=False
653 )
654 qtbot.addWidget(frame)
656 # Label should contain patient ID
657 assert "sub-30" in frame.subject_name.text()
659 @patch('glob.glob')
660 def test_save_button_translation(self, mock_glob, qtbot, mock_context, temp_workspace):
661 """Test Save button translation"""
662 mock_glob.return_value = [os.path.join(temp_workspace, "sub-31/anat/scan.nii.gz")]
664 frame = CollapsiblePatientFrame(
665 context=mock_context,
666 patient_id="sub-31",
667 files={},
668 patterns={"ct": [os.path.join(temp_workspace, "sub-31/anat/*.nii.gz")]},
669 multiple_choice=True
670 )
671 qtbot.addWidget(frame)
673 # Save button should have text
674 assert len(frame.save_btn.text()) > 0
677class TestEdgeCases:
678 """Tests for edge cases"""
680 def test_empty_patterns_dict(self, qtbot, mock_context, temp_workspace):
681 """Test with empty patterns dict"""
682 frame = CollapsiblePatientFrame(
683 context=mock_context,
684 patient_id="sub-32",
685 files={},
686 patterns={}, # Empty
687 multiple_choice=False
688 )
689 qtbot.addWidget(frame)
691 # Should not crash
692 assert frame.category_widgets == {}
694 def test_empty_files_dict(self, qtbot, mock_context, temp_workspace):
695 """Test with empty files dict"""
696 frame = CollapsiblePatientFrame(
697 context=mock_context,
698 patient_id="sub-33",
699 files={}, # Empty
700 patterns={"ct": ["*.nii.gz"]},
701 multiple_choice=False
702 )
703 qtbot.addWidget(frame)
705 # Should not crash
706 assert frame.files == {}
708 @patch('glob.glob')
709 def test_single_file_in_multiple_choice_mode(self, mock_glob, qtbot, mock_context, temp_workspace):
710 """Test single file in multiple-choice mode"""
711 mock_glob.return_value = [os.path.join(temp_workspace, "sub-34/anat/only_one.nii.gz")]
713 files = {}
714 patterns = {"ct": [os.path.join(temp_workspace, "sub-34/anat/*.nii.gz")]}
716 frame = CollapsiblePatientFrame(
717 context=mock_context,
718 patient_id="sub-34",
719 files=files,
720 patterns=patterns,
721 multiple_choice=True
722 )
723 qtbot.addWidget(frame)
725 # With only one file, it should behave like locked
726 # (internal logic in _populate_content)
728 def test_patient_id_with_special_characters(self, qtbot, mock_context, temp_workspace):
729 """Test patient ID containing special characters"""
730 patient_id = "sub-01_session-2"
732 frame = CollapsiblePatientFrame(
733 context=mock_context,
734 patient_id=patient_id,
735 files={},
736 patterns={},
737 multiple_choice=False
738 )
739 qtbot.addWidget(frame)
741 assert frame.patient_id == patient_id
742 assert patient_id in frame.subject_name.text()
744 def test_no_save_callback_provided(self, qtbot, mock_context, temp_workspace):
745 """Test when no save callback is provided"""
746 frame = CollapsiblePatientFrame(
747 context=mock_context,
748 patient_id="sub-35",
749 files={},
750 patterns={},
751 multiple_choice=True,
752 save_callback=None
753 )
754 qtbot.addWidget(frame)
756 # _save_patient should not crash
757 frame._save_patient()
759 def test_context_without_language_changed(self, qtbot, temp_workspace):
760 """Test context without language_changed signal"""
761 context = {"workspace_path": temp_workspace}
763 frame = CollapsiblePatientFrame(
764 context=context,
765 patient_id="sub-36",
766 files={},
767 patterns={},
768 multiple_choice=False
769 )
770 qtbot.addWidget(frame)
772 # Should not crash
774class TestFilePatternMatching:
775 """Test for file pattern matching"""
777 @patch('glob.glob')
778 def test_glob_called_with_correct_patterns(self, mock_glob, qtbot, mock_context, temp_workspace):
779 """Test glob call with correct patterns"""
780 patterns = {
781 "ct": [os.path.join(temp_workspace, "sub-37/anat/CT*.nii.gz")],
782 "pet4d": [os.path.join(temp_workspace, "sub-37/pet/PET*.nii.gz")]
783 }
785 mock_glob.return_value = []
787 frame = CollapsiblePatientFrame(
788 context=mock_context,
789 patient_id="sub-37",
790 files={},
791 patterns=patterns,
792 multiple_choice=False
793 )
794 qtbot.addWidget(frame)
796 assert mock_glob.call_count >= 2
798 @patch('glob.glob')
799 def test_relative_path_conversion(self, mock_glob, qtbot, mock_context, temp_workspace):
800 """Test relative path conversion"""
801 abs_path = os.path.join(temp_workspace, "sub-38/anat/scan.nii.gz")
802 mock_glob.return_value = [abs_path]
804 files = {}
805 patterns = {"ct": [os.path.join(temp_workspace, "sub-38/anat/*.nii.gz")]}
807 frame = CollapsiblePatientFrame(
808 context=mock_context,
809 patient_id="sub-38",
810 files=files,
811 patterns=patterns,
812 multiple_choice=True
813 )
814 qtbot.addWidget(frame)
816 combo = frame.category_widgets["ct"]
817 item_text = combo.itemText(0)
818 assert not os.path.isabs(item_text)
819 assert "sub-38/anat/scan.nii.gz" in item_text
821 @patch('glob.glob')
822 def test_multiple_patterns_per_category(self, mock_glob, qtbot, mock_context, temp_workspace):
823 """Test multiple patterns per category"""
825 def glob_side_effect(pattern):
826 if "CT*" in pattern:
827 return [os.path.join(temp_workspace, "sub-39/anat/CT.nii.gz")]
828 elif "ct*" in pattern:
829 return [os.path.join(temp_workspace, "sub-39/anat/ct.nii.gz")]
830 return []
832 mock_glob.side_effect = glob_side_effect
834 patterns = {
835 "ct": [
836 os.path.join(temp_workspace, "sub-39/anat/CT*.nii.gz"),
837 os.path.join(temp_workspace, "sub-39/anat/ct*.nii.gz")
838 ]
839 }
841 frame = CollapsiblePatientFrame(
842 context=mock_context,
843 patient_id="sub-39",
844 files={},
845 patterns=patterns,
846 multiple_choice=True
847 )
848 qtbot.addWidget(frame)
850 combo = frame.category_widgets["ct"]
851 assert combo.count() >= 1
854class TestComboBoxBehavior:
855 """Test combobox behavior"""
857 @patch('glob.glob')
858 def test_combo_current_index_set_from_files(self, mock_glob, qtbot, mock_context, temp_workspace):
859 """Test files dict manage combobox behavior"""
860 mock_glob.return_value = [
861 os.path.join(temp_workspace, "sub-40/anat/file1.nii.gz"),
862 os.path.join(temp_workspace, "sub-40/anat/file2.nii.gz"),
863 os.path.join(temp_workspace, "sub-40/anat/file3.nii.gz")
864 ]
866 files = {"ct": "sub-40/anat/file2.nii.gz"}
867 patterns = {"ct": [os.path.join(temp_workspace, "sub-40/anat/*.nii.gz")]}
869 frame = CollapsiblePatientFrame(
870 context=mock_context,
871 patient_id="sub-40",
872 files=files,
873 patterns=patterns,
874 multiple_choice=True
875 )
876 qtbot.addWidget(frame)
878 combo = frame.category_widgets["ct"]
879 assert "file2" in combo.currentText()
881 @patch('glob.glob')
882 def test_combo_default_to_first_if_no_match(self, mock_glob, qtbot, mock_context, temp_workspace):
883 """Test combobox behavior with default value if no match"""
884 mock_glob.return_value = [
885 os.path.join(temp_workspace, "sub-41/anat/new1.nii.gz"),
886 os.path.join(temp_workspace, "sub-41/anat/new2.nii.gz")
887 ]
889 files = {"ct": "sub-41/anat/nonexistent.nii.gz"}
890 patterns = {"ct": [os.path.join(temp_workspace, "sub-41/anat/*.nii.gz")]}
892 frame = CollapsiblePatientFrame(
893 context=mock_context,
894 patient_id="sub-41",
895 files=files,
896 patterns=patterns,
897 multiple_choice=True
898 )
899 qtbot.addWidget(frame)
901 combo = frame.category_widgets["ct"]
902 assert combo.currentIndex() == 0
904 @patch('glob.glob')
905 def test_combo_minimum_height(self, mock_glob, qtbot, mock_context, temp_workspace):
906 """Test minimum height combobox behavior"""
907 mock_glob.return_value = [os.path.join(temp_workspace, "sub-42/anat/scan.nii.gz")]
909 patterns = {"ct": [os.path.join(temp_workspace, "sub-42/anat/*.nii.gz")]}
911 frame = CollapsiblePatientFrame(
912 context=mock_context,
913 patient_id="sub-42",
914 files={},
915 patterns=patterns,
916 multiple_choice=True
917 )
918 qtbot.addWidget(frame)
920 combo = frame.category_widgets["ct"]
921 assert combo.minimumHeight() >= 28
924class TestAnimationBehavior:
925 """Test animation behavior"""
927 def test_animation_duration(self, qtbot, mock_context, temp_workspace):
928 """Test animation duration"""
929 frame = CollapsiblePatientFrame(
930 context=mock_context,
931 patient_id="sub-43",
932 files={},
933 patterns={},
934 multiple_choice=False
935 )
936 qtbot.addWidget(frame)
938 assert frame.animation.duration() == 300
940 def test_animation_easing_curve(self, qtbot, mock_context, temp_workspace):
941 """Test easing curve"""
942 from PyQt6.QtCore import QEasingCurve
944 frame = CollapsiblePatientFrame(
945 context=mock_context,
946 patient_id="sub-44",
947 files={},
948 patterns={},
949 multiple_choice=False
950 )
951 qtbot.addWidget(frame)
953 assert frame.animation.easingCurve().type() == QEasingCurve.Type.InOutCubic
955 def test_animation_target_property(self, qtbot, mock_context, temp_workspace):
956 """Test target property"""
957 frame = CollapsiblePatientFrame(
958 context=mock_context,
959 patient_id="sub-45",
960 files={},
961 patterns={},
962 multiple_choice=False
963 )
964 qtbot.addWidget(frame)
966 assert frame.animation.propertyName() == b"maximumHeight"
967 assert frame.animation.targetObject() == frame.content_frame
970class TestCategoryLabels:
971 """Test for category labels"""
973 @patch('glob.glob')
974 def test_category_label_formatting(self, mock_glob, qtbot, mock_context, temp_workspace):
975 """Test category label formatting"""
976 mock_glob.return_value = [os.path.join(temp_workspace, "sub-46/anat/scan.nii.gz")]
978 patterns = {"pet_4d_dynamic": [os.path.join(temp_workspace, "sub-46/pet/*.nii.gz")]}
980 frame = CollapsiblePatientFrame(
981 context=mock_context,
982 patient_id="sub-46",
983 files={},
984 patterns=patterns,
985 multiple_choice=False
986 )
987 qtbot.addWidget(frame)
989 labels = frame.findChildren(QLabel)
990 category_labels = [l for l in labels if "pet" in l.text().lower()]
991 assert len(category_labels) > 0
994class TestIntegrationScenarios:
995 """Test integration"""
997 @patch('glob.glob')
998 @patch('os.path.exists')
999 def test_complete_workflow_locked_patient(self, mock_exists, mock_glob, qtbot, mock_context, temp_workspace):
1000 """Test workflow locked patient"""
1001 ct_file = os.path.join(temp_workspace, "sub-47/anat/CT.nii.gz")
1002 pet_file = os.path.join(temp_workspace, "sub-47/pet/PET4D.nii.gz")
1003 pet_json = os.path.join(temp_workspace, "sub-47/pet/PET4D.json")
1005 def glob_side_effect(pattern):
1006 if "CT" in pattern:
1007 return [ct_file]
1008 elif "PET" in pattern:
1009 return [pet_file]
1010 return []
1012 mock_glob.side_effect = glob_side_effect
1013 mock_exists.side_effect = lambda p: p == pet_json
1015 files = {
1016 "ct": "sub-47/anat/CT.nii.gz",
1017 "pet4d": "sub-47/pet/PET4D.nii.gz"
1018 }
1019 patterns = {
1020 "ct": [os.path.join(temp_workspace, "sub-47/anat/CT*.nii.gz")],
1021 "pet4d": [os.path.join(temp_workspace, "sub-47/pet/PET*.nii.gz")]
1022 }
1024 frame = CollapsiblePatientFrame(
1025 context=mock_context,
1026 patient_id="sub-47",
1027 files=files,
1028 patterns=patterns,
1029 multiple_choice=False
1030 )
1031 qtbot.addWidget(frame)
1033 assert frame.locked is True
1034 assert hasattr(frame, 'file_label')
1035 assert "pet4d_json" in frame.files
1037 @patch('glob.glob')
1038 def test_complete_workflow_unlocked_patient(self, mock_glob, qtbot, mock_context, temp_workspace):
1039 """Test workflow unlocked patient"""
1040 ct_files = [
1041 os.path.join(temp_workspace, "sub-48/anat/CT1.nii.gz"),
1042 os.path.join(temp_workspace, "sub-48/anat/CT2.nii.gz")
1043 ]
1044 pet_files = [
1045 os.path.join(temp_workspace, "sub-48/pet/PET1.nii.gz"),
1046 os.path.join(temp_workspace, "sub-48/pet/PET2.nii.gz")
1047 ]
1049 def glob_side_effect(pattern):
1050 if "CT" in pattern:
1051 return ct_files
1052 elif "PET" in pattern:
1053 return pet_files
1054 return []
1056 mock_glob.side_effect = glob_side_effect
1058 files = {}
1059 patterns = {
1060 "ct": [os.path.join(temp_workspace, "sub-48/anat/CT*.nii.gz")],
1061 "pet4d": [os.path.join(temp_workspace, "sub-48/pet/PET*.nii.gz")]
1062 }
1064 save_callback = Mock()
1066 frame = CollapsiblePatientFrame(
1067 context=mock_context,
1068 patient_id="sub-48",
1069 files=files,
1070 patterns=patterns,
1071 multiple_choice=True,
1072 save_callback=save_callback
1073 )
1074 qtbot.addWidget(frame)
1076 assert frame.locked is False
1077 assert "ct" in frame.category_widgets
1078 assert "pet4d" in frame.category_widgets
1080 frame.category_widgets["ct"].setCurrentIndex(1)
1081 frame.category_widgets["pet4d"].setCurrentIndex(0)
1082 frame._save_patient()
1084 save_callback.assert_called_once()
1085 assert frame.locked is True
1087 @patch('glob.glob')
1088 def test_expand_select_save_workflow(self, mock_glob, qtbot, mock_context, temp_workspace):
1089 """Test workflow: expand → select → save"""
1090 mock_glob.return_value = [
1091 os.path.join(temp_workspace, "sub-49/anat/scan1.nii.gz"),
1092 os.path.join(temp_workspace, "sub-49/anat/scan2.nii.gz")
1093 ]
1095 patterns = {"ct": [os.path.join(temp_workspace, "sub-49/anat/*.nii.gz")]}
1097 frame = CollapsiblePatientFrame(
1098 context=mock_context,
1099 patient_id="sub-49",
1100 files={},
1101 patterns=patterns,
1102 multiple_choice=True
1103 )
1104 qtbot.addWidget(frame)
1106 frame._toggle_expand(True)
1107 assert frame.is_expanded is True
1109 combo = frame.category_widgets["ct"]
1110 combo.setCurrentIndex(1)
1112 frame._save_patient()
1114 assert "scan2" in frame.files["ct"]
1115 assert frame.locked is True
1117class TestMemoryAndCleanup:
1118 """Tests for memory management and cleanup"""
1120 @patch('glob.glob')
1121 def test_repopulate_content_cleans_old_widgets(self, mock_glob, qtbot, mock_context, temp_workspace):
1122 """Test that _populate_content cleans old widgets"""
1123 mock_glob.return_value = [os.path.join(temp_workspace, "sub-50/anat/scan.nii.gz")]
1125 patterns = {"ct": [os.path.join(temp_workspace, "sub-50/anat/*.nii.gz")]}
1127 frame = CollapsiblePatientFrame(
1128 context=mock_context,
1129 patient_id="sub-50",
1130 files={},
1131 patterns=patterns,
1132 multiple_choice=True
1133 )
1134 qtbot.addWidget(frame)
1136 initial_widget_count = frame.content_layout.count()
1138 # Re-populate
1139 frame._populate_content()
1141 # Count should be similar (old widgets removed)
1142 new_widget_count = frame.content_layout.count()
1143 assert new_widget_count > 0
1145 def test_widget_deletion_on_repopulate(self, qtbot, mock_context, temp_workspace):
1146 """Test widget deletion on re-population"""
1147 frame = CollapsiblePatientFrame(
1148 context=mock_context,
1149 patient_id="sub-51",
1150 files={},
1151 patterns={},
1152 multiple_choice=False
1153 )
1154 qtbot.addWidget(frame)
1156 # Add a custom widget
1157 from PyQt6.QtWidgets import QWidget
1158 custom_widget = QWidget()
1159 frame.content_layout.addWidget(custom_widget)
1161 assert custom_widget.parent() is not None
1163 # Re-populate
1164 frame._populate_content()
1166 # Custom widget should be scheduled for deletion
1167 # (deleteLater is called)
1170class TestAccessibility:
1171 """Tests for accessibility"""
1173 def test_toggle_button_cursor(self, qtbot, mock_context, temp_workspace):
1174 """Test cursor of the toggle button"""
1175 frame = CollapsiblePatientFrame(
1176 context=mock_context,
1177 patient_id="sub-52",
1178 files={},
1179 patterns={},
1180 multiple_choice=False
1181 )
1182 qtbot.addWidget(frame)
1184 # Should have PointingHand cursor
1185 assert frame.toggle_button.cursor().shape() == Qt.CursorShape.PointingHandCursor
1187 def test_toggle_button_checkable(self, qtbot, mock_context, temp_workspace):
1188 """Test that toggle button is checkable"""
1189 frame = CollapsiblePatientFrame(
1190 context=mock_context,
1191 patient_id="sub-53",
1192 files={},
1193 patterns={},
1194 multiple_choice=False
1195 )
1196 qtbot.addWidget(frame)
1198 assert frame.toggle_button.isCheckable()
1200 @patch('glob.glob')
1201 def test_save_button_minimum_height(self, mock_glob, qtbot, mock_context, temp_workspace):
1202 """Test minimum height of save button"""
1203 mock_glob.return_value = [os.path.join(temp_workspace, "sub-54/anat/scan.nii.gz")]
1205 patterns = {"ct": [os.path.join(temp_workspace, "sub-54/anat/*.nii.gz")]}
1207 frame = CollapsiblePatientFrame(
1208 context=mock_context,
1209 patient_id="sub-54",
1210 files={},
1211 patterns=patterns,
1212 multiple_choice=True
1213 )
1214 qtbot.addWidget(frame)
1216 assert frame.save_btn.minimumHeight() >= 32
1219class TestVisualEffects:
1220 """Tests for visual effects"""
1222 def test_drop_shadow_applied(self, qtbot, mock_context, temp_workspace):
1223 """Test drop shadow application"""
1224 frame = CollapsiblePatientFrame(
1225 context=mock_context,
1226 patient_id="sub-55",
1227 files={},
1228 patterns={},
1229 multiple_choice=False
1230 )
1231 qtbot.addWidget(frame)
1233 # Should have a graphics effect
1234 assert frame.graphicsEffect() is not None
1236 def test_frame_shape(self, qtbot, mock_context, temp_workspace):
1237 """Test frame shape"""
1238 from PyQt6.QtWidgets import QFrame
1240 frame = CollapsiblePatientFrame(
1241 context=mock_context,
1242 patient_id="sub-56",
1243 files={},
1244 patterns={},
1245 multiple_choice=False
1246 )
1247 qtbot.addWidget(frame)
1249 assert frame.frameShape() == QFrame.Shape.StyledPanel
1251 def test_object_name_set(self, qtbot, mock_context, temp_workspace):
1252 """Test object name is set"""
1253 frame = CollapsiblePatientFrame(
1254 context=mock_context,
1255 patient_id="sub-57",
1256 files={},
1257 patterns={},
1258 multiple_choice=False
1259 )
1260 qtbot.addWidget(frame)
1262 assert frame.objectName() == "collapsiblePatientFrame"
1265# Parametrized tests
1266@pytest.mark.parametrize("multiple_choice,expected_locked", [
1267 (False, True),
1268 (True, False),
1269])
1270def test_locked_state_by_multiple_choice(multiple_choice, expected_locked, qtbot, mock_context, temp_workspace):
1271 """Parametrized test for locked state based on multiple_choice"""
1272 frame = CollapsiblePatientFrame(
1273 context=mock_context,
1274 patient_id="sub-param",
1275 files={},
1276 patterns={},
1277 multiple_choice=multiple_choice
1278 )
1279 qtbot.addWidget(frame)
1281 assert frame.locked == expected_locked
1284@pytest.mark.parametrize("arrow_type,is_expanded", [
1285 (Qt.ArrowType.RightArrow, False),
1286 (Qt.ArrowType.DownArrow, True),
1287])
1288def test_arrow_type_by_expansion_state(arrow_type, is_expanded, qtbot, mock_context, temp_workspace):
1289 """Parametrized test for arrow type based on expansion"""
1290 frame = CollapsiblePatientFrame(
1291 context=mock_context,
1292 patient_id="sub-arrow",
1293 files={},
1294 patterns={},
1295 multiple_choice=False
1296 )
1297 qtbot.addWidget(frame)
1299 frame._toggle_expand(is_expanded)
1301 assert frame.toggle_button.arrowType() == arrow_type
1304@pytest.mark.parametrize("patient_id", [
1305 "sub-001",
1306 "sub-99",
1307 "patient_A",
1308 "subject-01-session-1"
1309])
1310def test_various_patient_id_formats(patient_id, qtbot, mock_context, temp_workspace):
1311 """Parametrized test for various patient ID formats"""
1312 frame = CollapsiblePatientFrame(
1313 context=mock_context,
1314 patient_id=patient_id,
1315 files={},
1316 patterns={},
1317 multiple_choice=False
1318 )
1319 qtbot.addWidget(frame)
1321 assert frame.patient_id == patient_id
1322 assert patient_id in frame.subject_name.text()
1325if __name__ == "__main__":
1326 pytest.main([__file__, "-v"])