pyPhotoAlbum/tests/test_page_setup_dialog.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

734 lines
27 KiB
Python

"""
Tests for PageSetupDialog
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtWidgets import QDialog
from pyPhotoAlbum.dialogs.page_setup_dialog import PageSetupDialog
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
class TestPageSetupDialog:
"""Test PageSetupDialog UI component"""
def test_dialog_initialization(self, qtbot):
"""Test dialog initializes with project data"""
project = Project(name="Test")
project.paper_thickness_mm = 0.1
project.cover_bleed_mm = 3.0
project.working_dpi = 96
project.export_dpi = 300
project.page_size_mm = (210, 297)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Check dialog is created
assert dialog.windowTitle() == "Page Setup"
assert dialog.minimumWidth() == 450
# Check DPI values initialized correctly
assert dialog.working_dpi_spinbox.value() == 96
assert dialog.export_dpi_spinbox.value() == 300
# Check cover settings initialized correctly
assert dialog.thickness_spinbox.value() == 0.1
assert dialog.bleed_spinbox.value() == 3.0
def test_dialog_page_selection(self, qtbot):
"""Test page selection combo box populated correctly"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
page2.manually_sized = True
page3 = Page(layout=PageLayout(width=420, height=297), page_number=3)
page3.is_double_spread = True
project.pages = [page1, page2, page3]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Check combo box has all pages
assert dialog.page_combo.count() == 3
# Check page labels
assert "Page 1" in dialog.page_combo.itemText(0)
assert "Page 2" in dialog.page_combo.itemText(1)
assert "*" in dialog.page_combo.itemText(1) # Manually sized marker
# Page 3 is a double spread, so it shows as "Pages 3-4"
assert "Pages 3-4" in dialog.page_combo.itemText(2) or "Page 3" in dialog.page_combo.itemText(2)
assert "Double Spread" in dialog.page_combo.itemText(2)
def test_dialog_cover_settings_visibility(self, qtbot):
"""Test cover settings visibility toggled based on page selection"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
project.pages = [page1, page2]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Test the _on_page_changed method directly (testing business logic)
# When showing first page (index 0), cover group should be made visible
dialog._on_page_changed(0)
# We can't reliably test isVisible() in headless Qt, but we can verify
# the method was called and completed without error
# When showing second page (index 1), cover group should be hidden
dialog._on_page_changed(1)
# Test that invalid indices are handled gracefully
dialog._on_page_changed(-1) # Should return early
dialog._on_page_changed(999) # Should return early
# Verify page combo was populated correctly
assert dialog.page_combo.count() == 2
def test_dialog_cover_disables_size_editing(self, qtbot):
"""Test cover pages disable size editing"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
page1 = Page(layout=PageLayout(width=500, height=297), page_number=1)
page1.is_cover = True
project.pages = [page1]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Size editing should be disabled for covers
assert not dialog.width_spinbox.isEnabled()
assert not dialog.height_spinbox.isEnabled()
assert not dialog.set_default_checkbox.isEnabled()
def test_dialog_double_spread_width_calculation(self, qtbot):
"""Test double spread shows per-page width, not total width"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
page = Page(layout=PageLayout(width=420, height=297), page_number=1)
page.is_double_spread = True
page.layout.base_width = 210
project.pages = [page]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Should show base width (per-page width), not total width
assert dialog.width_spinbox.value() == 210
assert dialog.height_spinbox.value() == 297
def test_dialog_spine_info_calculation(self, qtbot):
"""Test spine info is calculated correctly"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
project.paper_thickness_mm = 0.1
project.cover_bleed_mm = 3.0
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page1.is_cover = False
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
page3 = Page(layout=PageLayout(width=210, height=297), page_number=3)
project.pages = [page1, page2, page3]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Enable cover checkbox
dialog.cover_checkbox.setChecked(True)
# Check spine info label has content
spine_text = dialog.spine_info_label.text()
assert "Cover Layout" in spine_text
assert "Front" in spine_text
assert "Spine" in spine_text
assert "Back" in spine_text
assert "Bleed" in spine_text
# Disable cover checkbox
dialog.cover_checkbox.setChecked(False)
# Spine info should be empty
assert dialog.spine_info_label.text() == ""
def test_get_values_returns_correct_data(self, qtbot):
"""Test get_values returns all dialog values"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
project.paper_thickness_mm = 0.1
project.cover_bleed_mm = 3.0
project.working_dpi = 96
project.export_dpi = 300
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Modify some values
dialog.width_spinbox.setValue(200)
dialog.height_spinbox.setValue(280)
dialog.working_dpi_spinbox.setValue(150)
dialog.export_dpi_spinbox.setValue(600)
dialog.set_default_checkbox.setChecked(True)
dialog.cover_checkbox.setChecked(True)
dialog.thickness_spinbox.setValue(0.15)
dialog.bleed_spinbox.setValue(5.0)
values = dialog.get_values()
# Check all values returned
assert values["selected_index"] == 0
assert values["selected_page"] == page
assert values["is_cover"] is True
assert values["paper_thickness_mm"] == 0.15
assert values["cover_bleed_mm"] == 5.0
assert values["width_mm"] == 200
assert values["height_mm"] == 280
assert values["working_dpi"] == 150
assert values["export_dpi"] == 600
assert values["set_as_default"] is True
def test_dialog_page_change_updates_values(self, qtbot):
"""Test changing selected page updates displayed values"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=180, height=250), page_number=2)
project.pages = [page1, page2]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Initially showing page 1 values
assert dialog.width_spinbox.value() == 210
assert dialog.height_spinbox.value() == 297
# Change to page 2
dialog.page_combo.setCurrentIndex(1)
# Should now show page 2 values
assert dialog.width_spinbox.value() == 180
assert dialog.height_spinbox.value() == 250
class TestDialogMixin:
"""Test DialogMixin functionality"""
def test_dialog_mixin_create_dialog_accepted(self, qtbot):
"""Test create_dialog returns values when accepted"""
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
class TestWindow(DialogMixin):
pass
window = TestWindow()
# Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={"test": "value"})
# Mock dialog class
mock_dialog_class = Mock(return_value=mock_dialog)
result = window.create_dialog(mock_dialog_class)
assert result == {"test": "value"}
mock_dialog.exec.assert_called_once()
def test_dialog_mixin_create_dialog_rejected(self, qtbot):
"""Test create_dialog returns None when rejected"""
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
class TestWindow(DialogMixin):
pass
window = TestWindow()
# Create mock dialog
mock_dialog = Mock(spec=QDialog)
mock_dialog.exec.return_value = QDialog.DialogCode.Rejected
# Mock dialog class
mock_dialog_class = Mock(return_value=mock_dialog)
result = window.create_dialog(mock_dialog_class)
assert result is None
mock_dialog.exec.assert_called_once()
def test_dialog_mixin_show_dialog_with_callback(self, qtbot):
"""Test show_dialog executes callback on acceptance"""
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
class TestWindow(DialogMixin):
pass
window = TestWindow()
# Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={"test": "value"})
# Mock dialog class
mock_dialog_class = Mock(return_value=mock_dialog)
# Mock callback
callback = Mock()
result = window.show_dialog(mock_dialog_class, on_accept=callback)
assert result is True
callback.assert_called_once_with({"test": "value"})
class TestDialogActionDecorator:
"""Test the @dialog_action decorator functionality"""
def test_decorator_with_title_override(self, qtbot):
"""Test decorator can set custom dialog title"""
from pyPhotoAlbum.decorators import dialog_action
# We'll test that the decorator can pass through kwargs
# This is more of a structural test
decorator = dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
assert decorator.dialog_class == PageSetupDialog
assert decorator.requires_pages is True
def test_decorator_without_pages_requirement(self, qtbot):
"""Test decorator can disable page requirement"""
from pyPhotoAlbum.decorators import dialog_action
decorator = dialog_action(dialog_class=PageSetupDialog, requires_pages=False)
assert decorator.requires_pages is False
def test_dialog_action_class_decorator(self, qtbot):
"""Test DialogAction class directly"""
from pyPhotoAlbum.decorators import DialogAction
decorator = DialogAction(dialog_class=PageSetupDialog, requires_pages=True)
assert decorator.dialog_class == PageSetupDialog
assert decorator.requires_pages is True
class TestDialogMixinEdgeCases:
"""Test edge cases for DialogMixin"""
def test_create_dialog_without_get_values(self, qtbot):
"""Test create_dialog when dialog has no get_values method"""
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
class TestWindow(DialogMixin):
pass
window = TestWindow()
# Create mock dialog WITHOUT get_values
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
# Explicitly make get_values unavailable
del mock_dialog.get_values
mock_dialog_class = Mock(return_value=mock_dialog)
result = window.create_dialog(mock_dialog_class)
# Should return True when accepted even without get_values
assert result is True
def test_create_dialog_with_title(self, qtbot):
"""Test create_dialog with custom title"""
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
class TestWindow(DialogMixin):
pass
window = TestWindow()
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={"data": "test"})
mock_dialog_class = Mock(return_value=mock_dialog)
result = window.create_dialog(mock_dialog_class, title="Custom Title")
# Verify setWindowTitle was called
mock_dialog.setWindowTitle.assert_called_once_with("Custom Title")
assert result == {"data": "test"}
def test_show_dialog_rejected(self, qtbot):
"""Test show_dialog when user rejects dialog"""
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
class TestWindow(DialogMixin):
pass
window = TestWindow()
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Rejected)
mock_dialog_class = Mock(return_value=mock_dialog)
callback = Mock()
result = window.show_dialog(mock_dialog_class, on_accept=callback)
# Callback should not be called
callback.assert_not_called()
assert result is False
class TestPageSetupDialogEdgeCases:
"""Test edge cases in PageSetupDialog"""
def test_dialog_with_cover_page(self, qtbot):
"""Test dialog correctly handles cover pages"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
project.paper_thickness_mm = 0.1
project.cover_bleed_mm = 3.0
page1 = Page(layout=PageLayout(width=500, height=297), page_number=1)
page1.is_cover = True
project.pages = [page1]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Cover checkbox should be checked
assert dialog.cover_checkbox.isChecked()
# Width spinbox should show full cover width
assert dialog.width_spinbox.value() == 500
def test_dialog_invalid_initial_page_index(self, qtbot):
"""Test dialog handles invalid initial page index gracefully"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
# Invalid initial index (out of bounds)
dialog = PageSetupDialog(None, project, initial_page_index=999)
qtbot.addWidget(dialog)
# Should still work, defaulting to first available page or handling gracefully
assert dialog.page_combo.count() == 1
def test_on_page_changed_invalid_index(self, qtbot):
"""Test _on_page_changed handles invalid indices"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Call with negative index - should return early
dialog._on_page_changed(-1)
# Call with out of bounds index - should return early
dialog._on_page_changed(999)
# Dialog should still be functional
assert dialog.page_combo.count() == 1
def test_update_spine_info_when_not_cover(self, qtbot):
"""Test spine info is empty when cover checkbox is unchecked"""
project = Project(name="Test")
project.page_size_mm = (210, 297)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
dialog = PageSetupDialog(None, project, initial_page_index=0)
qtbot.addWidget(dialog)
# Uncheck cover
dialog.cover_checkbox.setChecked(False)
# Spine info should be empty
assert dialog.spine_info_label.text() == ""
class TestPageSetupIntegration:
"""Integration tests for page_setup with decorator"""
def test_page_setup_decorator_requires_pages(self, qtbot):
"""Test page_setup decorator returns early when no pages"""
from PyQt6.QtWidgets import QMainWindow
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
class TestWindow(PageOperationsMixin, ApplicationStateMixin, QMainWindow):
def __init__(self):
super().__init__()
self._project = Project(name="Test")
self._project.pages = [] # No pages
self._gl_widget = Mock()
self._status_bar = Mock()
self._update_view_called = False
def update_view(self):
self._update_view_called = True
def show_status(self, message, timeout=0):
pass
window = TestWindow()
qtbot.addWidget(window)
# Should return early without showing dialog
result = window.page_setup()
# No update should occur
assert not window._update_view_called
assert result is None
def test_page_setup_applies_values(self, qtbot):
"""Test page_setup applies dialog values to project"""
from PyQt6.QtWidgets import QMainWindow
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
class TestWindow(PageOperationsMixin, ApplicationStateMixin, QMainWindow):
def __init__(self):
super().__init__()
self._project = Project(name="Test")
self._project.page_size_mm = (210, 297)
self._project.working_dpi = 96
self._project.export_dpi = 300
self._project.paper_thickness_mm = 0.1
self._project.cover_bleed_mm = 3.0
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
self._project.pages = [page]
self._gl_widget = Mock()
self._gl_widget._page_renderers = []
self._status_bar = Mock()
self._update_view_called = False
self._status_message = None
def _get_most_visible_page_index(self):
return 0
def update_view(self):
self._update_view_called = True
def show_status(self, message, timeout=0):
self._status_message = message
window = TestWindow()
qtbot.addWidget(window)
# Create mock values that would come from dialog
values = {
"selected_index": 0,
"selected_page": window.project.pages[0],
"is_cover": False,
"paper_thickness_mm": 0.15,
"cover_bleed_mm": 5.0,
"width_mm": 200,
"height_mm": 280,
"working_dpi": 150,
"export_dpi": 600,
"set_as_default": True,
}
# Access the unwrapped function to test business logic directly
# The decorator wraps the function, so we need to get the original
# or call it through the wrapper with the right setup
import inspect
# Get the original function before decorators
original_func = window.page_setup
# Decorators return wrappers, but we can call them with values directly
# by accessing the innermost wrapped function
while hasattr(original_func, "__wrapped__"):
original_func = original_func.__wrapped__
# If no __wrapped__, the decorator system is different
# Let's just call the business logic method manually
# First, let's extract and call just the business logic
from pyPhotoAlbum.mixins.operations import page_ops
# Get the undecorated method from the class
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
# Find the innermost function
while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
# Call the business logic directly
undecorated_page_setup(window, values)
# Check values applied to project
assert window.project.paper_thickness_mm == 0.15
assert window.project.cover_bleed_mm == 5.0
assert window.project.working_dpi == 150
assert window.project.export_dpi == 600
assert window.project.page_size_mm == (200, 280) # set_as_default=True
# Check page size updated
assert window.project.pages[0].layout.size == (200, 280)
assert window.project.pages[0].manually_sized is True
# Check view updated
assert window._update_view_called
assert window._status_message is not None
def test_page_setup_cover_designation(self, qtbot):
"""Test page_setup correctly designates and un-designates covers"""
from PyQt6.QtWidgets import QMainWindow
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
class TestWindow(PageOperationsMixin, ApplicationStateMixin, QMainWindow):
def __init__(self):
super().__init__()
self._project = Project(name="Test")
self._project.page_size_mm = (210, 297)
self._project.working_dpi = 96
self._project.export_dpi = 300
self._project.paper_thickness_mm = 0.1
self._project.cover_bleed_mm = 3.0
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.is_cover = False
self._project.pages = [page]
self._gl_widget = Mock()
self._gl_widget._page_renderers = []
self._status_bar = Mock()
self._update_view_called = False
def _get_most_visible_page_index(self):
return 0
def update_view(self):
self._update_view_called = True
def show_status(self, message, timeout=0):
pass
window = TestWindow()
qtbot.addWidget(window)
# Test designating first page as cover
values = {
"selected_index": 0,
"selected_page": window.project.pages[0],
"is_cover": True, # Designate as cover
"paper_thickness_mm": 0.1,
"cover_bleed_mm": 3.0,
"width_mm": 210,
"height_mm": 297,
"working_dpi": 96,
"export_dpi": 300,
"set_as_default": False,
}
# Get the undecorated method
from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
# Mock update_cover_dimensions
window.project.update_cover_dimensions = Mock()
# Call with cover designation
undecorated_page_setup(window, values)
# Check cover was designated
assert window.project.pages[0].is_cover is True
assert window.project.has_cover is True
window.project.update_cover_dimensions.assert_called_once()
def test_page_setup_double_spread_sizing(self, qtbot):
"""Test page_setup correctly handles double spread page sizing"""
from PyQt6.QtWidgets import QMainWindow
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
class TestWindow(PageOperationsMixin, ApplicationStateMixin, QMainWindow):
def __init__(self):
super().__init__()
self._project = Project(name="Test")
self._project.page_size_mm = (210, 297)
self._project.working_dpi = 96
self._project.export_dpi = 300
self._project.paper_thickness_mm = 0.1
self._project.cover_bleed_mm = 3.0
# Create double spread page
page = Page(layout=PageLayout(width=420, height=297), page_number=1)
page.is_double_spread = True
page.layout.base_width = 210
page.layout.is_facing_page = True
self._project.pages = [page]
self._gl_widget = Mock()
self._gl_widget._page_renderers = []
self._status_bar = Mock()
self._update_view_called = False
def _get_most_visible_page_index(self):
return 0
def update_view(self):
self._update_view_called = True
def show_status(self, message, timeout=0):
pass
window = TestWindow()
qtbot.addWidget(window)
# Test changing double spread page size
values = {
"selected_index": 0,
"selected_page": window.project.pages[0],
"is_cover": False,
"paper_thickness_mm": 0.1,
"cover_bleed_mm": 3.0,
"width_mm": 200, # New base width
"height_mm": 280, # New height
"working_dpi": 96,
"export_dpi": 300,
"set_as_default": False,
}
from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
undecorated_page_setup(window, values)
# Check double spread sizing
assert window.project.pages[0].layout.base_width == 200
assert window.project.pages[0].layout.size == (400, 280) # Double width
assert window.project.pages[0].manually_sized is True