pyPhotoAlbum/tests/test_merge_ops_mixin.py
Duncan Tourolle b18a780a33
All checks were successful
Python CI / test (push) Successful in 1m28s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m35s
Tests / test (3.14) (push) Successful in 1m15s
increase test coverage
2025-11-28 19:54:41 +01:00

548 lines
21 KiB
Python

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