pyPhotoAlbum/tests/test_file_ops_mixin.py
Duncan Tourolle 254a95d83c
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
fix tests
2025-12-13 17:33:12 +01:00

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