pyPhotoAlbum/tests/test_asset_drop_mixin.py
Duncan Tourolle f6ed11b0bc
All checks were successful
Python CI / test (push) Successful in 1m20s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m27s
Tests / test (3.12) (push) Successful in 2m25s
Tests / test (3.13) (push) Successful in 2m52s
Tests / test (3.14) (push) Successful in 1m9s
black formatting
2025-11-27 23:07:16 +01:00

603 lines
21 KiB
Python
Executable File

"""
Tests for AssetDropMixin
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtCore import QMimeData, QUrl, QPoint
from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData
# Create test widget combining necessary mixins
class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining asset drop, asset path, page navigation, and viewport mixins"""
def _get_element_at(self, x, y):
"""Mock implementation for testing"""
# Will be overridden in tests that need it
return None
def _get_project_folder(self):
"""Override to access project via window mock"""
main_window = self.window()
if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, "folder_path", None)
return None
class TestAssetDropInitialization:
"""Test AssetDropMixin initialization"""
def test_widget_accepts_drops(self, qtbot):
"""Test that widget is configured to accept drops"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
# Should accept drops (set in GLWidget.__init__)
# This is a property of the widget, not the mixin
assert hasattr(widget, "acceptDrops")
class TestDragEnterEvent:
"""Test dragEnterEvent method"""
def test_accepts_image_urls(self, qtbot):
"""Test accepts drag events with image file URLs"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
# Create mime data with image file
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
# Create drag enter event
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
widget.dragEnterEvent(event)
# Should accept the event
assert event.acceptProposedAction.called
def test_accepts_png_files(self, qtbot):
"""Test accepts PNG files"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.png")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
widget.dragEnterEvent(event)
assert event.acceptProposedAction.called
def test_rejects_non_image_files(self, qtbot):
"""Test rejects non-image files"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
event.ignore = Mock()
widget.dragEnterEvent(event)
# Should not accept PDF files
assert not event.acceptProposedAction.called
def test_rejects_empty_mime_data(self, qtbot):
"""Test rejects events with no URLs"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
mime_data = QMimeData()
# No URLs set
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
widget.dragEnterEvent(event)
assert not event.acceptProposedAction.called
class TestDragMoveEvent:
"""Test dragMoveEvent method"""
def test_accepts_drag_move_with_image(self, qtbot):
"""Test accepts drag move events with image files"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
widget.dragMoveEvent(event)
assert event.acceptProposedAction.called
class TestDropEvent:
"""Test dropEvent method"""
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
"""Test dropping image file creates ImageData element"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
# Mock update method
widget.update = Mock()
# Setup project with page
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
# Mock asset manager
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(return_value="/imported/image.jpg")
# Mock history
mock_window.project.history = Mock()
# Mock page renderer
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=True)
mock_renderer.screen_to_page = Mock(return_value=(100, 100))
# Mock _get_page_at to return tuple
widget._get_page_at = Mock(return_value=(page, 0, mock_renderer))
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
# Create drop event
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should have called asset manager
assert mock_window.project.asset_manager.import_asset.called
# Should have created command
assert mock_cmd_class.called
# Should have executed command
assert mock_window.project.history.execute.called
assert widget.update.called
def test_drop_outside_page_does_nothing(self, qtbot):
"""Test dropping outside any page does nothing"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
# Mock renderer that returns False (not in page)
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=False)
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(5000, 5000))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should not create any elements
assert len(page.layout.elements) == 0
def test_drop_updates_existing_placeholder(self, qtbot, tmp_path):
"""Test dropping on existing placeholder updates it with image"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
widget.update = Mock()
# Create a real test image file
test_image = tmp_path / "test_image.jpg"
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Minimal JPEG header
# Setup project with page containing placeholder
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
page.layout.elements.append(placeholder)
mock_window.project.pages = [page]
# Mock renderer
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=True)
mock_renderer.screen_to_page = Mock(return_value=(150, 150))
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
# Mock element selection to return the placeholder
widget._get_element_at = Mock(return_value=placeholder)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should replace placeholder with ImageData
assert len(page.layout.elements) == 1
assert isinstance(page.layout.elements[0], ImageData)
# Image path should now be in assets folder (imported)
assert page.layout.elements[0].image_path.startswith("assets/")
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_multiple_files(self, mock_cmd_class, qtbot):
"""Test dropping first image from multiple files"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
widget.update = Mock()
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(return_value="/imported/image1.jpg")
mock_window.project.history = Mock()
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=True)
mock_renderer.screen_to_page = Mock(return_value=(100, 100))
widget._get_page_at = Mock(return_value=(page, 0, mock_renderer))
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
# Create drop event with multiple files (only first is used)
mime_data = QMimeData()
mime_data.setUrls(
[
QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image2.png"),
QUrl.fromLocalFile("/path/to/image3.jpg"),
]
)
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Only first image should be processed
assert mock_window.project.asset_manager.import_asset.call_count == 1
def test_drop_no_project_does_nothing(self, qtbot):
"""Test dropping when no project loaded does nothing"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.update = Mock()
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
# Mock _get_element_at to return None (no element hit)
widget._get_element_at = Mock(return_value=None)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
# Should not crash
widget.dropEvent(event)
# Should still accept event and call update
assert event.acceptProposedAction.called
assert widget.update.called
def test_drop_on_existing_image_updates_it(self, qtbot, tmp_path):
"""Test dropping on existing ImageData updates its image path"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Create a real test image file
test_image = tmp_path / "new_image.jpg"
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project with page containing existing ImageData
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
# Mock asset manager
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(return_value="assets/new_image.jpg")
widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=existing_image)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should update existing ImageData's path
assert existing_image.image_path == "assets/new_image.jpg"
assert mock_window.project.asset_manager.import_asset.called
def test_drop_with_asset_import_failure(self, qtbot, tmp_path):
"""Test dropping handles asset import errors gracefully"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.update = Mock()
test_image = tmp_path / "test.jpg"
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
# Mock asset manager to raise exception
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=existing_image)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
# Should not crash, should handle error gracefully
widget.dropEvent(event)
# Original path should remain unchanged
assert existing_image.image_path == "assets/old.jpg"
assert event.acceptProposedAction.called
def test_drop_with_corrupted_image_uses_defaults(self, qtbot, tmp_path):
"""Test dropping corrupted image uses default dimensions"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
widget.update = Mock()
# Create a corrupted/invalid image file
corrupted_image = tmp_path / "corrupted.jpg"
corrupted_image.write_bytes(b"not a valid image")
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(return_value="assets/corrupted.jpg")
mock_window.project.history = Mock()
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=True)
mock_renderer.screen_to_page = Mock(return_value=(100, 100))
widget._get_page_at = Mock(return_value=(page, 0, mock_renderer))
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=None)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile(str(corrupted_image))])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should use default dimensions (200, 150) from _calculate_image_dimensions
# Check that AddElementCommand was called with an ImageData
from pyPhotoAlbum.commands import AddElementCommand
with patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") as mock_cmd:
# Re-run to check the call
widget.dropEvent(event)
assert mock_cmd.called
class TestDragMoveEventEdgeCases:
"""Test edge cases for dragMoveEvent"""
def test_drag_move_rejects_no_urls(self, qtbot):
"""Test dragMoveEvent rejects events without URLs"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
mime_data = QMimeData()
# No URLs set
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
event.ignore = Mock()
widget.dragMoveEvent(event)
# Should ignore the event
assert event.ignore.called
assert not event.acceptProposedAction.called
class TestExtractImagePathEdgeCases:
"""Test edge cases for _extract_image_path"""
def test_drop_ignores_non_image_urls(self, qtbot):
"""Test dropping non-image files is ignored"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.update = Mock()
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.ignore = Mock()
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should ignore event since no valid image files
assert event.ignore.called
assert not event.acceptProposedAction.called
def test_drop_ignores_empty_urls(self, qtbot):
"""Test dropping with no URLs is ignored"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.update = Mock()
mime_data = QMimeData()
# No URLs at all
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.ignore = Mock()
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should ignore event
assert event.ignore.called
assert not event.acceptProposedAction.called
class TestPlaceholderReplacementEdgeCases:
"""Test edge cases for placeholder replacement"""
def test_replace_placeholder_with_no_pages(self, qtbot, tmp_path):
"""Test replacing placeholder when project has no pages"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.update = Mock()
test_image = tmp_path / "test.jpg"
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project WITHOUT pages
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
mock_window.project.pages = [] # Empty pages list
from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(return_value="assets/test.jpg")
widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=placeholder)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
# Should not crash when trying to replace placeholder
widget.dropEvent(event)
# Event should still be accepted
assert event.acceptProposedAction.called