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

1""" 

2test_collapsible_patient_frame.py - Test Suite for CollapsiblePatientFrame 

3 

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

13 

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 

22 

23from main.components.collapsible_patient_frame import ClickableFrame, CollapsiblePatientFrame 

24 

25 

26class TestClickableFrame: 

27 """Tests for ClickableFrame helper""" 

28 

29 def test_clickable_frame_initialization(self, qtbot): 

30 """Test ClickableFrame initialization""" 

31 frame = ClickableFrame() 

32 qtbot.addWidget(frame) 

33 

34 assert hasattr(frame, 'clicked') 

35 

36 def test_clickable_frame_emits_signal(self, qtbot): 

37 """Test that ClickableFrame emits signal on click""" 

38 frame = ClickableFrame() 

39 qtbot.addWidget(frame) 

40 

41 clicked_count = [0] 

42 frame.clicked.connect(lambda: clicked_count.__setitem__(0, clicked_count[0] + 1)) 

43 

44 QTest.mouseClick(frame, Qt.MouseButton.LeftButton) 

45 

46 assert clicked_count[0] == 1 

47 

48 

49class TestCollapsiblePatientFrameInitialization: 

50 """Tests for CollapsiblePatientFrame initialization""" 

51 

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

57 

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) 

66 

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 

73 

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

79 

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) 

88 

89 assert frame.multiple_choice is True 

90 assert frame.locked is False 

91 

92 def test_init_with_save_callback(self, qtbot, mock_context, temp_workspace): 

93 """Test initialization with save callback""" 

94 save_callback = Mock() 

95 

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) 

105 

106 assert frame.save_callback is save_callback 

107 

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) 

118 

119 assert frame.workspace_path == mock_context["workspace_path"] 

120 

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) 

131 

132 assert frame.category_widgets == {} 

133 

134 

135class TestUIBuilding: 

136 """Tests for UI construction""" 

137 

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) 

148 

149 assert hasattr(frame, 'subject_name') 

150 assert hasattr(frame, 'toggle_button') 

151 assert isinstance(frame.subject_name, QLabel) 

152 

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) 

163 

164 assert frame.toggle_button.isCheckable() 

165 assert not frame.toggle_button.isChecked() 

166 assert frame.toggle_button.arrowType() == Qt.ArrowType.RightArrow 

167 

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) 

178 

179 assert hasattr(frame, 'content_frame') 

180 assert frame.content_frame.maximumHeight() == 0 # Starts collapsed 

181 

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) 

192 

193 assert hasattr(frame, 'animation') 

194 assert frame.animation.duration() == 300 

195 

196 

197class TestStyleApplication: 

198 """Tests for style application""" 

199 

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) 

210 

211 stylesheet = frame.styleSheet() 

212 assert "white" in stylesheet.lower() or "#fff" in stylesheet.lower() 

213 assert "#4CAF50" in stylesheet # Green border 

214 

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) 

225 

226 stylesheet = frame.styleSheet() 

227 assert "#FFC107" in stylesheet # Yellow border 

228 assert "#FFF8E1" in stylesheet # Yellow background 

229 

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) 

240 

241 # Initially unlocked (yellow) 

242 initial_style = frame.styleSheet() 

243 assert "#FFC107" in initial_style 

244 

245 frame.locked = True 

246 frame._apply_style() 

247 

248 new_style = frame.styleSheet() 

249 assert "#4CAF50" in new_style 

250 

251 

252class TestExpandCollapse: 

253 """Tests for expand/collapse functionality""" 

254 

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) 

265 

266 assert frame.is_expanded is False 

267 assert frame.content_frame.maximumHeight() == 0 

268 

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) 

279 

280 frame._toggle_expand(True) 

281 

282 assert frame.is_expanded is True 

283 assert frame.toggle_button.arrowType() == Qt.ArrowType.DownArrow 

284 

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) 

295 

296 frame._toggle_expand(True) 

297 frame._toggle_expand(False) 

298 

299 assert frame.is_expanded is False 

300 assert frame.toggle_button.arrowType() == Qt.ArrowType.RightArrow 

301 

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) 

312 

313 header_frame = frame.findChild(ClickableFrame) 

314 assert header_frame is not None 

315 

316 initial_state = frame.is_expanded 

317 header_frame.clicked.emit() 

318 

319 assert frame.is_expanded != initial_state 

320 

321 

322class TestContentPopulation: 

323 """Tests for content population""" 

324 

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

329 

330 files = {"ct": "sub-17/anat/ct.nii.gz"} 

331 patterns = {"ct": [os.path.join(temp_workspace, "sub-17/anat/*.nii.gz")]} 

332 

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) 

341 

342 assert hasattr(frame, 'file_label') 

343 assert frame.file_label.text() == "sub-17/anat/ct.nii.gz" 

344 

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 ] 

352 

353 files = {"pet4d": "sub-18/pet/pet1.nii.gz"} 

354 patterns = {"pet4d": [os.path.join(temp_workspace, "sub-18/pet/*.nii.gz")]} 

355 

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) 

364 

365 assert "pet4d" in frame.category_widgets 

366 combo = frame.category_widgets["pet4d"] 

367 assert isinstance(combo, QComboBox) 

368 assert combo.count() == 2 

369 

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 = [] 

374 

375 files = {} 

376 patterns = {"ct": [os.path.join(temp_workspace, "sub-19/anat/*.nii.gz")]} 

377 

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) 

386 

387 assert hasattr(frame, 'file_label') 

388 assert "no file" in frame.file_label.text().lower() 

389 

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

394 

395 files = {} 

396 patterns = {"ct": [os.path.join(temp_workspace, "sub-20/anat/*.nii.gz")]} 

397 

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) 

406 

407 assert hasattr(frame, 'save_btn') 

408 assert isinstance(frame.save_btn, QPushButton) 

409 

410 

411class TestPET4DJSONDetection: 

412 """Tests for detecting JSON associated with PET4D""" 

413 

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

420 

421 mock_glob.return_value = [pet_file] 

422 mock_exists.side_effect = lambda path: path == json_file 

423 

424 files = {"pet4d": "sub-21/pet/pet4d.nii.gz"} 

425 patterns = {"pet4d": [os.path.join(temp_workspace, "sub-21/pet/*.nii.gz")]} 

426 

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) 

435 

436 # Should have detected JSON 

437 assert "pet4d_json" in frame.files 

438 assert "pet4d.json" in frame.files["pet4d_json"] 

439 

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

445 

446 mock_glob.return_value = [pet_file] 

447 mock_exists.return_value = False # JSON does not exist 

448 

449 files = {"pet4d": "sub-22/pet/pet4d.nii.gz"} 

450 patterns = {"pet4d": [os.path.join(temp_workspace, "sub-22/pet/*.nii.gz")]} 

451 

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) 

460 

461 # JSON should be empty 

462 assert frame.files.get("pet4d_json", "") == "" 

463 

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

471 

472 mock_glob.return_value = [pet1, pet2] 

473 mock_exists.side_effect = lambda path: path == json1 

474 

475 files = {"pet4d": "sub-23/pet/pet1.nii.gz"} 

476 patterns = {"pet4d": [os.path.join(temp_workspace, "sub-23/pet/*.nii.gz")]} 

477 

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) 

486 

487 # Change combo selection 

488 combo = frame.category_widgets["pet4d"] 

489 combo.setCurrentIndex(1) # pet2 

490 

491 # JSON label should update 

492 assert hasattr(frame, 'pet4d_json_label') 

493 

494 

495class TestSaveConfiguration: 

496 """Tests for configuration saving""" 

497 

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 ] 

505 

506 save_callback = Mock() 

507 files = {} 

508 patterns = {"ct": [os.path.join(temp_workspace, "sub-24/anat/*.nii.gz")]} 

509 

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) 

519 

520 # Save 

521 frame._save_patient() 

522 

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 

528 

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

533 

534 files = {"need_revision": True} 

535 patterns = {"ct": [os.path.join(temp_workspace, "sub-25/anat/*.nii.gz")]} 

536 

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) 

545 

546 frame._save_patient() 

547 

548 assert frame.files["need_revision"] is False 

549 

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

554 

555 files = {} 

556 patterns = {"ct": [os.path.join(temp_workspace, "sub-26/anat/*.nii.gz")]} 

557 

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) 

566 

567 assert frame.locked is False 

568 

569 frame._save_patient() 

570 

571 assert frame.locked is True 

572 

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 ] 

580 

581 files = {} 

582 patterns = {"ct": [os.path.join(temp_workspace, "sub-27/anat/*.nii.gz")]} 

583 

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) 

592 

593 # Change selection 

594 combo = frame.category_widgets["ct"] 

595 combo.setCurrentIndex(1) 

596 

597 frame._save_patient() 

598 

599 # files should be updated 

600 assert "file2.nii.gz" in frame.files["ct"] 

601 

602 

603class TestTranslation: 

604 """Tests for translation/localization""" 

605 

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) 

617 

618 # Should be called during __init__ 

619 assert mock_translate.called 

620 

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 } 

627 

628 frame = CollapsiblePatientFrame( 

629 context=context, 

630 patient_id="sub-29", 

631 files={}, 

632 patterns={}, 

633 multiple_choice=False 

634 ) 

635 qtbot.addWidget(frame) 

636 

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

641 

642 # _translate_ui should be called 

643 mock_translate.assert_called() 

644 

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) 

655 

656 # Label should contain patient ID 

657 assert "sub-30" in frame.subject_name.text() 

658 

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

663 

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) 

672 

673 # Save button should have text 

674 assert len(frame.save_btn.text()) > 0 

675 

676 

677class TestEdgeCases: 

678 """Tests for edge cases""" 

679 

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) 

690 

691 # Should not crash 

692 assert frame.category_widgets == {} 

693 

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) 

704 

705 # Should not crash 

706 assert frame.files == {} 

707 

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

712 

713 files = {} 

714 patterns = {"ct": [os.path.join(temp_workspace, "sub-34/anat/*.nii.gz")]} 

715 

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) 

724 

725 # With only one file, it should behave like locked 

726 # (internal logic in _populate_content) 

727 

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" 

731 

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) 

740 

741 assert frame.patient_id == patient_id 

742 assert patient_id in frame.subject_name.text() 

743 

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) 

755 

756 # _save_patient should not crash 

757 frame._save_patient() 

758 

759 def test_context_without_language_changed(self, qtbot, temp_workspace): 

760 """Test context without language_changed signal""" 

761 context = {"workspace_path": temp_workspace} 

762 

763 frame = CollapsiblePatientFrame( 

764 context=context, 

765 patient_id="sub-36", 

766 files={}, 

767 patterns={}, 

768 multiple_choice=False 

769 ) 

770 qtbot.addWidget(frame) 

771 

772 # Should not crash 

773 

774class TestFilePatternMatching: 

775 """Test for file pattern matching""" 

776 

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 } 

784 

785 mock_glob.return_value = [] 

786 

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) 

795 

796 assert mock_glob.call_count >= 2 

797 

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] 

803 

804 files = {} 

805 patterns = {"ct": [os.path.join(temp_workspace, "sub-38/anat/*.nii.gz")]} 

806 

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) 

815 

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 

820 

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

824 

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

831 

832 mock_glob.side_effect = glob_side_effect 

833 

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 } 

840 

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) 

849 

850 combo = frame.category_widgets["ct"] 

851 assert combo.count() >= 1 

852 

853 

854class TestComboBoxBehavior: 

855 """Test combobox behavior""" 

856 

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 ] 

865 

866 files = {"ct": "sub-40/anat/file2.nii.gz"} 

867 patterns = {"ct": [os.path.join(temp_workspace, "sub-40/anat/*.nii.gz")]} 

868 

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) 

877 

878 combo = frame.category_widgets["ct"] 

879 assert "file2" in combo.currentText() 

880 

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 ] 

888 

889 files = {"ct": "sub-41/anat/nonexistent.nii.gz"} 

890 patterns = {"ct": [os.path.join(temp_workspace, "sub-41/anat/*.nii.gz")]} 

891 

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) 

900 

901 combo = frame.category_widgets["ct"] 

902 assert combo.currentIndex() == 0 

903 

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

908 

909 patterns = {"ct": [os.path.join(temp_workspace, "sub-42/anat/*.nii.gz")]} 

910 

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) 

919 

920 combo = frame.category_widgets["ct"] 

921 assert combo.minimumHeight() >= 28 

922 

923 

924class TestAnimationBehavior: 

925 """Test animation behavior""" 

926 

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) 

937 

938 assert frame.animation.duration() == 300 

939 

940 def test_animation_easing_curve(self, qtbot, mock_context, temp_workspace): 

941 """Test easing curve""" 

942 from PyQt6.QtCore import QEasingCurve 

943 

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) 

952 

953 assert frame.animation.easingCurve().type() == QEasingCurve.Type.InOutCubic 

954 

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) 

965 

966 assert frame.animation.propertyName() == b"maximumHeight" 

967 assert frame.animation.targetObject() == frame.content_frame 

968 

969 

970class TestCategoryLabels: 

971 """Test for category labels""" 

972 

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

977 

978 patterns = {"pet_4d_dynamic": [os.path.join(temp_workspace, "sub-46/pet/*.nii.gz")]} 

979 

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) 

988 

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 

992 

993 

994class TestIntegrationScenarios: 

995 """Test integration""" 

996 

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

1004 

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

1011 

1012 mock_glob.side_effect = glob_side_effect 

1013 mock_exists.side_effect = lambda p: p == pet_json 

1014 

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 } 

1023 

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) 

1032 

1033 assert frame.locked is True 

1034 assert hasattr(frame, 'file_label') 

1035 assert "pet4d_json" in frame.files 

1036 

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 ] 

1048 

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

1055 

1056 mock_glob.side_effect = glob_side_effect 

1057 

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 } 

1063 

1064 save_callback = Mock() 

1065 

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) 

1075 

1076 assert frame.locked is False 

1077 assert "ct" in frame.category_widgets 

1078 assert "pet4d" in frame.category_widgets 

1079 

1080 frame.category_widgets["ct"].setCurrentIndex(1) 

1081 frame.category_widgets["pet4d"].setCurrentIndex(0) 

1082 frame._save_patient() 

1083 

1084 save_callback.assert_called_once() 

1085 assert frame.locked is True 

1086 

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 ] 

1094 

1095 patterns = {"ct": [os.path.join(temp_workspace, "sub-49/anat/*.nii.gz")]} 

1096 

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) 

1105 

1106 frame._toggle_expand(True) 

1107 assert frame.is_expanded is True 

1108 

1109 combo = frame.category_widgets["ct"] 

1110 combo.setCurrentIndex(1) 

1111 

1112 frame._save_patient() 

1113 

1114 assert "scan2" in frame.files["ct"] 

1115 assert frame.locked is True 

1116 

1117class TestMemoryAndCleanup: 

1118 """Tests for memory management and cleanup""" 

1119 

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

1124 

1125 patterns = {"ct": [os.path.join(temp_workspace, "sub-50/anat/*.nii.gz")]} 

1126 

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) 

1135 

1136 initial_widget_count = frame.content_layout.count() 

1137 

1138 # Re-populate 

1139 frame._populate_content() 

1140 

1141 # Count should be similar (old widgets removed) 

1142 new_widget_count = frame.content_layout.count() 

1143 assert new_widget_count > 0 

1144 

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) 

1155 

1156 # Add a custom widget 

1157 from PyQt6.QtWidgets import QWidget 

1158 custom_widget = QWidget() 

1159 frame.content_layout.addWidget(custom_widget) 

1160 

1161 assert custom_widget.parent() is not None 

1162 

1163 # Re-populate 

1164 frame._populate_content() 

1165 

1166 # Custom widget should be scheduled for deletion 

1167 # (deleteLater is called) 

1168 

1169 

1170class TestAccessibility: 

1171 """Tests for accessibility""" 

1172 

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) 

1183 

1184 # Should have PointingHand cursor 

1185 assert frame.toggle_button.cursor().shape() == Qt.CursorShape.PointingHandCursor 

1186 

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) 

1197 

1198 assert frame.toggle_button.isCheckable() 

1199 

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

1204 

1205 patterns = {"ct": [os.path.join(temp_workspace, "sub-54/anat/*.nii.gz")]} 

1206 

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) 

1215 

1216 assert frame.save_btn.minimumHeight() >= 32 

1217 

1218 

1219class TestVisualEffects: 

1220 """Tests for visual effects""" 

1221 

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) 

1232 

1233 # Should have a graphics effect 

1234 assert frame.graphicsEffect() is not None 

1235 

1236 def test_frame_shape(self, qtbot, mock_context, temp_workspace): 

1237 """Test frame shape""" 

1238 from PyQt6.QtWidgets import QFrame 

1239 

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) 

1248 

1249 assert frame.frameShape() == QFrame.Shape.StyledPanel 

1250 

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) 

1261 

1262 assert frame.objectName() == "collapsiblePatientFrame" 

1263 

1264 

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) 

1280 

1281 assert frame.locked == expected_locked 

1282 

1283 

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) 

1298 

1299 frame._toggle_expand(is_expanded) 

1300 

1301 assert frame.toggle_button.arrowType() == arrow_type 

1302 

1303 

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) 

1320 

1321 assert frame.patient_id == patient_id 

1322 assert patient_id in frame.subject_name.text() 

1323 

1324 

1325if __name__ == "__main__": 

1326 pytest.main([__file__, "-v"])