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