All checks were successful
Python CI / test (push) Successful in 1m24s
Lint / lint (push) Successful in 1m13s
Tests / test (3.11) (push) Successful in 1m37s
Tests / test (3.12) (push) Successful in 1m41s
Tests / test (3.13) (push) Successful in 1m34s
Tests / test (3.14) (push) Successful in 1m12s
877 lines
32 KiB
Python
877 lines
32 KiB
Python
"""
|
|
Tests for FileOperationsMixin
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, MagicMock, patch, call
|
|
from PyQt6.QtWidgets import QMainWindow, QDialog
|
|
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
|
from pyPhotoAlbum.mixins.operations.file_ops import FileOperationsMixin
|
|
from pyPhotoAlbum.project import Project, Page
|
|
from pyPhotoAlbum.page_layout import PageLayout
|
|
from pyPhotoAlbum.models import ImageData
|
|
from pyPhotoAlbum.commands import CommandHistory
|
|
|
|
|
|
class TestFileOpsWindow(FileOperationsMixin, ApplicationStateMixin, QMainWindow):
|
|
"""Test window with file operations mixin"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._gl_widget = Mock()
|
|
self._gl_widget.current_page_index = 0
|
|
self._gl_widget.zoom_level = 1.0
|
|
self._gl_widget.pan_offset = [0, 0]
|
|
self._gl_widget._page_renderers = []
|
|
self._gl_widget.width = Mock(return_value=800)
|
|
self._gl_widget.height = Mock(return_value=600)
|
|
self._gl_widget.export_pdf_async = Mock(return_value=True)
|
|
self._project = Project(name="Test")
|
|
self._project.page_size_mm = (210, 297)
|
|
self._project.working_dpi = 96
|
|
self._project.export_dpi = 300
|
|
self._project.history = CommandHistory()
|
|
self._update_view_called = False
|
|
self._status_message = None
|
|
self._status_timeout = None
|
|
self._info_title = None
|
|
self._info_message = None
|
|
self._warning_title = None
|
|
self._warning_message = None
|
|
self._error_title = None
|
|
self._error_message = None
|
|
|
|
@property
|
|
def gl_widget(self):
|
|
return self._gl_widget
|
|
|
|
@property
|
|
def project(self):
|
|
return self._project
|
|
|
|
@project.setter
|
|
def project(self, value):
|
|
self._project = value
|
|
|
|
def update_view(self):
|
|
self._update_view_called = True
|
|
|
|
def show_status(self, message, timeout=0):
|
|
self._status_message = message
|
|
self._status_timeout = timeout
|
|
|
|
def show_info(self, title, message):
|
|
self._info_title = title
|
|
self._info_message = message
|
|
|
|
def show_warning(self, title, message):
|
|
self._warning_title = title
|
|
self._warning_message = message
|
|
|
|
def show_error(self, title, message):
|
|
self._error_title = title
|
|
self._error_message = message
|
|
|
|
def resolve_asset_path(self, path):
|
|
"""Mock asset path resolution"""
|
|
if path.startswith("assets/") and path.endswith("exists.jpg"):
|
|
return "/fake/path/exists.jpg"
|
|
return None
|
|
|
|
|
|
class TestNewProject:
|
|
"""Test new_project method"""
|
|
|
|
def test_new_project_dialog_cancelled(self, qtbot):
|
|
"""Test returns when user cancels dialog"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
old_project = window.project
|
|
|
|
# Patch QDialog.exec to return rejected
|
|
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
|
|
window.new_project()
|
|
|
|
# Should keep old project
|
|
assert window.project == old_project
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
|
|
def test_new_project_creates_project_default_values(self, mock_set_context, qtbot):
|
|
"""Test creates new project with default dialog values"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
old_project = window.project
|
|
old_project.cleanup = Mock()
|
|
|
|
# Patch QDialog.exec to accept with default values (140x140, 300 DPI)
|
|
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
|
|
window.new_project()
|
|
|
|
# Verify new project was created with default values
|
|
assert window.project != old_project
|
|
assert window.project.name == "New Project" # Default name
|
|
assert window.project.page_size_mm == (140.0, 140.0) # Default size
|
|
assert window.project.working_dpi == 300 # Default working DPI
|
|
assert window.project.export_dpi == 300 # Default export DPI
|
|
assert window._update_view_called
|
|
mock_set_context.assert_called_once()
|
|
old_project.cleanup.assert_called_once()
|
|
|
|
|
|
class TestOpenProject:
|
|
"""Test open_project method"""
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getOpenFileName")
|
|
def test_open_project_dialog_cancelled(self, mock_file_dialog, qtbot):
|
|
"""Test returns when user cancels file dialog"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
# Mock dialog to return empty path
|
|
mock_file_dialog.return_value = ("", "")
|
|
|
|
window.open_project()
|
|
|
|
# Should not create loader
|
|
assert not hasattr(window, "_project_loader")
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.AsyncProjectLoader")
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getOpenFileName")
|
|
def test_open_project_starts_async_loading(self, mock_file_dialog, mock_loading_widget, mock_loader, qtbot):
|
|
"""Test starts async loading when file selected"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
# Mock dialog to return file path
|
|
mock_file_dialog.return_value = ("/path/to/project.ppz", "")
|
|
|
|
# Mock loader
|
|
mock_loader_instance = Mock()
|
|
mock_loader.return_value = mock_loader_instance
|
|
|
|
# Mock loading widget
|
|
mock_loading_instance = Mock()
|
|
mock_loading_widget.return_value = mock_loading_instance
|
|
|
|
window.open_project()
|
|
|
|
# Verify loader was created and started
|
|
mock_loader.assert_called_once_with("/path/to/project.ppz")
|
|
mock_loader_instance.start.assert_called_once()
|
|
mock_loading_instance.show_loading.assert_called_once()
|
|
|
|
|
|
class TestLoadCallbacks:
|
|
"""Test async loading callback methods"""
|
|
|
|
def test_on_load_progress(self, qtbot):
|
|
"""Test progress callback updates loading widget"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
# Create mock loading widget
|
|
window._loading_widget = Mock()
|
|
|
|
window._on_load_progress(5, 10, "Loading page 5...")
|
|
|
|
window._loading_widget.set_progress.assert_called_once_with(5, 10)
|
|
window._loading_widget.set_status.assert_called_once_with("Loading page 5...")
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
|
|
def test_on_load_complete_success(self, mock_set_context, qtbot):
|
|
"""Test successful load callback"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
# Create mock loading widget
|
|
window._loading_widget = Mock()
|
|
window._opening_file_path = "/path/to/project.ppz"
|
|
|
|
# Create mock project
|
|
new_project = Mock()
|
|
new_project.name = "Loaded Project"
|
|
new_project.mark_clean = Mock()
|
|
|
|
# Mock check missing assets
|
|
window._check_missing_assets = Mock(return_value=[])
|
|
|
|
old_project = window.project
|
|
old_project.cleanup = Mock()
|
|
|
|
window._on_load_complete(new_project)
|
|
|
|
# Verify old project was cleaned up
|
|
old_project.cleanup.assert_called_once()
|
|
|
|
# Verify new project was set
|
|
assert window.project == new_project
|
|
assert window.project.file_path == "/path/to/project.ppz"
|
|
new_project.mark_clean.assert_called_once()
|
|
|
|
# Verify UI was updated
|
|
window._loading_widget.hide_loading.assert_called_once()
|
|
assert window._update_view_called
|
|
assert "Project opened" in window._status_message
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
|
|
def test_on_load_complete_with_missing_assets(self, mock_set_context, qtbot):
|
|
"""Test load callback with missing assets"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
window._loading_widget = Mock()
|
|
window._opening_file_path = "/path/to/project.ppz"
|
|
|
|
new_project = Mock()
|
|
new_project.name = "Project with Missing"
|
|
new_project.mark_clean = Mock()
|
|
|
|
# Mock missing assets
|
|
window._check_missing_assets = Mock(return_value=["/missing/image1.jpg", "/missing/image2.jpg"])
|
|
window._show_missing_assets_warning = Mock()
|
|
|
|
window._on_load_complete(new_project)
|
|
|
|
# Verify warning was shown
|
|
window._show_missing_assets_warning.assert_called_once_with(["/missing/image1.jpg", "/missing/image2.jpg"])
|
|
assert "2 missing images" in window._status_message
|
|
|
|
def test_on_load_failed(self, qtbot):
|
|
"""Test load failure callback"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
window._loading_widget = Mock()
|
|
|
|
window._on_load_failed("File corrupted")
|
|
|
|
# Verify error was shown
|
|
window._loading_widget.hide_loading.assert_called_once()
|
|
assert "Failed to open project" in window._error_message
|
|
assert "File corrupted" in window._error_message
|
|
|
|
|
|
class TestSaveProject:
|
|
"""Test save_project method"""
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip_async")
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
|
|
def test_save_project_with_existing_path(self, mock_loading_widget, mock_save_async, qtbot):
|
|
"""Test saves to existing file path"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
window.project.file_path = "/path/to/existing.ppz"
|
|
|
|
# Mock loading widget
|
|
mock_loading_instance = Mock()
|
|
mock_loading_widget.return_value = mock_loading_instance
|
|
|
|
# Capture status messages
|
|
status_messages = []
|
|
original_show_status = window.show_status
|
|
def capture_status(msg, timeout=0):
|
|
status_messages.append(msg)
|
|
original_show_status(msg, timeout)
|
|
window.show_status = capture_status
|
|
|
|
# Mock save_to_zip_async to call on_complete immediately with success
|
|
def mock_save_call(project, path, on_complete=None, on_progress=None):
|
|
if on_complete:
|
|
on_complete(True, None)
|
|
return Mock() # Return mock thread
|
|
|
|
mock_save_async.side_effect = mock_save_call
|
|
|
|
window.save_project()
|
|
|
|
# Verify save was called with existing path
|
|
assert mock_save_async.call_count == 1
|
|
call_args = mock_save_async.call_args
|
|
assert call_args[0][0] == window.project
|
|
assert call_args[0][1] == "/path/to/existing.ppz"
|
|
# Check that "Project saved" was shown at some point
|
|
assert any("Project saved" in msg for msg in status_messages)
|
|
assert not window.project.is_dirty() # is_dirty() is a method
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip_async")
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
|
def test_save_project_prompts_for_path(self, mock_file_dialog, mock_loading_widget, mock_save_async, qtbot):
|
|
"""Test prompts for path when none exists"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
# No existing file path
|
|
window.project.file_path = None
|
|
|
|
# Mock dialog to return path
|
|
mock_file_dialog.return_value = ("/path/to/new.ppz", "")
|
|
|
|
# Mock loading widget
|
|
mock_loading_instance = Mock()
|
|
mock_loading_widget.return_value = mock_loading_instance
|
|
|
|
# Mock save_to_zip_async to call on_complete immediately with success
|
|
def mock_save_call(project, path, on_complete=None, on_progress=None):
|
|
if on_complete:
|
|
on_complete(True, None)
|
|
return Mock() # Return mock thread
|
|
|
|
mock_save_async.side_effect = mock_save_call
|
|
|
|
window.save_project()
|
|
|
|
# Verify dialog was shown and save was called
|
|
mock_file_dialog.assert_called_once()
|
|
assert mock_save_async.call_count == 1
|
|
call_args = mock_save_async.call_args
|
|
assert call_args[0][0] == window.project
|
|
assert call_args[0][1] == "/path/to/new.ppz"
|
|
assert window.project.file_path == "/path/to/new.ppz"
|
|
assert not window.project.is_dirty()
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip_async")
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
|
def test_save_project_user_cancels(self, mock_file_dialog, mock_save_async, qtbot):
|
|
"""Test returns when user cancels file dialog"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
window.project.file_path = None
|
|
mock_file_dialog.return_value = ("", "")
|
|
|
|
window.save_project()
|
|
|
|
# Should not call save
|
|
mock_save_async.assert_not_called()
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip_async")
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
|
|
def test_save_project_handles_error(self, mock_loading_widget, mock_save_async, qtbot):
|
|
"""Test handles save errors"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
window.project.file_path = "/path/to/project.ppz"
|
|
|
|
# Mock loading widget
|
|
mock_loading_instance = Mock()
|
|
mock_loading_widget.return_value = mock_loading_instance
|
|
|
|
# Mock save_to_zip_async to call on_complete with error
|
|
def mock_save_call(project, path, on_complete=None, on_progress=None):
|
|
if on_complete:
|
|
on_complete(False, "Disk full")
|
|
return Mock() # Return mock thread
|
|
|
|
mock_save_async.side_effect = mock_save_call
|
|
|
|
window.save_project()
|
|
|
|
# Verify error was shown
|
|
assert "Failed to save project" in window._error_message
|
|
assert "Disk full" in window._error_message
|
|
|
|
|
|
class TestHealAssets:
|
|
"""Test heal_assets method"""
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
|
|
def test_heal_assets_opens_dialog(self, mock_dialog, qtbot):
|
|
"""Test opens asset heal dialog"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
mock_dialog_instance = Mock()
|
|
mock_dialog.return_value = mock_dialog_instance
|
|
|
|
window.heal_assets()
|
|
|
|
# Verify dialog was created and executed
|
|
mock_dialog.assert_called_once_with(window.project, window)
|
|
mock_dialog_instance.exec.assert_called_once()
|
|
assert window._update_view_called
|
|
|
|
|
|
class TestCheckMissingAssets:
|
|
"""Test _check_missing_assets method"""
|
|
|
|
def test_check_missing_assets_absolute_path(self, qtbot):
|
|
"""Test detects absolute path as missing"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
# Create page with absolute path
|
|
layout = PageLayout(width=210, height=297)
|
|
layout.elements = [ImageData(image_path="/absolute/path/image.jpg", x=0, y=0, width=100, height=100)]
|
|
page = Page(layout=layout, page_number=1)
|
|
window.project.pages = [page]
|
|
|
|
missing = window._check_missing_assets()
|
|
|
|
assert len(missing) == 1
|
|
assert "/absolute/path/image.jpg" in missing
|
|
|
|
def test_check_missing_assets_non_assets_path(self, qtbot):
|
|
"""Test detects non-assets path as missing"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=210, height=297)
|
|
layout.elements = [ImageData(image_path="relative/path/image.jpg", x=0, y=0, width=100, height=100)]
|
|
page = Page(layout=layout, page_number=1)
|
|
window.project.pages = [page]
|
|
|
|
missing = window._check_missing_assets()
|
|
|
|
assert len(missing) == 1
|
|
assert "relative/path/image.jpg" in missing
|
|
|
|
def test_check_missing_assets_not_found(self, qtbot):
|
|
"""Test detects assets that don't exist"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=210, height=297)
|
|
layout.elements = [ImageData(image_path="assets/missing.jpg", x=0, y=0, width=100, height=100)]
|
|
page = Page(layout=layout, page_number=1)
|
|
window.project.pages = [page]
|
|
|
|
missing = window._check_missing_assets()
|
|
|
|
assert len(missing) == 1
|
|
assert "assets/missing.jpg" in missing
|
|
|
|
def test_check_missing_assets_found(self, qtbot):
|
|
"""Test ignores assets that exist"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=210, height=297)
|
|
layout.elements = [ImageData(image_path="assets/exists.jpg", x=0, y=0, width=100, height=100)]
|
|
page = Page(layout=layout, page_number=1)
|
|
window.project.pages = [page]
|
|
|
|
missing = window._check_missing_assets()
|
|
|
|
assert len(missing) == 0
|
|
|
|
def test_check_missing_assets_removes_duplicates(self, qtbot):
|
|
"""Test removes duplicate missing paths"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout1 = PageLayout(width=210, height=297)
|
|
layout1.elements = [ImageData(image_path="/missing/image.jpg", x=0, y=0, width=100, height=100)]
|
|
page1 = Page(layout=layout1, page_number=1)
|
|
|
|
layout2 = PageLayout(width=210, height=297)
|
|
layout2.elements = [ImageData(image_path="/missing/image.jpg", x=0, y=0, width=100, height=100)]
|
|
page2 = Page(layout=layout2, page_number=2)
|
|
|
|
window.project.pages = [page1, page2]
|
|
|
|
missing = window._check_missing_assets()
|
|
|
|
assert len(missing) == 1
|
|
assert "/missing/image.jpg" in missing
|
|
|
|
|
|
class TestShowMissingAssetsWarning:
|
|
"""Test _show_missing_assets_warning method"""
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
|
|
def test_show_warning_few_assets(self, mock_heal_dialog, qtbot):
|
|
"""Test shows all assets when count is small"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
# Patch QMessageBox.exec to return Ok (not Open)
|
|
from PyQt6.QtWidgets import QMessageBox
|
|
|
|
with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Ok):
|
|
missing = ["/path1.jpg", "/path2.jpg", "/path3.jpg"]
|
|
window._show_missing_assets_warning(missing)
|
|
|
|
# Heal dialog should not be opened for Ok
|
|
mock_heal_dialog.assert_not_called()
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
|
|
def test_show_warning_many_assets(self, mock_heal_dialog, qtbot):
|
|
"""Test truncates list when many assets missing"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
from PyQt6.QtWidgets import QMessageBox
|
|
|
|
with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Ok):
|
|
missing = [f"/path{i}.jpg" for i in range(10)]
|
|
window._show_missing_assets_warning(missing)
|
|
|
|
# Verify it completed without error
|
|
mock_heal_dialog.assert_not_called()
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
|
|
def test_show_warning_opens_heal_dialog(self, mock_heal_dialog, qtbot):
|
|
"""Test opens heal dialog when user clicks Open"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
from PyQt6.QtWidgets import QMessageBox
|
|
|
|
mock_heal_instance = Mock()
|
|
mock_heal_dialog.return_value = mock_heal_instance
|
|
|
|
# Patch QMessageBox.exec to return Open
|
|
with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Open):
|
|
missing = ["/path1.jpg"]
|
|
window._show_missing_assets_warning(missing)
|
|
|
|
# Verify heal dialog was opened
|
|
mock_heal_dialog.assert_called_once()
|
|
mock_heal_instance.exec.assert_called_once()
|
|
|
|
|
|
class TestProjectSettings:
|
|
"""Test project_settings method"""
|
|
|
|
def test_project_settings_cancelled(self, qtbot):
|
|
"""Test returns when user cancels"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
old_size = window.project.page_size_mm
|
|
|
|
# Patch QDialog.exec to return rejected
|
|
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
|
|
window.project_settings()
|
|
|
|
# Size should not change
|
|
assert window.project.page_size_mm == old_size
|
|
|
|
def test_project_settings_updates_values_default(self, qtbot):
|
|
"""Test updates project settings with dialog defaults"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
# Set initial values
|
|
window.project.page_size_mm = (210, 297)
|
|
window.project.working_dpi = 96
|
|
window.project.export_dpi = 300
|
|
|
|
# Patch QDialog.exec to accept (will use current project values as defaults)
|
|
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
|
|
window.project_settings()
|
|
|
|
# Values should remain the same (since dialog uses current values as defaults)
|
|
assert window.project.page_size_mm == (210, 297)
|
|
assert window.project.working_dpi == 96
|
|
assert window.project.export_dpi == 300
|
|
assert window._update_view_called
|
|
|
|
|
|
class TestApplyPageSizeToProject:
|
|
"""Test _apply_page_size_to_project method"""
|
|
|
|
def test_apply_page_size_skips_manual_pages(self, qtbot):
|
|
"""Test skips manually sized pages"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=210, height=297)
|
|
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
|
page = Page(layout=layout, page_number=1)
|
|
page.manually_sized = True
|
|
window.project.pages = [page]
|
|
|
|
window._apply_page_size_to_project((210, 297), (200, 200), "proportional")
|
|
|
|
# Page size should not change
|
|
assert page.layout.size == (210, 297)
|
|
|
|
def test_apply_page_size_proportional_scaling(self, qtbot):
|
|
"""Test proportional scaling mode"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=100, height=100)
|
|
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
|
page = Page(layout=layout, page_number=1)
|
|
page.manually_sized = False
|
|
window.project.pages = [page]
|
|
|
|
# Double the size
|
|
window._apply_page_size_to_project((100, 100), (200, 200), "proportional")
|
|
|
|
# Page should be resized
|
|
assert page.layout.size == (200, 200)
|
|
# Elements should be scaled uniformly
|
|
assert layout.elements[0].position == (20.0, 20.0)
|
|
assert layout.elements[0].size == (100.0, 100.0)
|
|
|
|
def test_apply_page_size_stretch_scaling(self, qtbot):
|
|
"""Test stretch scaling mode"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=100, height=100)
|
|
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
|
page = Page(layout=layout, page_number=1)
|
|
page.manually_sized = False
|
|
window.project.pages = [page]
|
|
|
|
# Stretch to different aspect ratio
|
|
window._apply_page_size_to_project((100, 100), (200, 150), "stretch")
|
|
|
|
# Page should be resized
|
|
assert page.layout.size == (200, 150)
|
|
# Elements should be scaled independently
|
|
assert layout.elements[0].position == (20.0, 15.0) # x*2, y*1.5
|
|
assert layout.elements[0].size == (100.0, 75.0) # w*2, h*1.5
|
|
|
|
def test_apply_page_size_reposition_mode(self, qtbot):
|
|
"""Test reposition mode"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=100, height=100)
|
|
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
|
page = Page(layout=layout, page_number=1)
|
|
page.manually_sized = False
|
|
window.project.pages = [page]
|
|
|
|
# Increase size
|
|
window._apply_page_size_to_project((100, 100), (150, 150), "reposition")
|
|
|
|
# Page should be resized
|
|
assert page.layout.size == (150, 150)
|
|
# Elements should be offset to center
|
|
assert layout.elements[0].position == (35.0, 35.0) # +25, +25
|
|
assert layout.elements[0].size == (50, 50) # unchanged
|
|
|
|
def test_apply_page_size_none_mode(self, qtbot):
|
|
"""Test none mode (no element changes)"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=100, height=100)
|
|
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
|
page = Page(layout=layout, page_number=1)
|
|
page.manually_sized = False
|
|
window.project.pages = [page]
|
|
|
|
window._apply_page_size_to_project((100, 100), (200, 200), "none")
|
|
|
|
# Page should be resized
|
|
assert page.layout.size == (200, 200)
|
|
# Elements should not change
|
|
assert layout.elements[0].position == (10, 10)
|
|
assert layout.elements[0].size == (50, 50)
|
|
|
|
def test_apply_page_size_double_spread(self, qtbot):
|
|
"""Test handles double spread pages correctly"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=200, height=100)
|
|
page = Page(layout=layout, page_number=1)
|
|
page.manually_sized = False
|
|
page.is_double_spread = True
|
|
window.project.pages = [page]
|
|
|
|
window._apply_page_size_to_project((100, 100), (150, 150), "none")
|
|
|
|
# Double spread should be 2x width
|
|
assert page.layout.size == (300, 150)
|
|
|
|
|
|
class TestScalePageElements:
|
|
"""Test _scale_page_elements method"""
|
|
|
|
def test_scale_elements_uniform(self, qtbot):
|
|
"""Test uniform scaling"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=100, height=100)
|
|
layout.elements = [
|
|
ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60),
|
|
ImageData(image_path="test2.jpg", x=5, y=10, width=30, height=40),
|
|
]
|
|
page = Page(layout=layout, page_number=1)
|
|
|
|
window._scale_page_elements(page, 2.0, 2.0)
|
|
|
|
# Check first element
|
|
assert layout.elements[0].position == (20.0, 40.0)
|
|
assert layout.elements[0].size == (100.0, 120.0)
|
|
|
|
# Check second element
|
|
assert layout.elements[1].position == (10.0, 20.0)
|
|
assert layout.elements[1].size == (60.0, 80.0)
|
|
|
|
def test_scale_elements_non_uniform(self, qtbot):
|
|
"""Test non-uniform scaling"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=100, height=100)
|
|
layout.elements = [ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60)]
|
|
page = Page(layout=layout, page_number=1)
|
|
|
|
window._scale_page_elements(page, 2.0, 1.5)
|
|
|
|
assert layout.elements[0].position == (20.0, 30.0)
|
|
assert layout.elements[0].size == (100.0, 90.0)
|
|
|
|
|
|
class TestRepositionPageElements:
|
|
"""Test _reposition_page_elements method"""
|
|
|
|
def test_reposition_elements_larger_page(self, qtbot):
|
|
"""Test repositioning on larger page"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=100, height=100)
|
|
layout.elements = [
|
|
ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60),
|
|
ImageData(image_path="test2.jpg", x=5, y=10, width=30, height=40),
|
|
]
|
|
page = Page(layout=layout, page_number=1)
|
|
|
|
window._reposition_page_elements(page, (100, 100), (150, 130))
|
|
|
|
# Offset should be (25, 15)
|
|
assert layout.elements[0].position == (35.0, 35.0)
|
|
assert layout.elements[1].position == (30.0, 25.0)
|
|
|
|
def test_reposition_elements_smaller_page(self, qtbot):
|
|
"""Test repositioning on smaller page"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=100, height=100)
|
|
layout.elements = [ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60)]
|
|
page = Page(layout=layout, page_number=1)
|
|
|
|
window._reposition_page_elements(page, (100, 100), (80, 90))
|
|
|
|
# Offset should be (-10, -5)
|
|
assert layout.elements[0].position == (0.0, 15.0)
|
|
|
|
|
|
class TestExportPdf:
|
|
"""Test export_pdf method"""
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
|
def test_export_pdf_no_pages(self, mock_file_dialog, qtbot):
|
|
"""Test returns early when no pages"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
window.project.pages = []
|
|
|
|
window.export_pdf()
|
|
|
|
# Should not show file dialog
|
|
mock_file_dialog.assert_not_called()
|
|
assert "No pages to export" in window._status_message
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
|
def test_export_pdf_user_cancels(self, mock_file_dialog, qtbot):
|
|
"""Test returns when user cancels"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=210, height=297)
|
|
page = Page(layout=layout, page_number=1)
|
|
window.project.pages = [page]
|
|
|
|
mock_file_dialog.return_value = ("", "")
|
|
|
|
window.export_pdf()
|
|
|
|
# Should not call export
|
|
window.gl_widget.export_pdf_async.assert_not_called()
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
|
def test_export_pdf_success(self, mock_file_dialog, qtbot):
|
|
"""Test successful PDF export"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=210, height=297)
|
|
page = Page(layout=layout, page_number=1)
|
|
window.project.pages = [page]
|
|
|
|
mock_file_dialog.return_value = ("/path/to/output.pdf", "")
|
|
window.gl_widget.export_pdf_async.return_value = True
|
|
|
|
window.export_pdf()
|
|
|
|
# Verify export was called
|
|
window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=300)
|
|
assert "PDF export started" in window._status_message
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
|
def test_export_pdf_adds_extension(self, mock_file_dialog, qtbot):
|
|
"""Test adds .pdf extension if missing"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=210, height=297)
|
|
page = Page(layout=layout, page_number=1)
|
|
window.project.pages = [page]
|
|
|
|
mock_file_dialog.return_value = ("/path/to/output", "")
|
|
window.gl_widget.export_pdf_async.return_value = True
|
|
|
|
window.export_pdf()
|
|
|
|
# Verify .pdf was added
|
|
window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=300)
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
|
def test_export_pdf_failed_to_start(self, mock_file_dialog, qtbot):
|
|
"""Test handles export failure"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
layout = PageLayout(width=210, height=297)
|
|
page = Page(layout=layout, page_number=1)
|
|
window.project.pages = [page]
|
|
|
|
mock_file_dialog.return_value = ("/path/to/output.pdf", "")
|
|
window.gl_widget.export_pdf_async.return_value = False
|
|
|
|
window.export_pdf()
|
|
|
|
assert "PDF export failed to start" in window._status_message
|
|
|
|
|
|
class TestShowAbout:
|
|
"""Test show_about method"""
|
|
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.format_version_info")
|
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QDialog")
|
|
def test_show_about_displays_dialog(self, mock_dialog_class, mock_format_version, qtbot):
|
|
"""Test shows about dialog with version info"""
|
|
window = TestFileOpsWindow()
|
|
qtbot.addWidget(window)
|
|
|
|
mock_dialog = Mock()
|
|
mock_dialog_class.return_value = mock_dialog
|
|
mock_format_version.return_value = "Version 1.0.0\nData Format: v2"
|
|
|
|
window.show_about()
|
|
|
|
# Verify dialog was created and executed
|
|
mock_dialog_class.assert_called_once()
|
|
mock_dialog.exec.assert_called_once()
|
|
mock_format_version.assert_called_once()
|