""" Tests for MergeOperationsMixin """ import pytest import tempfile import os from unittest.mock import Mock, MagicMock, patch, call from PyQt6.QtWidgets import QMainWindow, QMessageBox, QFileDialog from pyPhotoAlbum.mixins.base import ApplicationStateMixin from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.models import TextBoxData class MergeOpsWindow(MergeOperationsMixin, ApplicationStateMixin, QMainWindow): """Test window with merge operations mixin""" def __init__(self): super().__init__() self._project = Project(name="Test Project") self._gl_widget = Mock() self._gl_widget.current_page_index = 0 self._autosave_timer = Mock() self._status_bar = Mock() # Track calls self._save_project_called = False self._update_view_called = False @property def gl_widget(self): return self._gl_widget @property def status_bar(self): return self._status_bar def save_project(self): self._save_project_called = True def update_view(self): self._update_view_called = True class TestMergeProjects: """Test merge_projects method""" def test_dirty_project_user_cancels(self, qtbot): """Test user cancels when prompted about unsaved changes""" window = MergeOpsWindow() qtbot.addWidget(window) # Make project dirty window.project.mark_dirty() with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Cancel): window.merge_projects() # Should return early without attempting merge assert not window._save_project_called def test_dirty_project_user_saves(self, qtbot): """Test user chooses to save before merging""" window = MergeOpsWindow() qtbot.addWidget(window) # Make project dirty window.project.mark_dirty() # Mock QMessageBox.question to return Yes, then mock file dialog to cancel with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes): with patch.object(QFileDialog, 'getOpenFileName', return_value=('', '')): window.merge_projects() # Should have called save_project assert window._save_project_called def test_dirty_project_user_skips_save(self, qtbot): """Test user chooses not to save before merging""" window = MergeOpsWindow() qtbot.addWidget(window) # Make project dirty window.project.mark_dirty() # Mock QMessageBox.question to return No, then mock file dialog to cancel with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.No): with patch.object(QFileDialog, 'getOpenFileName', return_value=('', '')): window.merge_projects() # Should NOT have called save_project assert not window._save_project_called def test_user_cancels_file_selection(self, qtbot): """Test user cancels the file selection dialog""" window = MergeOpsWindow() qtbot.addWidget(window) # Project is clean (not dirty) assert not window.project.is_dirty() with patch.object(QFileDialog, 'getOpenFileName', return_value=('', '')): window.merge_projects() # Should return early assert not window._update_view_called def test_autosave_timer_stopped_and_restarted(self, qtbot, tmp_path): """Test autosave timer is stopped during merge and restarted after""" window = MergeOpsWindow() qtbot.addWidget(window) # Create a temporary project file test_project = Project("Other Project") test_file = tmp_path / "test.ppz" from pyPhotoAlbum.project_serializer import save_to_zip save_to_zip(test_project, str(test_file)) # Track timer calls timer_stop_called = False timer_start_called = False def mock_stop(): nonlocal timer_stop_called timer_stop_called = True def mock_start(): nonlocal timer_start_called timer_start_called = True window._autosave_timer.stop = mock_stop window._autosave_timer.start = mock_start # Mock file dialog to return our test file with patch.object(QFileDialog, 'getOpenFileName', return_value=(str(test_file), '')): # Mock QMessageBox for the concatenation question with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes): # Mock the information box with patch.object(QMessageBox, 'information'): window.merge_projects() # Verify timer was stopped and started assert timer_stop_called assert timer_start_called def test_merge_same_project_no_conflicts(self, qtbot, tmp_path): """Test merging same project with no conflicts""" window = MergeOpsWindow() qtbot.addWidget(window) # Create base project base_project = Project("Base Project") base_project._project_id = "same-id-123" page = Page(page_number=1) text = TextBoxData(text_content="Original Text", x=10, y=10, width=100, height=50) page.layout.add_element(text) base_project.add_page(page) # Set window's project to have same ID window._project = Project("Base Project") window._project._project_id = "same-id-123" page1 = Page(page_number=1) window._project.add_page(page1) # Create temporary project file test_file = tmp_path / "test.ppz" from pyPhotoAlbum.project_serializer import save_to_zip save_to_zip(base_project, str(test_file)) # Mock file dialog with patch.object(QFileDialog, 'getOpenFileName', return_value=(str(test_file), '')): # Mock QMessageBox to accept auto-merge (no conflicts) with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes): # Mock the completion information box with patch.object(QMessageBox, 'information') as mock_info: window.merge_projects() # Should show completion message assert mock_info.called def test_merge_different_projects_concatenation(self, qtbot, tmp_path): """Test concatenating different projects""" window = MergeOpsWindow() qtbot.addWidget(window) # Create another project with different ID other_project = Project("Other Project") page = Page(page_number=1) other_project.add_page(page) # Create temporary project file test_file = tmp_path / "test.ppz" from pyPhotoAlbum.project_serializer import save_to_zip save_to_zip(other_project, str(test_file)) # Mock file dialog with patch.object(QFileDialog, 'getOpenFileName', return_value=(str(test_file), '')): # Mock QMessageBox to accept concatenation with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes): # Mock the completion information box with patch.object(QMessageBox, 'information') as mock_info: window.merge_projects() # Should show completion message assert mock_info.called def test_merge_error_handling(self, qtbot): """Test error handling during merge""" window = MergeOpsWindow() qtbot.addWidget(window) # Mock file dialog to return invalid file with patch.object(QFileDialog, 'getOpenFileName', return_value=('/invalid/path.ppz', '')): # Mock QMessageBox.critical to capture error with patch.object(QMessageBox, 'critical') as mock_critical: window.merge_projects() # Should show error message assert mock_critical.called args = mock_critical.call_args[0] assert "Merge Error" in args[1] def test_merge_timer_restarted_after_error(self, qtbot): """Test autosave timer is restarted even after error""" window = MergeOpsWindow() qtbot.addWidget(window) timer_start_called = False def mock_start(): nonlocal timer_start_called timer_start_called = True window._autosave_timer.start = mock_start # Mock file dialog to return invalid file (will cause error) with patch.object(QFileDialog, 'getOpenFileName', return_value=('/invalid/path.ppz', '')): with patch.object(QMessageBox, 'critical'): window.merge_projects() # Timer should be restarted even after error assert timer_start_called class TestPerformMergeWithConflicts: """Test _perform_merge_with_conflicts method""" def test_no_conflicts_user_accepts(self, qtbot): """Test auto-merge when no conflicts and user accepts""" window = MergeOpsWindow() qtbot.addWidget(window) # Create mock data our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'} their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'} # Mock MergeManager to return no conflicts with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager: mock_manager = MockMergeManager.return_value mock_manager.detect_conflicts.return_value = [] mock_manager.apply_resolutions.return_value = {'pages': [], 'name': 'Merged'} # Mock QMessageBox to accept auto-merge with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes): with patch.object(QMessageBox, 'information') as mock_info: window._perform_merge_with_conflicts(our_data, their_data) # Should have called apply_resolutions mock_manager.apply_resolutions.assert_called_once() # Should show completion message assert mock_info.called def test_no_conflicts_user_rejects(self, qtbot): """Test user rejects auto-merge when no conflicts""" window = MergeOpsWindow() qtbot.addWidget(window) our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'} their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'} # Mock MergeManager to return no conflicts with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager: mock_manager = MockMergeManager.return_value mock_manager.detect_conflicts.return_value = [] # Mock QMessageBox to reject auto-merge with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.No): window._perform_merge_with_conflicts(our_data, their_data) # Should NOT have called apply_resolutions mock_manager.apply_resolutions.assert_not_called() def test_with_conflicts_user_accepts_dialog(self, qtbot): """Test merge with conflicts when user accepts dialog""" window = MergeOpsWindow() qtbot.addWidget(window) our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'} their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'} # Mock MergeManager to return conflicts with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager: mock_manager = MockMergeManager.return_value mock_manager.detect_conflicts.return_value = [Mock()] # One conflict # Mock MergeDialog with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeDialog') as MockDialog: mock_dialog = MockDialog.return_value mock_dialog.exec.return_value = QMessageBox.DialogCode.Accepted mock_dialog.get_merged_project_data.return_value = {'pages': [], 'name': 'Merged'} with patch.object(QMessageBox, 'information'): window._perform_merge_with_conflicts(our_data, their_data) # Should have shown dialog MockDialog.assert_called_once() # Should have gotten merged data mock_dialog.get_merged_project_data.assert_called_once() def test_with_conflicts_user_cancels_dialog(self, qtbot): """Test merge with conflicts when user cancels dialog""" window = MergeOpsWindow() qtbot.addWidget(window) our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'} their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'} # Mock MergeManager to return conflicts with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager: mock_manager = MockMergeManager.return_value mock_manager.detect_conflicts.return_value = [Mock()] # One conflict # Mock MergeDialog with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeDialog') as MockDialog: mock_dialog = MockDialog.return_value mock_dialog.exec.return_value = QMessageBox.DialogCode.Rejected with patch.object(QMessageBox, 'information') as mock_info: window._perform_merge_with_conflicts(our_data, their_data) # Should have shown cancellation message assert mock_info.called args = mock_info.call_args[0] assert "Cancelled" in args[1] # Should NOT have gotten merged data mock_dialog.get_merged_project_data.assert_not_called() class TestPerformConcatenation: """Test _perform_concatenation method""" def test_user_accepts_concatenation(self, qtbot): """Test concatenation when user accepts""" window = MergeOpsWindow() qtbot.addWidget(window) our_data = {'pages': [], 'name': 'Project A', 'project_id': 'id-a'} their_data = {'pages': [], 'name': 'Project B', 'project_id': 'id-b'} # Mock concatenate_projects with patch('pyPhotoAlbum.mixins.operations.merge_ops.concatenate_projects') as mock_concat: mock_concat.return_value = {'pages': [], 'name': 'Combined'} # Mock QMessageBox to accept with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes): with patch.object(QMessageBox, 'information') as mock_info: window._perform_concatenation(our_data, their_data) # Should have called concatenate_projects mock_concat.assert_called_once_with(our_data, their_data) # Should show completion message assert mock_info.called args = mock_info.call_args[0] assert "Concatenation Complete" in args[1] def test_user_rejects_concatenation(self, qtbot): """Test concatenation when user rejects""" window = MergeOpsWindow() qtbot.addWidget(window) our_data = {'pages': [], 'name': 'Project A', 'project_id': 'id-a'} their_data = {'pages': [], 'name': 'Project B', 'project_id': 'id-b'} # Mock concatenate_projects with patch('pyPhotoAlbum.mixins.operations.merge_ops.concatenate_projects') as mock_concat: # Mock QMessageBox to reject with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.No): window._perform_concatenation(our_data, their_data) # Should NOT have called concatenate_projects mock_concat.assert_not_called() def test_concatenation_shows_project_names(self, qtbot): """Test concatenation dialog shows both project names""" window = MergeOpsWindow() qtbot.addWidget(window) our_data = {'pages': [], 'name': 'My Project', 'project_id': 'id-a'} their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'id-b'} # Mock QMessageBox.question to capture the message with patch.object(QMessageBox, 'question') as mock_question: mock_question.return_value = QMessageBox.StandardButton.No window._perform_concatenation(our_data, their_data) # Check that the dialog message contains both project names args = mock_question.call_args[0] message = args[2] # Third argument is the message assert 'My Project' in message assert 'Their Project' in message class TestApplyMergedData: """Test _apply_merged_data method""" def test_apply_merged_data_updates_project(self, qtbot): """Test applying merged data creates new project""" window = MergeOpsWindow() qtbot.addWidget(window) # Create merged data merged_data = { 'pages': [], 'name': 'Merged Project', 'project_id': 'merged-123' } # Mock set_asset_resolution_context with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'): window._apply_merged_data(merged_data) # Should have updated project assert window.project.name == 'Merged Project' # Project should be marked dirty assert window.project.is_dirty() def test_apply_merged_data_updates_gl_widget(self, qtbot): """Test applying merged data updates GL widget""" window = MergeOpsWindow() qtbot.addWidget(window) merged_data = { 'pages': [], 'name': 'Merged Project', 'project_id': 'merged-123' } # Mock set_asset_resolution_context with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'): window._apply_merged_data(merged_data) # Should have updated gl_widget window.gl_widget.set_project.assert_called_once() window.gl_widget.update.assert_called_once() def test_apply_merged_data_shows_status(self, qtbot): """Test applying merged data shows status message""" window = MergeOpsWindow() qtbot.addWidget(window) merged_data = { 'pages': [], 'name': 'Merged Project', 'project_id': 'merged-123' } # Mock set_asset_resolution_context with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'): window._apply_merged_data(merged_data) # Should have shown status message window.status_bar.showMessage.assert_called_once() args = window.status_bar.showMessage.call_args[0] assert "Merge completed successfully" in args[0] def test_apply_merged_data_sets_asset_context(self, qtbot, tmp_path): """Test applying merged data sets asset resolution context""" window = MergeOpsWindow() qtbot.addWidget(window) # Create project with folder_path test_path = tmp_path / "test" test_path.mkdir() window._project.folder_path = str(test_path) merged_path = tmp_path / "merged" merged_path.mkdir() merged_data = { 'pages': [], 'name': 'Merged Project', 'project_id': 'merged-123', 'folder_path': str(merged_path) } # Mock set_asset_resolution_context with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context') as mock_set: window._apply_merged_data(merged_data) # Should have called set_asset_resolution_context with new project's folder_path mock_set.assert_called_once() def test_apply_merged_data_without_gl_widget(self, qtbot): """Test applying merged data when gl_widget doesn't exist""" window = MergeOpsWindow() qtbot.addWidget(window) # Remove gl_widget delattr(window, '_gl_widget') merged_data = { 'pages': [], 'name': 'Merged Project', 'project_id': 'merged-123' } # Should not raise error with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'): window._apply_merged_data(merged_data) # Project should still be updated assert window.project.name == 'Merged Project' def test_apply_merged_data_without_status_bar(self, qtbot): """Test applying merged data when status_bar doesn't exist""" window = MergeOpsWindow() qtbot.addWidget(window) # Remove status_bar delattr(window, '_status_bar') merged_data = { 'pages': [], 'name': 'Merged Project', 'project_id': 'merged-123' } # Should not raise error with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'): window._apply_merged_data(merged_data) # Project should still be updated assert window.project.name == 'Merged Project'