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
548 lines
21 KiB
Python
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'
|