Refactor to allow indepth testing
All checks were successful
Python CI / test (push) Successful in 1m37s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m11s
Tests / test (3.11) (push) Successful in 1m11s
Tests / test (3.9) (push) Successful in 1m8s

This commit is contained in:
Duncan Tourolle 2025-11-23 11:05:46 +01:00
parent 0d698a83b4
commit 6755549dfd
39 changed files with 5343 additions and 1819 deletions

View File

@ -6,6 +6,257 @@ from typing import List, Tuple
from pyPhotoAlbum.models import BaseLayoutElement
class ElementMaximizer:
"""
Handles element maximization using a crystal growth algorithm.
Breaks down the complex maximize_pattern logic into atomic, testable methods.
"""
def __init__(self, elements: List[BaseLayoutElement], page_size: Tuple[float, float], min_gap: float):
"""
Initialize the maximizer with elements and constraints.
Args:
elements: List of elements to maximize
page_size: (width, height) of the page in mm
min_gap: Minimum gap to maintain between elements and borders (in mm)
"""
self.elements = elements
self.page_width, self.page_height = page_size
self.min_gap = min_gap
self.changes: List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]] = []
self._record_initial_states()
def _record_initial_states(self) -> None:
"""Record initial positions and sizes for undo functionality."""
for elem in self.elements:
self.changes.append((elem, elem.position, elem.size))
def check_collision(self, elem_idx: int, new_size: Tuple[float, float]) -> bool:
"""
Check if element with new_size would collide with boundaries or other elements.
Args:
elem_idx: Index of the element to check
new_size: Proposed new size (width, height)
Returns:
True if collision detected, False otherwise
"""
elem = self.elements[elem_idx]
x, y = elem.position
w, h = new_size
# Check page boundaries
if x < self.min_gap or y < self.min_gap:
return True
if x + w > self.page_width - self.min_gap:
return True
if y + h > self.page_height - self.min_gap:
return True
# Check collision with other elements
for i, other in enumerate(self.elements):
if i == elem_idx:
continue
other_x, other_y = other.position
other_w, other_h = other.size
# Calculate distances between rectangles
horizontal_gap = max(
other_x - (x + w), # Other is to the right
x - (other_x + other_w) # Other is to the left
)
vertical_gap = max(
other_y - (y + h), # Other is below
y - (other_y + other_h) # Other is above
)
# If rectangles overlap or are too close in both dimensions
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
return True
return False
def find_max_scale(self, elem_idx: int, current_scale: float, max_search_scale: float = 3.0,
tolerance: float = 0.001, max_iterations: int = 20) -> float:
"""
Use binary search to find the maximum scale factor for an element.
Args:
elem_idx: Index of the element
current_scale: Current scale factor
max_search_scale: Maximum scale to search up to (relative to current_scale)
tolerance: Convergence tolerance for binary search
max_iterations: Maximum binary search iterations
Returns:
Maximum scale factor that doesn't cause collision
"""
old_size = self.changes[elem_idx][2]
# Binary search for maximum scale
low, high = current_scale, current_scale * max_search_scale
best_scale = current_scale
for _ in range(max_iterations):
mid = (low + high) / 2.0
test_size = (old_size[0] * mid, old_size[1] * mid)
if self.check_collision(elem_idx, test_size):
high = mid
else:
best_scale = mid
low = mid
if high - low < tolerance:
break
return best_scale
def grow_iteration(self, scales: List[float], growth_rate: float) -> bool:
"""
Perform one iteration of the growth algorithm.
Args:
scales: Current scale factors for each element
growth_rate: Percentage to grow each iteration (0.05 = 5%)
Returns:
True if any element grew, False otherwise
"""
any_growth = False
for i, elem in enumerate(self.elements):
old_size = self.changes[i][2]
# Try to grow this element
new_scale = scales[i] * (1.0 + growth_rate)
new_size = (old_size[0] * new_scale, old_size[1] * new_scale)
if not self.check_collision(i, new_size):
scales[i] = new_scale
elem.size = new_size
any_growth = True
else:
# Can't grow uniformly, try to find maximum possible scale
max_scale = self.find_max_scale(i, scales[i])
if max_scale > scales[i]:
scales[i] = max_scale
elem.size = (old_size[0] * max_scale, old_size[1] * max_scale)
any_growth = True
return any_growth
def check_element_collision(self, elem: BaseLayoutElement, new_pos: Tuple[float, float]) -> bool:
"""
Check if moving an element to new_pos would cause collision with other elements.
Args:
elem: The element to check
new_pos: Proposed new position (x, y)
Returns:
True if collision detected, False otherwise
"""
x, y = new_pos
w, h = elem.size
for other in self.elements:
if other is elem:
continue
ox, oy = other.position
ow, oh = other.size
# Check if rectangles overlap (with min_gap consideration)
if (abs((x + w/2) - (ox + ow/2)) < (w + ow)/2 + self.min_gap and
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + self.min_gap):
return True
return False
def center_element_horizontally(self, elem: BaseLayoutElement) -> None:
"""
Micro-adjust element position to center horizontally in available space.
Args:
elem: Element to center
"""
x, y = elem.position
w, h = elem.size
# Calculate available space on each side
space_left = x - self.min_gap
space_right = (self.page_width - self.min_gap) - (x + w)
if space_left >= 0 and space_right >= 0:
adjust_x = (space_right - space_left) / 4.0 # Gentle centering
new_x = max(self.min_gap, min(self.page_width - w - self.min_gap, x + adjust_x))
# Verify this doesn't cause collision
old_pos = elem.position
new_pos = (new_x, y)
if not self.check_element_collision(elem, new_pos):
elem.position = new_pos
def center_element_vertically(self, elem: BaseLayoutElement) -> None:
"""
Micro-adjust element position to center vertically in available space.
Args:
elem: Element to center
"""
x, y = elem.position
w, h = elem.size
# Calculate available space on each side
space_top = y - self.min_gap
space_bottom = (self.page_height - self.min_gap) - (y + h)
if space_top >= 0 and space_bottom >= 0:
adjust_y = (space_bottom - space_top) / 4.0
new_y = max(self.min_gap, min(self.page_height - h - self.min_gap, y + adjust_y))
# Verify this doesn't cause collision
old_pos = elem.position
new_pos = (x, new_y)
if not self.check_element_collision(elem, new_pos):
elem.position = new_pos
def center_elements(self) -> None:
"""Center all elements slightly within their constrained space."""
for elem in self.elements:
self.center_element_horizontally(elem)
self.center_element_vertically(elem)
def maximize(self, max_iterations: int = 100, growth_rate: float = 0.05) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Execute the maximization algorithm.
Args:
max_iterations: Maximum number of growth iterations
growth_rate: Percentage to grow each iteration (0.05 = 5%)
Returns:
List of (element, old_position, old_size) tuples for undo
"""
scales = [1.0] * len(self.elements)
# Growth algorithm - iterative expansion
for _ in range(max_iterations):
if not self.grow_iteration(scales, growth_rate):
break
# Center elements slightly within their constrained space
self.center_elements()
return self.changes
class AlignmentManager:
"""Manages alignment and distribution operations on multiple elements"""
@ -470,159 +721,8 @@ class AlignmentManager:
if not elements:
return []
page_width, page_height = page_size
changes = []
# Record initial states
for elem in elements:
changes.append((elem, elem.position, elem.size))
# Helper function to check if element would collide with boundaries or other elements
def check_collision(elem_idx: int, new_size: Tuple[float, float]) -> bool:
elem = elements[elem_idx]
x, y = elem.position
w, h = new_size
# Check page boundaries
if x < min_gap or y < min_gap:
return True
if x + w > page_width - min_gap:
return True
if y + h > page_height - min_gap:
return True
# Check collision with other elements
for i, other in enumerate(elements):
if i == elem_idx:
continue
other_x, other_y = other.position
other_w, other_h = other.size
# Calculate distances between rectangles
horizontal_gap = max(
other_x - (x + w), # Other is to the right
x - (other_x + other_w) # Other is to the left
)
vertical_gap = max(
other_y - (y + h), # Other is below
y - (other_y + other_h) # Other is above
)
# If rectangles overlap or are too close in both dimensions
if horizontal_gap < min_gap and vertical_gap < min_gap:
return True
return False
# Helper function to get the maximum scale factor for an element
def get_max_scale(elem_idx: int, current_scale: float) -> float:
elem = elements[elem_idx]
old_size = changes[elem_idx][2]
# Binary search for maximum scale
low, high = current_scale, current_scale * 3.0
best_scale = current_scale
for _ in range(20): # Binary search iterations
mid = (low + high) / 2.0
test_size = (old_size[0] * mid, old_size[1] * mid)
if check_collision(elem_idx, test_size):
high = mid
else:
best_scale = mid
low = mid
if high - low < 0.001:
break
return best_scale
# Growth algorithm - iterative expansion
scales = [1.0] * len(elements)
for iteration in range(max_iterations):
any_growth = False
for i, elem in enumerate(elements):
old_size = changes[i][2]
# Try to grow this element
new_scale = scales[i] * (1.0 + growth_rate)
new_size = (old_size[0] * new_scale, old_size[1] * new_scale)
if not check_collision(i, new_size):
scales[i] = new_scale
elem.size = new_size
any_growth = True
else:
# Can't grow uniformly, try to find maximum possible scale
max_scale = get_max_scale(i, scales[i])
if max_scale > scales[i]:
scales[i] = max_scale
elem.size = (old_size[0] * max_scale, old_size[1] * max_scale)
any_growth = True
# If no element could grow, we're done
if not any_growth:
break
# Optional: Center elements slightly within their constrained space
for elem in elements:
x, y = elem.position
w, h = elem.size
# Calculate available space on each side
space_left = x - min_gap
space_right = (page_width - min_gap) - (x + w)
space_top = y - min_gap
space_bottom = (page_height - min_gap) - (y + h)
# Micro-adjust position to center in available space
if space_left >= 0 and space_right >= 0:
adjust_x = (space_right - space_left) / 4.0 # Gentle centering
new_x = max(min_gap, min(page_width - w - min_gap, x + adjust_x))
# Verify this doesn't cause collision
old_pos = elem.position
elem.position = (new_x, y)
collision = False
for other in elements:
if other is elem:
continue
ox, oy = other.position
ow, oh = other.size
if (abs((new_x + w/2) - (ox + ow/2)) < (w + ow)/2 + min_gap and
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + min_gap):
collision = True
break
if collision:
elem.position = old_pos
if space_top >= 0 and space_bottom >= 0:
adjust_y = (space_bottom - space_top) / 4.0
new_y = max(min_gap, min(page_height - h - min_gap, y + adjust_y))
old_pos = elem.position
elem.position = (elem.position[0], new_y)
collision = False
for other in elements:
if other is elem:
continue
ox, oy = other.position
ow, oh = other.size
if (abs((elem.position[0] + w/2) - (ox + ow/2)) < (w + ow)/2 + min_gap and
abs((new_y + h/2) - (oy + oh/2)) < (h + oh)/2 + min_gap):
collision = True
break
if collision:
elem.position = old_pos
return changes
maximizer = ElementMaximizer(elements, page_size, min_gap)
return maximizer.maximize(max_iterations, growth_rate)
@staticmethod
def expand_to_bounds(

View File

@ -823,32 +823,30 @@ class CommandHistory:
if cmd:
self.redo_stack.append(cmd)
# Command type registry for deserialization
_COMMAND_DESERIALIZERS = {
"add_element": AddElementCommand.deserialize,
"delete_element": DeleteElementCommand.deserialize,
"move_element": MoveElementCommand.deserialize,
"resize_element": ResizeElementCommand.deserialize,
"rotate_element": RotateElementCommand.deserialize,
"align_elements": AlignElementsCommand.deserialize,
"resize_elements": ResizeElementsCommand.deserialize,
"change_zorder": ChangeZOrderCommand.deserialize,
"adjust_image_crop": AdjustImageCropCommand.deserialize,
}
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
"""Deserialize a single command"""
"""Deserialize a single command using registry pattern"""
cmd_type = data.get("type")
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
if not deserializer:
print(f"Warning: Unknown command type: {cmd_type}")
return None
try:
if cmd_type == "add_element":
return AddElementCommand.deserialize(data, project)
elif cmd_type == "delete_element":
return DeleteElementCommand.deserialize(data, project)
elif cmd_type == "move_element":
return MoveElementCommand.deserialize(data, project)
elif cmd_type == "resize_element":
return ResizeElementCommand.deserialize(data, project)
elif cmd_type == "rotate_element":
return RotateElementCommand.deserialize(data, project)
elif cmd_type == "align_elements":
return AlignElementsCommand.deserialize(data, project)
elif cmd_type == "resize_elements":
return ResizeElementsCommand.deserialize(data, project)
elif cmd_type == "change_zorder":
return ChangeZOrderCommand.deserialize(data, project)
elif cmd_type == "adjust_image_crop":
return AdjustImageCropCommand.deserialize(data, project)
else:
print(f"Warning: Unknown command type: {cmd_type}")
return None
return deserializer(data, project)
except Exception as e:
print(f"Error deserializing command: {e}")
return None

View File

@ -315,14 +315,111 @@ class UndoableOperation:
def undoable_operation(capture: str = 'page_elements', description: str = None) -> Callable:
"""
Convenience function for the UndoableOperation decorator.
This provides a lowercase function-style interface to the decorator.
Args:
capture: What to capture for undo/redo
description: Human-readable description of the operation
Returns:
UndoableOperation decorator instance
"""
return UndoableOperation(capture=capture, description=description)
class DialogAction:
"""
Decorator to mark methods that should open a dialog.
This decorator automatically handles dialog creation and result processing,
separating UI presentation from business logic.
Example:
@dialog_action(dialog_class=PageSetupDialog)
def page_setup(self, values):
# Just implement the business logic
# Dialog presentation is handled automatically
self.apply_page_setup(values)
"""
def __init__(
self,
dialog_class: type,
requires_pages: bool = True
):
"""
Initialize the dialog action decorator.
Args:
dialog_class: The dialog class to instantiate
requires_pages: Whether this action requires pages to exist
"""
self.dialog_class = dialog_class
self.requires_pages = requires_pages
def __call__(self, func: Callable) -> Callable:
"""
Decorate the function with automatic dialog handling.
Args:
func: The function to decorate (receives dialog values)
Returns:
The decorated function
"""
@wraps(func)
def wrapper(self_instance, *args, **kwargs):
# Check preconditions
if self.requires_pages and not self_instance.project.pages:
return
# Get initial page index if available
initial_page_index = 0
if hasattr(self_instance, '_get_most_visible_page_index'):
initial_page_index = self_instance._get_most_visible_page_index()
# Create and show dialog
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
# Create dialog
dialog = self.dialog_class(
parent=self_instance,
project=self_instance.project,
initial_page_index=initial_page_index,
**kwargs
)
# Show dialog and get result
from PyQt6.QtWidgets import QDialog
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get values from dialog
if hasattr(dialog, 'get_values'):
values = dialog.get_values()
# Call the decorated function with values
return func(self_instance, values, *args, **kwargs)
else:
return func(self_instance, *args, **kwargs)
return None
return wrapper
def dialog_action(
dialog_class: type,
requires_pages: bool = True
) -> Callable:
"""
Convenience function for the DialogAction decorator.
This provides a lowercase function-style interface to the decorator.
Args:
dialog_class: The dialog class to instantiate
requires_pages: Whether this action requires pages to exist
Returns:
DialogAction decorator instance
"""
return DialogAction(dialog_class=dialog_class, requires_pages=requires_pages)

View File

@ -0,0 +1,10 @@
"""
Dialog classes for pyPhotoAlbum
This package contains reusable dialog classes that encapsulate
UI presentation logic separately from business logic.
"""
from .page_setup_dialog import PageSetupDialog
__all__ = ['PageSetupDialog']

View File

@ -0,0 +1,330 @@
"""
Page Setup Dialog for pyPhotoAlbum
Encapsulates all UI logic for page setup configuration,
separating presentation from business logic.
"""
from typing import Optional, Dict, Any
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox,
QComboBox, QCheckBox
)
from pyPhotoAlbum.project import Project
class PageSetupDialog(QDialog):
"""
Dialog for configuring page settings.
This dialog handles all UI presentation logic for page setup,
including page size, DPI settings, and cover configuration.
"""
def __init__(
self,
parent,
project: Project,
initial_page_index: int = 0
):
"""
Initialize the page setup dialog.
Args:
parent: Parent widget
project: Project instance containing pages and settings
initial_page_index: Index of page to initially select
"""
super().__init__(parent)
self.project = project
self.initial_page_index = initial_page_index
self._setup_ui()
self._connect_signals()
self._initialize_values()
def _setup_ui(self):
"""Create and layout all UI components."""
self.setWindowTitle("Page Setup")
self.setMinimumWidth(450)
layout = QVBoxLayout()
# Page selection group
self._page_select_group = self._create_page_selection_group()
layout.addWidget(self._page_select_group)
# Cover settings group
self._cover_group = self._create_cover_settings_group()
layout.addWidget(self._cover_group)
# Page size group
self._size_group = self._create_page_size_group()
layout.addWidget(self._size_group)
# DPI settings group
self._dpi_group = self._create_dpi_settings_group()
layout.addWidget(self._dpi_group)
# Buttons
button_layout = self._create_button_layout()
layout.addLayout(button_layout)
self.setLayout(layout)
def _create_page_selection_group(self) -> QGroupBox:
"""Create the page selection group."""
group = QGroupBox("Select Page")
layout = QVBoxLayout()
# Page combo box
self.page_combo = QComboBox()
for i, page in enumerate(self.project.pages):
page_label = self.project.get_page_display_name(page)
if page.is_double_spread and not page.is_cover:
page_label += " (Double Spread)"
if page.manually_sized:
page_label += " *"
self.page_combo.addItem(page_label, i)
layout.addWidget(self.page_combo)
# Info label
info_label = QLabel("* = Manually sized page")
info_label.setStyleSheet("font-size: 9pt; color: gray;")
layout.addWidget(info_label)
group.setLayout(layout)
return group
def _create_cover_settings_group(self) -> QGroupBox:
"""Create the cover settings group."""
group = QGroupBox("Cover Settings")
layout = QVBoxLayout()
# Cover checkbox
self.cover_checkbox = QCheckBox("Designate as Cover")
self.cover_checkbox.setToolTip(
"Mark this page as the book cover with wrap-around front/spine/back"
)
layout.addWidget(self.cover_checkbox)
# Paper thickness
thickness_layout = QHBoxLayout()
thickness_layout.addWidget(QLabel("Paper Thickness:"))
self.thickness_spinbox = QDoubleSpinBox()
self.thickness_spinbox.setRange(0.05, 1.0)
self.thickness_spinbox.setSingleStep(0.05)
self.thickness_spinbox.setValue(self.project.paper_thickness_mm)
self.thickness_spinbox.setSuffix(" mm")
self.thickness_spinbox.setToolTip("Thickness of paper for spine calculation")
thickness_layout.addWidget(self.thickness_spinbox)
layout.addLayout(thickness_layout)
# Bleed margin
bleed_layout = QHBoxLayout()
bleed_layout.addWidget(QLabel("Bleed Margin:"))
self.bleed_spinbox = QDoubleSpinBox()
self.bleed_spinbox.setRange(0, 10)
self.bleed_spinbox.setSingleStep(0.5)
self.bleed_spinbox.setValue(self.project.cover_bleed_mm)
self.bleed_spinbox.setSuffix(" mm")
self.bleed_spinbox.setToolTip("Extra margin around cover for printing bleed")
bleed_layout.addWidget(self.bleed_spinbox)
layout.addLayout(bleed_layout)
# Calculated spine width display
self.spine_info_label = QLabel()
self.spine_info_label.setStyleSheet(
"font-size: 9pt; color: #0066cc; padding: 5px;"
)
self.spine_info_label.setWordWrap(True)
layout.addWidget(self.spine_info_label)
group.setLayout(layout)
return group
def _create_page_size_group(self) -> QGroupBox:
"""Create the page size group."""
group = QGroupBox("Page Size")
layout = QVBoxLayout()
# Width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width:"))
self.width_spinbox = QDoubleSpinBox()
self.width_spinbox.setRange(10, 1000)
self.width_spinbox.setSuffix(" mm")
width_layout.addWidget(self.width_spinbox)
layout.addLayout(width_layout)
# Height
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Height:"))
self.height_spinbox = QDoubleSpinBox()
self.height_spinbox.setRange(10, 1000)
self.height_spinbox.setSuffix(" mm")
height_layout.addWidget(self.height_spinbox)
layout.addLayout(height_layout)
# Set as default checkbox
self.set_default_checkbox = QCheckBox("Set as default for new pages")
self.set_default_checkbox.setToolTip(
"Update project default page size for future pages"
)
layout.addWidget(self.set_default_checkbox)
group.setLayout(layout)
return group
def _create_dpi_settings_group(self) -> QGroupBox:
"""Create the DPI settings group."""
group = QGroupBox("DPI Settings")
layout = QVBoxLayout()
# Working DPI
working_dpi_layout = QHBoxLayout()
working_dpi_layout.addWidget(QLabel("Working DPI:"))
self.working_dpi_spinbox = QSpinBox()
self.working_dpi_spinbox.setRange(72, 1200)
self.working_dpi_spinbox.setValue(self.project.working_dpi)
working_dpi_layout.addWidget(self.working_dpi_spinbox)
layout.addLayout(working_dpi_layout)
# Export DPI
export_dpi_layout = QHBoxLayout()
export_dpi_layout.addWidget(QLabel("Export DPI:"))
self.export_dpi_spinbox = QSpinBox()
self.export_dpi_spinbox.setRange(72, 1200)
self.export_dpi_spinbox.setValue(self.project.export_dpi)
export_dpi_layout.addWidget(self.export_dpi_spinbox)
layout.addLayout(export_dpi_layout)
group.setLayout(layout)
return group
def _create_button_layout(self) -> QHBoxLayout:
"""Create dialog button layout."""
layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
ok_btn.setDefault(True)
layout.addStretch()
layout.addWidget(cancel_btn)
layout.addWidget(ok_btn)
return layout
def _connect_signals(self):
"""Connect widget signals to handlers."""
self.page_combo.currentIndexChanged.connect(self._on_page_changed)
self.cover_checkbox.stateChanged.connect(self._update_spine_info)
self.thickness_spinbox.valueChanged.connect(self._update_spine_info)
self.bleed_spinbox.valueChanged.connect(self._update_spine_info)
def _initialize_values(self):
"""Initialize dialog values based on current page."""
# Set initial page selection
if 0 <= self.initial_page_index < len(self.project.pages):
self.page_combo.setCurrentIndex(self.initial_page_index)
# Trigger initial page change to populate values
self._on_page_changed(self.initial_page_index)
def _on_page_changed(self, index: int):
"""
Handle page selection change.
Args:
index: Index of selected page
"""
if index < 0 or index >= len(self.project.pages):
return
selected_page = self.project.pages[index]
is_first_page = (index == 0)
# Show/hide cover settings based on page selection
self._cover_group.setVisible(is_first_page)
# Update cover checkbox
if is_first_page:
self.cover_checkbox.setChecked(selected_page.is_cover)
self._update_spine_info()
# Get display width (accounting for double spreads and covers)
if selected_page.is_cover:
# For covers, show the full calculated width
display_width = selected_page.layout.size[0]
elif selected_page.is_double_spread:
display_width = (
selected_page.layout.base_width
if hasattr(selected_page.layout, 'base_width')
else selected_page.layout.size[0] / 2
)
else:
display_width = selected_page.layout.size[0]
self.width_spinbox.setValue(display_width)
self.height_spinbox.setValue(selected_page.layout.size[1])
# Disable size editing for covers (auto-calculated)
is_cover = selected_page.is_cover
self.width_spinbox.setEnabled(not is_cover)
self.height_spinbox.setEnabled(not is_cover)
self.set_default_checkbox.setEnabled(not is_cover)
def _update_spine_info(self):
"""Update the spine information display."""
if self.cover_checkbox.isChecked():
# Calculate spine width with current settings
content_pages = sum(
p.get_page_count() for p in self.project.pages if not p.is_cover
)
import math
sheets = math.ceil(content_pages / 4)
spine_width = sheets * self.thickness_spinbox.value() * 2
page_width = self.project.page_size_mm[0]
total_width = (
(page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
)
self.spine_info_label.setText(
f"Cover Layout: Front ({page_width:.0f}mm) + "
f"Spine ({spine_width:.2f}mm) + "
f"Back ({page_width:.0f}mm) + "
f"Bleed ({self.bleed_spinbox.value():.1f}mm × 2)\n"
f"Total Width: {total_width:.1f}mm | "
f"Content Pages: {content_pages} | Sheets: {sheets}"
)
else:
self.spine_info_label.setText("")
def get_values(self) -> Dict[str, Any]:
"""
Get dialog values.
Returns:
Dictionary containing all dialog values
"""
selected_index = self.page_combo.currentData()
selected_page = self.project.pages[selected_index]
return {
'selected_index': selected_index,
'selected_page': selected_page,
'is_cover': self.cover_checkbox.isChecked(),
'paper_thickness_mm': self.thickness_spinbox.value(),
'cover_bleed_mm': self.bleed_spinbox.value(),
'width_mm': self.width_spinbox.value(),
'height_mm': self.height_spinbox.value(),
'working_dpi': self.working_dpi_spinbox.value(),
'export_dpi': self.export_dpi_spinbox.value(),
'set_as_default': self.set_default_checkbox.isChecked()
}

View File

@ -421,54 +421,96 @@ class MergeManager:
their_data: Dict[str, Any]
):
"""Add non-conflicting pages and elements from their version."""
self._add_missing_pages(merged_data, their_data)
self._merge_page_elements(merged_data, their_data)
def _add_missing_pages(
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""Add pages that exist only in their version."""
our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])}
# Add pages that exist only in their version
for their_page in their_data.get("pages", []):
if their_page["uuid"] not in our_page_uuids:
merged_data["pages"].append(their_page)
# For pages that exist in both, merge elements
def _merge_page_elements(
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""For pages that exist in both versions, merge their elements."""
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
for our_page in merged_data.get("pages", []):
page_uuid = our_page["uuid"]
their_page = their_pages.get(page_uuid)
their_page = their_pages.get(our_page["uuid"])
if not their_page:
continue
if their_page:
our_elements = {
elem["uuid"]: elem
for elem in our_page.get("layout", {}).get("elements", [])
}
our_elements = {
elem["uuid"]: elem
for elem in our_page.get("layout", {}).get("elements", [])
}
# Process elements from their version
for their_elem in their_page.get("layout", {}).get("elements", []):
elem_uuid = their_elem["uuid"]
for their_elem in their_page.get("layout", {}).get("elements", []):
self._merge_element(
our_page=our_page,
page_uuid=our_page["uuid"],
their_elem=their_elem,
our_elements=our_elements
)
if elem_uuid not in our_elements:
# Add elements from their version that we don't have
our_page["layout"]["elements"].append(their_elem)
else:
# Element exists in both versions - check if we should use their version
our_elem = our_elements[elem_uuid]
def _merge_element(
self,
our_page: Dict[str, Any],
page_uuid: str,
their_elem: Dict[str, Any],
our_elements: Dict[str, Any]
):
"""Merge a single element from their version into our page."""
elem_uuid = their_elem["uuid"]
# Check if this element was part of a conflict that was already resolved
elem_in_conflict = any(
c.element_uuid == elem_uuid and c.page_uuid == page_uuid
for c in self.conflicts
)
# Add new elements that we don't have
if elem_uuid not in our_elements:
our_page["layout"]["elements"].append(their_elem)
return
if not elem_in_conflict:
# No conflict, so use the more recently modified version
our_modified = our_elem.get("last_modified")
their_modified = their_elem.get("last_modified")
# Element exists in both - check if already resolved as conflict
if self._is_element_in_conflict(elem_uuid, page_uuid):
return
if their_modified and (not our_modified or their_modified > our_modified):
# Their version is newer, replace ours
for i, elem in enumerate(our_page["layout"]["elements"]):
if elem["uuid"] == elem_uuid:
our_page["layout"]["elements"][i] = their_elem
break
# No conflict - use the more recently modified version
self._merge_by_timestamp(our_page, elem_uuid, their_elem, our_elements[elem_uuid])
def _is_element_in_conflict(self, elem_uuid: str, page_uuid: str) -> bool:
"""Check if element was part of a conflict that was already resolved."""
return any(
c.element_uuid == elem_uuid and c.page_uuid == page_uuid
for c in self.conflicts
)
def _merge_by_timestamp(
self,
our_page: Dict[str, Any],
elem_uuid: str,
their_elem: Dict[str, Any],
our_elem: Dict[str, Any]
):
"""Use the more recently modified version of an element."""
our_modified = our_elem.get("last_modified")
their_modified = their_elem.get("last_modified")
# Their version is newer
if not their_modified or (our_modified and their_modified <= our_modified):
return
# Replace with their newer version
for i, elem in enumerate(our_page["layout"]["elements"]):
if elem["uuid"] == elem_uuid:
our_page["layout"]["elements"][i] = their_elem
break
def concatenate_projects(

View File

@ -3,5 +3,6 @@ Mixin modules for pyPhotoAlbum
"""
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
__all__ = ['ApplicationStateMixin']
__all__ = ['ApplicationStateMixin', 'DialogMixin']

View File

@ -35,108 +35,134 @@ class AssetDropMixin:
event.ignore()
def dropEvent(self, event):
"""Handle drop events"""
if not event.mimeData().hasUrls():
event.ignore()
return
image_path = None
for url in event.mimeData().urls():
file_path = url.toLocalFile()
if any(file_path.lower().endswith(ext) for ext in self.IMAGE_EXTENSIONS):
image_path = file_path
break
"""Handle drop events - delegates to specialized handlers"""
image_path = self._extract_image_path(event)
if not image_path:
event.ignore()
return
x, y = event.position().x(), event.position().y()
target_element = self._get_element_at(x, y)
if target_element and isinstance(target_element, (ImageData, PlaceholderData)):
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project:
try:
# Import the asset to the project's assets folder
asset_path = main_window.project.asset_manager.import_asset(image_path)
if isinstance(target_element, PlaceholderData):
new_image = ImageData(
image_path=asset_path, # Use imported asset path
x=target_element.position[0],
y=target_element.position[1],
width=target_element.size[0],
height=target_element.size[1],
z_index=target_element.z_index
)
if main_window.project.pages:
for page in main_window.project.pages:
if target_element in page.layout.elements:
page.layout.elements.remove(target_element)
page.layout.add_element(new_image)
break
else:
# Update existing ImageData with imported asset
target_element.image_path = asset_path
print(f"Updated element with image: {asset_path}")
except Exception as e:
print(f"Error importing dropped image: {e}")
self._handle_drop_on_element(image_path, target_element)
else:
try:
from PIL import Image
img = Image.open(image_path)
img_width, img_height = img.size
max_size = 300
if img_width > max_size or img_height > max_size:
scale = min(max_size / img_width, max_size / img_height)
img_width = int(img_width * scale)
img_height = int(img_height * scale)
except Exception as e:
print(f"Error loading image dimensions: {e}")
img_width, img_height = 200, 150
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project and main_window.project.pages:
# Detect which page the drop occurred on
target_page, page_index, page_renderer = self._get_page_at(x, y)
if target_page and page_renderer:
# Update current_page_index
if page_index >= 0:
self.current_page_index = page_index
# Convert screen coordinates to page-local coordinates
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
try:
asset_path = main_window.project.asset_manager.import_asset(image_path)
new_image = ImageData(
image_path=asset_path,
x=page_local_x,
y=page_local_y,
width=img_width,
height=img_height
)
cmd = AddElementCommand(
target_page.layout,
new_image,
asset_manager=main_window.project.asset_manager
)
main_window.project.history.execute(cmd)
print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")
except Exception as e:
print(f"Error adding dropped image: {e}")
else:
print("Drop location not on any page")
self._handle_drop_on_empty_space(image_path, x, y)
event.acceptProposedAction()
self.update()
def _extract_image_path(self, event):
"""Extract the first valid image path from drop event"""
if not event.mimeData().hasUrls():
return None
for url in event.mimeData().urls():
file_path = url.toLocalFile()
if any(file_path.lower().endswith(ext) for ext in self.IMAGE_EXTENSIONS):
return file_path
return None
def _handle_drop_on_element(self, image_path, target_element):
"""Handle dropping an image onto an existing element"""
main_window = self.window()
if not (hasattr(main_window, 'project') and main_window.project):
return
try:
asset_path = main_window.project.asset_manager.import_asset(image_path)
if isinstance(target_element, PlaceholderData):
self._replace_placeholder_with_image(target_element, asset_path, main_window)
else:
target_element.image_path = asset_path
print(f"Updated element with image: {asset_path}")
except Exception as e:
print(f"Error importing dropped image: {e}")
def _replace_placeholder_with_image(self, placeholder, asset_path, main_window):
"""Replace a placeholder element with an ImageData element"""
new_image = ImageData(
image_path=asset_path,
x=placeholder.position[0],
y=placeholder.position[1],
width=placeholder.size[0],
height=placeholder.size[1],
z_index=placeholder.z_index
)
if not main_window.project.pages:
return
for page in main_window.project.pages:
if placeholder in page.layout.elements:
page.layout.elements.remove(placeholder)
page.layout.add_element(new_image)
break
def _handle_drop_on_empty_space(self, image_path, x, y):
"""Handle dropping an image onto empty space"""
main_window = self.window()
if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages):
return
img_width, img_height = self._calculate_image_dimensions(image_path)
target_page, page_index, page_renderer = self._get_page_at(x, y)
if not (target_page and page_renderer):
print("Drop location not on any page")
return
self._add_new_image_to_page(
image_path, target_page, page_index, page_renderer,
x, y, img_width, img_height, main_window
)
def _calculate_image_dimensions(self, image_path):
"""Calculate scaled image dimensions for new image"""
try:
from PIL import Image
img = Image.open(image_path)
img_width, img_height = img.size
max_size = 300
if img_width > max_size or img_height > max_size:
scale = min(max_size / img_width, max_size / img_height)
img_width = int(img_width * scale)
img_height = int(img_height * scale)
return img_width, img_height
except Exception as e:
print(f"Error loading image dimensions: {e}")
return 200, 150
def _add_new_image_to_page(self, image_path, target_page, page_index,
page_renderer, x, y, img_width, img_height, main_window):
"""Add a new image element to the target page"""
if page_index >= 0:
self.current_page_index = page_index
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
try:
asset_path = main_window.project.asset_manager.import_asset(image_path)
new_image = ImageData(
image_path=asset_path,
x=page_local_x,
y=page_local_y,
width=img_width,
height=img_height
)
cmd = AddElementCommand(
target_page.layout,
new_image,
asset_manager=main_window.project.asset_manager
)
main_window.project.history.execute(cmd)
print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")
except Exception as e:
print(f"Error adding dropped image: {e}")

View File

@ -0,0 +1,76 @@
"""
Dialog operations mixin for pyPhotoAlbum
Provides common functionality for creating and managing dialogs.
"""
from typing import Optional, Any, Callable
from PyQt6.QtWidgets import QDialog
class DialogMixin:
"""
Mixin providing dialog creation and management capabilities.
This mixin separates dialog UI concerns from business logic,
making it easier to create, test, and maintain complex dialogs.
"""
def create_dialog(
self,
dialog_class: type,
title: Optional[str] = None,
**kwargs
) -> Optional[Any]:
"""
Create and show a dialog, handling the result.
Args:
dialog_class: Dialog class to instantiate
title: Optional title override
**kwargs: Additional arguments passed to dialog constructor
Returns:
Dialog result if accepted, None if rejected
"""
# Create dialog instance
dialog = dialog_class(parent=self, **kwargs)
# Set title if provided
if title:
dialog.setWindowTitle(title)
# Show dialog and handle result
if dialog.exec() == QDialog.DialogCode.Accepted:
# Check if dialog has a get_values method
if hasattr(dialog, 'get_values'):
return dialog.get_values()
return True
return None
def show_dialog(
self,
dialog_class: type,
on_accept: Optional[Callable] = None,
**kwargs
) -> bool:
"""
Show a dialog and execute callback on acceptance.
Args:
dialog_class: Dialog class to instantiate
on_accept: Callback to execute if dialog is accepted.
Will receive dialog result as parameter.
**kwargs: Additional arguments passed to dialog constructor
Returns:
True if dialog was accepted, False otherwise
"""
result = self.create_dialog(dialog_class, **kwargs)
if result is not None and on_accept:
on_accept(result)
return True
return result is not None

View File

@ -61,7 +61,8 @@ class ElementManipulationMixin:
dpi = main_window.project.working_dpi
# Apply snapping to resize
new_pos, new_size = snap_sys.snap_resize(
from pyPhotoAlbum.snapping import SnapResizeParams
params = SnapResizeParams(
position=self.resize_start_pos,
size=self.resize_start_size,
dx=dx,
@ -71,6 +72,7 @@ class ElementManipulationMixin:
dpi=dpi,
project=main_window.project
)
new_pos, new_size = snap_sys.snap_resize(params)
# Apply the snapped values
self.selected_element.position = new_pos

View File

@ -0,0 +1,199 @@
"""
Command builders for different interaction types.
Each builder is responsible for:
1. Validating if a command should be created
2. Creating the appropriate command object
3. Logging the operation
"""
from abc import ABC, abstractmethod
from typing import Optional, Any
from pyPhotoAlbum.models import BaseLayoutElement
from .interaction_validators import InteractionChangeDetector
class CommandBuilder(ABC):
"""Base class for command builders."""
def __init__(self, change_detector: Optional[InteractionChangeDetector] = None):
self.change_detector = change_detector or InteractionChangeDetector()
@abstractmethod
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""
Check if a command should be built based on state changes.
Args:
element: The element being modified
start_state: Dict containing the initial state
**kwargs: Additional context
Returns:
True if a command should be created
"""
pass
@abstractmethod
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""
Build and return the command object.
Args:
element: The element being modified
start_state: Dict containing the initial state
**kwargs: Additional context
Returns:
Command object or None
"""
pass
def log_command(self, command_type: str, details: str):
"""Log command creation for debugging."""
print(f"{command_type} command created: {details}")
class MoveCommandBuilder(CommandBuilder):
"""Builds MoveElementCommand objects."""
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position changed significantly."""
old_pos = start_state.get('position')
if old_pos is None:
return False
new_pos = element.position
return self.change_detector.detect_position_change(old_pos, new_pos) is not None
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a MoveElementCommand."""
old_pos = start_state.get('position')
if old_pos is None:
return None
new_pos = element.position
change_info = self.change_detector.detect_position_change(old_pos, new_pos)
if change_info is None:
return None
from pyPhotoAlbum.commands import MoveElementCommand
command = MoveElementCommand(element, old_pos, new_pos)
self.log_command("Move", f"{old_pos}{new_pos}")
return command
class ResizeCommandBuilder(CommandBuilder):
"""Builds ResizeElementCommand objects."""
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position or size changed significantly."""
old_pos = start_state.get('position')
old_size = start_state.get('size')
if old_pos is None or old_size is None:
return False
new_pos = element.position
new_size = element.size
pos_change = self.change_detector.detect_position_change(old_pos, new_pos)
size_change = self.change_detector.detect_size_change(old_size, new_size)
return pos_change is not None or size_change is not None
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a ResizeElementCommand."""
old_pos = start_state.get('position')
old_size = start_state.get('size')
if old_pos is None or old_size is None:
return None
new_pos = element.position
new_size = element.size
if not self.can_build(element, start_state):
return None
from pyPhotoAlbum.commands import ResizeElementCommand
command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)
self.log_command("Resize", f"{old_size}{new_size}")
return command
class RotateCommandBuilder(CommandBuilder):
"""Builds RotateElementCommand objects."""
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if rotation changed significantly."""
old_rotation = start_state.get('rotation')
if old_rotation is None:
return False
new_rotation = element.rotation
return self.change_detector.detect_rotation_change(old_rotation, new_rotation) is not None
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a RotateElementCommand."""
old_rotation = start_state.get('rotation')
if old_rotation is None:
return None
new_rotation = element.rotation
change_info = self.change_detector.detect_rotation_change(old_rotation, new_rotation)
if change_info is None:
return None
from pyPhotoAlbum.commands import RotateElementCommand
command = RotateElementCommand(element, old_rotation, new_rotation)
self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
return command
class ImagePanCommandBuilder(CommandBuilder):
"""Builds AdjustImageCropCommand objects for image panning."""
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if crop info changed significantly."""
from pyPhotoAlbum.models import ImageData
if not isinstance(element, ImageData):
return False
old_crop = start_state.get('crop_info')
if old_crop is None:
return False
new_crop = element.crop_info
change_detector = InteractionChangeDetector(threshold=0.001)
return change_detector.detect_crop_change(old_crop, new_crop) is not None
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build an AdjustImageCropCommand."""
from pyPhotoAlbum.models import ImageData
if not isinstance(element, ImageData):
return None
old_crop = start_state.get('crop_info')
if old_crop is None:
return None
new_crop = element.crop_info
change_detector = InteractionChangeDetector(threshold=0.001)
change_info = change_detector.detect_crop_change(old_crop, new_crop)
if change_info is None:
return None
from pyPhotoAlbum.commands import AdjustImageCropCommand
command = AdjustImageCropCommand(element, old_crop, new_crop)
self.log_command("Image pan", f"{old_crop}{new_crop}")
return command

View File

@ -0,0 +1,148 @@
"""
Factory for creating interaction commands based on interaction type.
This implements the Strategy pattern, allowing different command builders
to be registered and used based on the interaction type.
"""
from typing import Optional, Dict, Any
from pyPhotoAlbum.models import BaseLayoutElement
from .interaction_command_builders import (
CommandBuilder,
MoveCommandBuilder,
ResizeCommandBuilder,
RotateCommandBuilder,
ImagePanCommandBuilder
)
class InteractionCommandFactory:
"""
Factory for creating commands from interaction data.
Uses the Strategy pattern to delegate command creation to
specialized builder classes based on interaction type.
"""
def __init__(self):
"""Initialize factory with default builders."""
self._builders: Dict[str, CommandBuilder] = {}
self._register_default_builders()
def _register_default_builders(self):
"""Register the default command builders."""
self.register_builder('move', MoveCommandBuilder())
self.register_builder('resize', ResizeCommandBuilder())
self.register_builder('rotate', RotateCommandBuilder())
self.register_builder('image_pan', ImagePanCommandBuilder())
def register_builder(self, interaction_type: str, builder: CommandBuilder):
"""
Register a command builder for an interaction type.
Args:
interaction_type: The type of interaction (e.g., 'move', 'resize')
builder: The CommandBuilder instance to handle this type
"""
self._builders[interaction_type] = builder
def create_command(self,
interaction_type: str,
element: BaseLayoutElement,
start_state: dict,
**kwargs) -> Optional[Any]:
"""
Create a command based on interaction type and state changes.
Args:
interaction_type: Type of interaction ('move', 'resize', etc.)
element: The element that was interacted with
start_state: Dictionary containing initial state values
**kwargs: Additional context for command creation
Returns:
Command object if changes warrant it, None otherwise
"""
builder = self._builders.get(interaction_type)
if builder is None:
print(f"Warning: No builder registered for interaction type '{interaction_type}'")
return None
if not builder.can_build(element, start_state, **kwargs):
return None
return builder.build(element, start_state, **kwargs)
def has_builder(self, interaction_type: str) -> bool:
"""Check if a builder is registered for the given interaction type."""
return interaction_type in self._builders
def get_supported_types(self) -> list:
"""Get list of supported interaction types."""
return list(self._builders.keys())
class InteractionState:
"""
Value object representing the state of an interaction.
This simplifies passing interaction data around and makes
the code more maintainable.
"""
def __init__(self,
element: Optional[BaseLayoutElement] = None,
interaction_type: Optional[str] = None,
position: Optional[tuple] = None,
size: Optional[tuple] = None,
rotation: Optional[float] = None,
crop_info: Optional[tuple] = None):
"""
Initialize interaction state.
Args:
element: The element being interacted with
interaction_type: Type of interaction
position: Initial position
size: Initial size
rotation: Initial rotation
crop_info: Initial crop info (for images)
"""
self.element = element
self.interaction_type = interaction_type
self.position = position
self.size = size
self.rotation = rotation
self.crop_info = crop_info
def to_dict(self) -> dict:
"""
Convert state to dictionary for command builders.
Returns:
Dict with non-None state values
"""
state = {}
if self.position is not None:
state['position'] = self.position
if self.size is not None:
state['size'] = self.size
if self.rotation is not None:
state['rotation'] = self.rotation
if self.crop_info is not None:
state['crop_info'] = self.crop_info
return state
def is_valid(self) -> bool:
"""Check if state has required fields for command creation."""
return self.element is not None and self.interaction_type is not None
def clear(self):
"""Clear all state values."""
self.element = None
self.interaction_type = None
self.position = None
self.size = None
self.rotation = None
self.crop_info = None

View File

@ -4,6 +4,7 @@ Mixin for automatic undo/redo handling in interactive mouse operations
from typing import Optional
from pyPhotoAlbum.models import BaseLayoutElement
from .interaction_command_factory import InteractionCommandFactory, InteractionState
class UndoableInteractionMixin:
@ -17,188 +18,97 @@ class UndoableInteractionMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Interaction tracking state
self._interaction_element: Optional[BaseLayoutElement] = None
self._interaction_type: Optional[str] = None
self._interaction_start_pos: Optional[tuple] = None
self._interaction_start_size: Optional[tuple] = None
self._interaction_start_rotation: Optional[float] = None
# Command factory for creating undo/redo commands
self._command_factory = InteractionCommandFactory()
# Interaction state tracking
self._interaction_state = InteractionState()
def _begin_move(self, element: BaseLayoutElement):
"""
Begin tracking a move operation.
Args:
element: The element being moved
"""
self._interaction_element = element
self._interaction_type = 'move'
self._interaction_start_pos = element.position
self._interaction_start_size = None
self._interaction_start_rotation = None
self._interaction_state.element = element
self._interaction_state.interaction_type = 'move'
self._interaction_state.position = element.position
def _begin_resize(self, element: BaseLayoutElement):
"""
Begin tracking a resize operation.
Args:
element: The element being resized
"""
self._interaction_element = element
self._interaction_type = 'resize'
self._interaction_start_pos = element.position
self._interaction_start_size = element.size
self._interaction_start_rotation = None
self._interaction_state.element = element
self._interaction_state.interaction_type = 'resize'
self._interaction_state.position = element.position
self._interaction_state.size = element.size
def _begin_rotate(self, element: BaseLayoutElement):
"""
Begin tracking a rotate operation.
Args:
element: The element being rotated
"""
self._interaction_element = element
self._interaction_type = 'rotate'
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = element.rotation
self._interaction_state.element = element
self._interaction_state.interaction_type = 'rotate'
self._interaction_state.rotation = element.rotation
def _begin_image_pan(self, element):
"""
Begin tracking an image pan operation.
Args:
element: The ImageData element being panned
"""
from pyPhotoAlbum.models import ImageData
if not isinstance(element, ImageData):
return
self._interaction_element = element
self._interaction_type = 'image_pan'
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = None
self._interaction_start_crop_info = element.crop_info
self._interaction_state.element = element
self._interaction_state.interaction_type = 'image_pan'
self._interaction_state.crop_info = element.crop_info
def _end_interaction(self):
"""
End the current interaction and create appropriate undo/redo command.
This method checks what changed during the interaction and creates
the appropriate Command object (MoveElementCommand, ResizeElementCommand,
or RotateElementCommand).
This method uses the command factory to create the appropriate
Command object based on what changed during the interaction.
"""
if not self._interaction_element or not self._interaction_type:
# Validate interaction state
if not self._interaction_state.is_valid():
self._clear_interaction_state()
return
element = self._interaction_element
# Get main window to access project history
main_window = self.window()
if not hasattr(main_window, 'project'):
self._clear_interaction_state()
return
# Create appropriate command based on interaction type
command = None
if self._interaction_type == 'move':
# Check if position actually changed
new_pos = element.position
if self._interaction_start_pos and new_pos != self._interaction_start_pos:
# Check for significant change (> 0.1 units)
dx = abs(new_pos[0] - self._interaction_start_pos[0])
dy = abs(new_pos[1] - self._interaction_start_pos[1])
if dx > 0.1 or dy > 0.1:
from pyPhotoAlbum.commands import MoveElementCommand
command = MoveElementCommand(
element,
self._interaction_start_pos,
new_pos
)
print(f"Move command created: {self._interaction_start_pos}{new_pos}")
elif self._interaction_type == 'resize':
# Check if position or size actually changed
new_pos = element.position
new_size = element.size
if self._interaction_start_pos and self._interaction_start_size:
pos_changed = new_pos != self._interaction_start_pos
size_changed = new_size != self._interaction_start_size
if pos_changed or size_changed:
# Check for significant change
dx = abs(new_pos[0] - self._interaction_start_pos[0])
dy = abs(new_pos[1] - self._interaction_start_pos[1])
dw = abs(new_size[0] - self._interaction_start_size[0])
dh = abs(new_size[1] - self._interaction_start_size[1])
if dx > 0.1 or dy > 0.1 or dw > 0.1 or dh > 0.1:
from pyPhotoAlbum.commands import ResizeElementCommand
command = ResizeElementCommand(
element,
self._interaction_start_pos,
self._interaction_start_size,
new_pos,
new_size
)
print(f"Resize command created: {self._interaction_start_size}{new_size}")
elif self._interaction_type == 'rotate':
# Check if rotation actually changed
new_rotation = element.rotation
if self._interaction_start_rotation is not None:
if abs(new_rotation - self._interaction_start_rotation) > 0.1:
from pyPhotoAlbum.commands import RotateElementCommand
command = RotateElementCommand(
element,
self._interaction_start_rotation,
new_rotation
)
print(f"Rotation command created: {self._interaction_start_rotation:.1f}° → {new_rotation:.1f}°")
elif self._interaction_type == 'image_pan':
# Check if crop_info actually changed
from pyPhotoAlbum.models import ImageData
if isinstance(element, ImageData):
new_crop_info = element.crop_info
if hasattr(self, '_interaction_start_crop_info') and self._interaction_start_crop_info is not None:
# Check if crop changed significantly (more than 0.001 in any coordinate)
if new_crop_info != self._interaction_start_crop_info:
old_crop = self._interaction_start_crop_info
significant_change = any(
abs(new_crop_info[i] - old_crop[i]) > 0.001
for i in range(4)
)
if significant_change:
from pyPhotoAlbum.commands import AdjustImageCropCommand
command = AdjustImageCropCommand(
element,
self._interaction_start_crop_info,
new_crop_info
)
print(f"Image pan command created: {self._interaction_start_crop_info}{new_crop_info}")
# Use factory to create command based on interaction type and changes
command = self._command_factory.create_command(
interaction_type=self._interaction_state.interaction_type,
element=self._interaction_state.element,
start_state=self._interaction_state.to_dict()
)
# Execute the command through history if one was created
if command:
main_window.project.history.execute(command)
# Clear interaction state
self._clear_interaction_state()
def _clear_interaction_state(self):
"""Clear all interaction tracking state"""
self._interaction_element = None
self._interaction_type = None
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = None
if hasattr(self, '_interaction_start_crop_info'):
self._interaction_start_crop_info = None
self._interaction_state.clear()
def _cancel_interaction(self):
"""Cancel the current interaction without creating a command"""

View File

@ -0,0 +1,156 @@
"""
Decorators and validators for interaction change detection.
"""
from functools import wraps
from typing import Optional, Tuple, Any
def significant_change(threshold: float = 0.1):
"""
Decorator that validates if a change is significant enough to warrant a command.
Args:
threshold: Minimum change magnitude to be considered significant
Returns:
None if change is insignificant, otherwise returns the command builder result
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if result is None:
return None
return result
return wrapper
return decorator
class ChangeValidator:
"""Validates whether changes are significant enough to create commands."""
@staticmethod
def position_changed(old_pos: Optional[Tuple[float, float]],
new_pos: Optional[Tuple[float, float]],
threshold: float = 0.1) -> bool:
"""Check if position changed significantly."""
if old_pos is None or new_pos is None:
return False
dx = abs(new_pos[0] - old_pos[0])
dy = abs(new_pos[1] - old_pos[1])
return dx > threshold or dy > threshold
@staticmethod
def size_changed(old_size: Optional[Tuple[float, float]],
new_size: Optional[Tuple[float, float]],
threshold: float = 0.1) -> bool:
"""Check if size changed significantly."""
if old_size is None or new_size is None:
return False
dw = abs(new_size[0] - old_size[0])
dh = abs(new_size[1] - old_size[1])
return dw > threshold or dh > threshold
@staticmethod
def rotation_changed(old_rotation: Optional[float],
new_rotation: Optional[float],
threshold: float = 0.1) -> bool:
"""Check if rotation changed significantly."""
if old_rotation is None or new_rotation is None:
return False
return abs(new_rotation - old_rotation) > threshold
@staticmethod
def crop_changed(old_crop: Optional[Tuple[float, float, float, float]],
new_crop: Optional[Tuple[float, float, float, float]],
threshold: float = 0.001) -> bool:
"""Check if crop info changed significantly."""
if old_crop is None or new_crop is None:
return False
if old_crop == new_crop:
return False
return any(abs(new_crop[i] - old_crop[i]) > threshold for i in range(4))
class InteractionChangeDetector:
"""Detects and quantifies changes in element properties."""
def __init__(self, threshold: float = 0.1):
self.threshold = threshold
self.validator = ChangeValidator()
def detect_position_change(self, old_pos: Tuple[float, float],
new_pos: Tuple[float, float]) -> Optional[dict]:
"""
Detect position change and return change info.
Returns:
Dict with change info if significant, None otherwise
"""
if not self.validator.position_changed(old_pos, new_pos, self.threshold):
return None
return {
'old_position': old_pos,
'new_position': new_pos,
'delta_x': new_pos[0] - old_pos[0],
'delta_y': new_pos[1] - old_pos[1]
}
def detect_size_change(self, old_size: Tuple[float, float],
new_size: Tuple[float, float]) -> Optional[dict]:
"""
Detect size change and return change info.
Returns:
Dict with change info if significant, None otherwise
"""
if not self.validator.size_changed(old_size, new_size, self.threshold):
return None
return {
'old_size': old_size,
'new_size': new_size,
'delta_width': new_size[0] - old_size[0],
'delta_height': new_size[1] - old_size[1]
}
def detect_rotation_change(self, old_rotation: float,
new_rotation: float) -> Optional[dict]:
"""
Detect rotation change and return change info.
Returns:
Dict with change info if significant, None otherwise
"""
if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold):
return None
return {
'old_rotation': old_rotation,
'new_rotation': new_rotation,
'delta_angle': new_rotation - old_rotation
}
def detect_crop_change(self, old_crop: Tuple[float, float, float, float],
new_crop: Tuple[float, float, float, float]) -> Optional[dict]:
"""
Detect crop change and return change info.
Returns:
Dict with change info if significant, None otherwise
"""
if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001):
return None
return {
'old_crop': old_crop,
'new_crop': new_crop,
'delta': tuple(new_crop[i] - old_crop[i] for i in range(4))
}

View File

@ -2,7 +2,8 @@
Page operations mixin for pyPhotoAlbum
"""
from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.decorators import ribbon_action, dialog_action
from pyPhotoAlbum.dialogs import PageSetupDialog
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
@ -78,303 +79,102 @@ class PageOperationsMixin:
tab="Layout",
group="Page"
)
def page_setup(self):
"""Open page setup dialog"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QComboBox, QCheckBox
# Check if we have pages
if not self.project.pages:
return
# Create dialog
dialog = QDialog(self)
dialog.setWindowTitle("Page Setup")
dialog.setMinimumWidth(450)
layout = QVBoxLayout()
# Page selection group
page_select_group = QGroupBox("Select Page")
page_select_layout = QVBoxLayout()
page_combo = QComboBox()
for i, page in enumerate(self.project.pages):
# Use display name helper
page_label = self.project.get_page_display_name(page)
if page.is_double_spread and not page.is_cover:
page_label += f" (Double Spread)"
if page.manually_sized:
page_label += " *"
page_combo.addItem(page_label, i)
page_select_layout.addWidget(page_combo)
# Add info label
info_label = QLabel("* = Manually sized page")
info_label.setStyleSheet("font-size: 9pt; color: gray;")
page_select_layout.addWidget(info_label)
page_select_group.setLayout(page_select_layout)
layout.addWidget(page_select_group)
# Cover settings group (only show if first page is selected)
cover_group = QGroupBox("Cover Settings")
cover_layout = QVBoxLayout()
# Cover checkbox
cover_checkbox = QCheckBox("Designate as Cover")
cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
cover_layout.addWidget(cover_checkbox)
# Paper thickness
thickness_layout = QHBoxLayout()
thickness_layout.addWidget(QLabel("Paper Thickness:"))
thickness_spinbox = QDoubleSpinBox()
thickness_spinbox.setRange(0.05, 1.0)
thickness_spinbox.setSingleStep(0.05)
thickness_spinbox.setValue(self.project.paper_thickness_mm)
thickness_spinbox.setSuffix(" mm")
thickness_spinbox.setToolTip("Thickness of paper for spine calculation")
thickness_layout.addWidget(thickness_spinbox)
cover_layout.addLayout(thickness_layout)
# Bleed margin
bleed_layout = QHBoxLayout()
bleed_layout.addWidget(QLabel("Bleed Margin:"))
bleed_spinbox = QDoubleSpinBox()
bleed_spinbox.setRange(0, 10)
bleed_spinbox.setSingleStep(0.5)
bleed_spinbox.setValue(self.project.cover_bleed_mm)
bleed_spinbox.setSuffix(" mm")
bleed_spinbox.setToolTip("Extra margin around cover for printing bleed")
bleed_layout.addWidget(bleed_spinbox)
cover_layout.addLayout(bleed_layout)
# Calculated spine width display
spine_info_label = QLabel()
spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
spine_info_label.setWordWrap(True)
cover_layout.addWidget(spine_info_label)
cover_group.setLayout(cover_layout)
layout.addWidget(cover_group)
# Page size group
size_group = QGroupBox("Page Size")
size_layout = QVBoxLayout()
# Width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width:"))
width_spinbox = QDoubleSpinBox()
width_spinbox.setRange(10, 1000)
width_spinbox.setSuffix(" mm")
width_layout.addWidget(width_spinbox)
size_layout.addLayout(width_layout)
# Height
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Height:"))
height_spinbox = QDoubleSpinBox()
height_spinbox.setRange(10, 1000)
height_spinbox.setSuffix(" mm")
height_layout.addWidget(height_spinbox)
size_layout.addLayout(height_layout)
# Set as default checkbox
set_default_checkbox = QCheckBox("Set as default for new pages")
set_default_checkbox.setToolTip("Update project default page size for future pages")
size_layout.addWidget(set_default_checkbox)
size_group.setLayout(size_layout)
layout.addWidget(size_group)
# DPI settings group
dpi_group = QGroupBox("DPI Settings")
dpi_layout = QVBoxLayout()
# Working DPI
working_dpi_layout = QHBoxLayout()
working_dpi_layout.addWidget(QLabel("Working DPI:"))
working_dpi_spinbox = QSpinBox()
working_dpi_spinbox.setRange(72, 1200)
working_dpi_spinbox.setValue(self.project.working_dpi)
working_dpi_layout.addWidget(working_dpi_spinbox)
dpi_layout.addLayout(working_dpi_layout)
# Export DPI
export_dpi_layout = QHBoxLayout()
export_dpi_layout.addWidget(QLabel("Export DPI:"))
export_dpi_spinbox = QSpinBox()
export_dpi_spinbox.setRange(72, 1200)
export_dpi_spinbox.setValue(self.project.export_dpi)
export_dpi_layout.addWidget(export_dpi_spinbox)
dpi_layout.addLayout(export_dpi_layout)
dpi_group.setLayout(dpi_layout)
layout.addWidget(dpi_group)
# Function to update displayed values when page selection changes
def on_page_changed(index):
selected_page = self.project.pages[index]
# Show/hide cover settings based on page selection
is_first_page = (index == 0)
cover_group.setVisible(is_first_page)
# Update cover checkbox
if is_first_page:
cover_checkbox.setChecked(selected_page.is_cover)
update_spine_info()
# Get base width (accounting for double spreads and covers)
if selected_page.is_cover:
# For covers, show the full calculated width
display_width = selected_page.layout.size[0]
elif selected_page.is_double_spread:
display_width = selected_page.layout.base_width if hasattr(selected_page.layout, 'base_width') else selected_page.layout.size[0] / 2
else:
display_width = selected_page.layout.size[0]
width_spinbox.setValue(display_width)
height_spinbox.setValue(selected_page.layout.size[1])
# Disable size editing for covers (auto-calculated)
if selected_page.is_cover:
width_spinbox.setEnabled(False)
height_spinbox.setEnabled(False)
set_default_checkbox.setEnabled(False)
else:
width_spinbox.setEnabled(True)
height_spinbox.setEnabled(True)
set_default_checkbox.setEnabled(True)
def update_spine_info():
"""Update the spine information display"""
if cover_checkbox.isChecked():
# Calculate spine width with current settings
content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)
import math
sheets = math.ceil(content_pages / 4)
spine_width = sheets * thickness_spinbox.value() * 2
page_width = self.project.page_size_mm[0]
total_width = (page_width * 2) + spine_width + (bleed_spinbox.value() * 2)
spine_info_label.setText(
f"Cover Layout: Front ({page_width:.0f}mm) + Spine ({spine_width:.2f}mm) + "
f"Back ({page_width:.0f}mm) + Bleed ({bleed_spinbox.value():.1f}mm × 2)\n"
f"Total Width: {total_width:.1f}mm | Content Pages: {content_pages} | Sheets: {sheets}"
)
else:
spine_info_label.setText("")
# Connect signals
cover_checkbox.stateChanged.connect(lambda: update_spine_info())
thickness_spinbox.valueChanged.connect(lambda: update_spine_info())
bleed_spinbox.valueChanged.connect(lambda: update_spine_info())
# Connect page selection change
page_combo.currentIndexChanged.connect(on_page_changed)
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
def page_setup(self, values):
"""
Apply page setup configuration.
# Initialize with most visible page
initial_page_index = self._get_most_visible_page_index()
if 0 <= initial_page_index < len(self.project.pages):
page_combo.setCurrentIndex(initial_page_index)
on_page_changed(initial_page_index if 0 <= initial_page_index < len(self.project.pages) else 0)
# Buttons
button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.reject)
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(dialog.accept)
ok_btn.setDefault(True)
button_layout.addStretch()
button_layout.addWidget(cancel_btn)
button_layout.addWidget(ok_btn)
layout.addLayout(button_layout)
dialog.setLayout(layout)
# Show dialog
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get selected page
selected_index = page_combo.currentData()
selected_page = self.project.pages[selected_index]
# Update project cover settings
self.project.paper_thickness_mm = thickness_spinbox.value()
self.project.cover_bleed_mm = bleed_spinbox.value()
# Handle cover designation (only for first page)
if selected_index == 0:
was_cover = selected_page.is_cover
is_cover = cover_checkbox.isChecked()
if was_cover != is_cover:
selected_page.is_cover = is_cover
self.project.has_cover = is_cover
if is_cover:
# Calculate and set cover dimensions
self.project.update_cover_dimensions()
print(f"Page 1 designated as cover")
else:
# Restore normal page size
selected_page.layout.size = self.project.page_size_mm
print(f"Cover removed from page 1")
# Get new values
width_mm = width_spinbox.value()
height_mm = height_spinbox.value()
# Don't allow manual size changes for covers
if not selected_page.is_cover:
# Check if size actually changed
# For double spreads, compare with base width
if selected_page.is_double_spread:
old_base_width = selected_page.layout.base_width if hasattr(selected_page.layout, 'base_width') else selected_page.layout.size[0] / 2
old_height = selected_page.layout.size[1]
size_changed = (old_base_width != width_mm or old_height != height_mm)
if size_changed:
# Update double spread
selected_page.layout.base_width = width_mm
selected_page.layout.size = (width_mm * 2, height_mm)
selected_page.manually_sized = True
print(f"{self.project.get_page_display_name(selected_page)} (double spread) updated to {width_mm}×{height_mm} mm per page")
This method contains only business logic. UI presentation
is handled by PageSetupDialog and the dialog_action decorator.
Args:
values: Dictionary of values from the dialog
"""
selected_page = values['selected_page']
selected_index = values['selected_index']
# Update project cover settings
self.project.paper_thickness_mm = values['paper_thickness_mm']
self.project.cover_bleed_mm = values['cover_bleed_mm']
# Handle cover designation (only for first page)
if selected_index == 0:
was_cover = selected_page.is_cover
is_cover = values['is_cover']
if was_cover != is_cover:
selected_page.is_cover = is_cover
self.project.has_cover = is_cover
if is_cover:
# Calculate and set cover dimensions
self.project.update_cover_dimensions()
print(f"Page 1 designated as cover")
else:
old_size = selected_page.layout.size
size_changed = (old_size != (width_mm, height_mm))
if size_changed:
# Update single page
selected_page.layout.size = (width_mm, height_mm)
selected_page.layout.base_width = width_mm
selected_page.manually_sized = True
print(f"{self.project.get_page_display_name(selected_page)} updated to {width_mm}×{height_mm} mm")
# Update DPI settings
self.project.working_dpi = working_dpi_spinbox.value()
self.project.export_dpi = export_dpi_spinbox.value()
# Set as default if checkbox is checked
if set_default_checkbox.isChecked():
self.project.page_size_mm = (width_mm, height_mm)
print(f"Project default page size set to {width_mm}×{height_mm} mm")
self.update_view()
# Build status message
page_name = self.project.get_page_display_name(selected_page)
if selected_page.is_cover:
status_msg = f"{page_name} updated"
# Restore normal page size
selected_page.layout.size = self.project.page_size_mm
print(f"Cover removed from page 1")
# Get new values
width_mm = values['width_mm']
height_mm = values['height_mm']
# Don't allow manual size changes for covers
if not selected_page.is_cover:
# Check if size actually changed
# For double spreads, compare with base width
if selected_page.is_double_spread:
old_base_width = (
selected_page.layout.base_width
if hasattr(selected_page.layout, 'base_width')
else selected_page.layout.size[0] / 2
)
old_height = selected_page.layout.size[1]
size_changed = (old_base_width != width_mm or old_height != height_mm)
if size_changed:
# Update double spread
selected_page.layout.base_width = width_mm
selected_page.layout.size = (width_mm * 2, height_mm)
selected_page.manually_sized = True
print(
f"{self.project.get_page_display_name(selected_page)} "
f"(double spread) updated to {width_mm}×{height_mm} mm per page"
)
else:
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
if set_default_checkbox.isChecked():
status_msg += " (set as default)"
self.show_status(status_msg, 2000)
old_size = selected_page.layout.size
size_changed = (old_size != (width_mm, height_mm))
if size_changed:
# Update single page
selected_page.layout.size = (width_mm, height_mm)
selected_page.layout.base_width = width_mm
selected_page.manually_sized = True
print(
f"{self.project.get_page_display_name(selected_page)} "
f"updated to {width_mm}×{height_mm} mm"
)
# Update DPI settings
self.project.working_dpi = values['working_dpi']
self.project.export_dpi = values['export_dpi']
# Set as default if checkbox is checked
if values['set_as_default']:
self.project.page_size_mm = (width_mm, height_mm)
print(f"Project default page size set to {width_mm}×{height_mm} mm")
self.update_view()
# Build status message
page_name = self.project.get_page_display_name(selected_page)
if selected_page.is_cover:
status_msg = f"{page_name} updated"
else:
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
if values['set_as_default']:
status_msg += " (set as default)"
self.show_status(status_msg, 2000)
@ribbon_action(
label="Toggle Spread",

View File

@ -4,6 +4,7 @@ PDF export functionality for pyPhotoAlbum
import os
from typing import List, Tuple, Optional
from dataclasses import dataclass
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
@ -12,6 +13,35 @@ import math
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
@dataclass
class RenderContext:
"""Parameters for rendering an image element"""
canvas: canvas.Canvas
image_element: ImageData
x_pt: float
y_pt: float
width_pt: float
height_pt: float
page_number: int
crop_left: float = 0.0
crop_right: float = 1.0
original_width_pt: Optional[float] = None
original_height_pt: Optional[float] = None
@dataclass
class SplitRenderParams:
"""Parameters for rendering a split element"""
canvas: canvas.Canvas
element: any
x_offset_mm: float
split_line_mm: float
page_width_pt: float
page_height_pt: float
page_number: int
side: str
class PDFExporter:
"""Handles PDF export of photo album projects"""
@ -210,8 +240,17 @@ class PDFExporter:
pass
else:
# Spanning element - render left portion
self._render_split_element(c, element, 0, center_mm, page_width_pt,
page_height_pt, page.page_number, 'left')
params = SplitRenderParams(
canvas=c,
element=element,
x_offset_mm=0,
split_line_mm=center_mm,
page_width_pt=page_width_pt,
page_height_pt=page_height_pt,
page_number=page.page_number,
side='left'
)
self._render_split_element(params)
c.showPage() # Finish left page
self.current_pdf_page += 1
@ -228,8 +267,17 @@ class PDFExporter:
page.page_number + 1)
elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
# Spanning element - render right portion
self._render_split_element(c, element, center_mm, center_mm, page_width_pt,
page_height_pt, page.page_number + 1, 'right')
params = SplitRenderParams(
canvas=c,
element=element,
x_offset_mm=center_mm,
split_line_mm=center_mm,
page_width_pt=page_width_pt,
page_height_pt=page_height_pt,
page_number=page.page_number + 1,
side='right'
)
self._render_split_element(params)
c.showPage() # Finish right page
self.current_pdf_page += 1
@ -272,81 +320,91 @@ class PDFExporter:
height_pt = element_height_mm * self.MM_TO_POINTS
if isinstance(element, ImageData):
self._render_image(c, element, x_pt, y_pt, width_pt, height_pt, page_number)
ctx = RenderContext(
canvas=c,
image_element=element,
x_pt=x_pt,
y_pt=y_pt,
width_pt=width_pt,
height_pt=height_pt,
page_number=page_number
)
self._render_image(ctx)
elif isinstance(element, TextBoxData):
self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)
def _render_split_element(self, c: canvas.Canvas, element, x_offset_mm: float,
split_line_mm: float, page_width_pt: float, page_height_pt: float,
page_number: int, side: str):
def _render_split_element(self, params: SplitRenderParams):
"""
Render a split element (only the portion on one side of the split line).
Args:
c: ReportLab canvas
element: The layout element to render
x_offset_mm: X offset in mm (0 for left, page_width for right)
split_line_mm: Position of split line in mm
page_width_pt: Page width in points
page_height_pt: Page height in points
page_number: Current page number
side: 'left' or 'right'
params: SplitRenderParams containing all rendering parameters
"""
# Skip placeholders
if isinstance(element, PlaceholderData):
if isinstance(params.element, PlaceholderData):
return
# Get element position and size in pixels
element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size
element_x_px, element_y_px = params.element.position
element_width_px, element_height_px = params.element.size
# Convert to mm
dpi = self.project.working_dpi
element_x_mm = element_x_px * 25.4 / dpi
element_y_mm = element_y_px * 25.4 / dpi
element_width_mm = element_width_px * 25.4 / dpi
element_height_mm = element_height_px * 25.4 / dpi
if isinstance(element, ImageData):
if isinstance(params.element, ImageData):
# Calculate which portion of the image to render
if side == 'left':
if params.side == 'left':
# Render from element start to split line
crop_width_mm = split_line_mm - element_x_mm
crop_width_mm = params.split_line_mm - element_x_mm
crop_x_start = 0
render_x_mm = element_x_mm
else: # right
# Render from split line to element end
crop_width_mm = (element_x_mm + element_width_mm) - split_line_mm
crop_x_start = split_line_mm - element_x_mm
render_x_mm = split_line_mm # Start at split line in spread coordinates
crop_width_mm = (element_x_mm + element_width_mm) - params.split_line_mm
crop_x_start = params.split_line_mm - element_x_mm
render_x_mm = params.split_line_mm # Start at split line in spread coordinates
# Adjust render position for offset
adjusted_x_mm = render_x_mm - x_offset_mm
adjusted_x_mm = render_x_mm - params.x_offset_mm
# Convert to points
x_pt = adjusted_x_mm * self.MM_TO_POINTS
y_pt = page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
y_pt = params.page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
width_pt = crop_width_mm * self.MM_TO_POINTS
height_pt = element_height_mm * self.MM_TO_POINTS
# Calculate original element dimensions in points (before splitting)
original_width_pt = element_width_mm * self.MM_TO_POINTS
original_height_pt = element_height_mm * self.MM_TO_POINTS
# Render cropped image with original dimensions for correct aspect ratio
self._render_image(c, element, x_pt, y_pt, width_pt, height_pt, page_number,
crop_left=crop_x_start / element_width_mm,
crop_right=(crop_x_start + crop_width_mm) / element_width_mm,
original_width_pt=original_width_pt,
original_height_pt=original_height_pt)
elif isinstance(element, TextBoxData):
ctx = RenderContext(
canvas=params.canvas,
image_element=params.element,
x_pt=x_pt,
y_pt=y_pt,
width_pt=width_pt,
height_pt=height_pt,
page_number=params.page_number,
crop_left=crop_x_start / element_width_mm,
crop_right=(crop_x_start + crop_width_mm) / element_width_mm,
original_width_pt=original_width_pt,
original_height_pt=original_height_pt
)
self._render_image(ctx)
elif isinstance(params.element, TextBoxData):
# For text boxes spanning the split, we'll render the whole text on the side
# where most of it appears (simpler than trying to split text)
element_center_mm = element_x_mm + element_width_mm / 2
if (side == 'left' and element_center_mm < split_line_mm) or \
(side == 'right' and element_center_mm >= split_line_mm):
self._render_element(c, element, x_offset_mm, page_width_pt, page_height_pt, page_number)
if (params.side == 'left' and element_center_mm < params.split_line_mm) or \
(params.side == 'right' and element_center_mm >= params.split_line_mm):
self._render_element(params.canvas, params.element, params.x_offset_mm,
params.page_width_pt, params.page_height_pt, params.page_number)
def _resolve_image_path(self, image_path: str) -> Optional[str]:
"""
@ -394,43 +452,33 @@ class PDFExporter:
return None
def _render_image(self, c: canvas.Canvas, image_element: 'ImageData', x_pt: float,
y_pt: float, width_pt: float, height_pt: float, page_number: int,
crop_left: float = 0.0, crop_right: float = 1.0,
original_width_pt: Optional[float] = None, original_height_pt: Optional[float] = None):
def _render_image(self, ctx: RenderContext):
"""
Render an image element on the PDF canvas.
Args:
c: ReportLab canvas
image_element: ImageData instance
x_pt, y_pt, width_pt, height_pt: Position and size in points (after cropping for split images)
page_number: Current page number (for warnings)
crop_left: Left crop position (0.0 to 1.0)
crop_right: Right crop position (0.0 to 1.0)
original_width_pt: Original element width in points (before splitting, for aspect ratio)
original_height_pt: Original element height in points (before splitting, for aspect ratio)
ctx: RenderContext containing all rendering parameters
"""
# Resolve image path (handles both absolute and relative paths)
image_full_path = self._resolve_image_path(image_element.image_path)
image_full_path = self._resolve_image_path(ctx.image_element.image_path)
# Check if image exists
if not image_full_path:
warning = f"Page {page_number}: Image not found: {image_element.image_path}"
warning = f"Page {ctx.page_number}: Image not found: {ctx.image_element.image_path}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
return
try:
# Load image using resolved path
img = Image.open(image_full_path)
img = img.convert('RGBA')
# Apply PIL-level rotation if needed (same logic as _on_async_image_loaded in models.py)
if hasattr(image_element, 'pil_rotation_90') and image_element.pil_rotation_90 > 0:
if hasattr(ctx.image_element, 'pil_rotation_90') and ctx.image_element.pil_rotation_90 > 0:
# Rotate counter-clockwise by 90° * pil_rotation_90
# PIL.Image.ROTATE_90 rotates counter-clockwise
angle = image_element.pil_rotation_90 * 90
angle = ctx.image_element.pil_rotation_90 * 90
if angle == 90:
img = img.transpose(Image.ROTATE_270) # CCW 90 = rotate right
elif angle == 180:
@ -439,11 +487,11 @@ class PDFExporter:
img = img.transpose(Image.ROTATE_90) # CCW 270 = rotate left
# Apply element's crop_info (from the element's own cropping)
crop_x_min, crop_y_min, crop_x_max, crop_y_max = image_element.crop_info
crop_x_min, crop_y_min, crop_x_max, crop_y_max = ctx.image_element.crop_info
# Combine with split cropping if applicable
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * crop_left
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * crop_right
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_left
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_right
# Calculate pixel crop coordinates
img_width, img_height = img.size
@ -452,10 +500,10 @@ class PDFExporter:
img_aspect = img_width / img_height
# Use original dimensions for aspect ratio if provided (for split images)
# This prevents stretching when splitting an image across pages
if original_width_pt is not None and original_height_pt is not None:
target_aspect = original_width_pt / original_height_pt
if ctx.original_width_pt is not None and ctx.original_height_pt is not None:
target_aspect = ctx.original_width_pt / ctx.original_height_pt
else:
target_aspect = width_pt / height_pt
target_aspect = ctx.width_pt / ctx.height_pt
if img_aspect > target_aspect:
# Image is wider - crop horizontally
@ -495,8 +543,8 @@ class PDFExporter:
# Downsample image to target resolution based on export DPI
# This prevents embedding huge images and reduces PDF file size
# Calculate target dimensions in pixels based on physical size and export DPI
target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
# Only downsample if current image is larger than target
# Don't upscale small images as that would reduce quality
@ -508,30 +556,30 @@ class PDFExporter:
# Note: Rotation is applied at the canvas level (below), not here
# to avoid double-rotation issues
# Save state for transformations
c.saveState()
ctx.canvas.saveState()
# Apply rotation to canvas if needed
if image_element.rotation != 0:
if ctx.image_element.rotation != 0:
# Move to element center
center_x = x_pt + width_pt / 2
center_y = y_pt + height_pt / 2
c.translate(center_x, center_y)
c.rotate(-image_element.rotation)
c.translate(-width_pt / 2, -height_pt / 2)
center_x = ctx.x_pt + ctx.width_pt / 2
center_y = ctx.y_pt + ctx.height_pt / 2
ctx.canvas.translate(center_x, center_y)
ctx.canvas.rotate(-ctx.image_element.rotation)
ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2)
# Draw at origin after transformation
c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt,
ctx.canvas.drawImage(ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt,
mask='auto', preserveAspectRatio=False)
else:
# Draw without rotation
c.drawImage(ImageReader(cropped_img), x_pt, y_pt, width_pt, height_pt,
ctx.canvas.drawImage(ImageReader(cropped_img), ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt,
mask='auto', preserveAspectRatio=False)
c.restoreState()
ctx.canvas.restoreState()
except Exception as e:
warning = f"Page {page_number}: Error rendering image {image_element.image_path}: {str(e)}"
warning = f"Page {ctx.page_number}: Error rendering image {ctx.image_element.image_path}: {str(e)}"
print(f"WARNING: {warning}")
self.warnings.append(warning)

View File

@ -12,14 +12,14 @@ class Guide:
"""Represents a snapping guide (vertical or horizontal line)"""
position: float # Position in mm
orientation: str # 'vertical' or 'horizontal'
def serialize(self) -> dict:
"""Serialize guide to dictionary"""
return {
"position": self.position,
"orientation": self.orientation
}
@staticmethod
def deserialize(data: dict) -> 'Guide':
"""Deserialize guide from dictionary"""
@ -29,6 +29,19 @@ class Guide:
)
@dataclass
class SnapResizeParams:
"""Parameters for snap resize operations"""
position: Tuple[float, float]
size: Tuple[float, float]
dx: float
dy: float
resize_handle: str
page_size: Tuple[float, float]
dpi: int = 300
project: Optional[any] = None
class SnappingSystem:
"""Manages snapping behavior for layout elements"""
@ -182,75 +195,60 @@ class SnappingSystem:
else:
return (x, y)
def snap_resize(self,
position: Tuple[float, float],
size: Tuple[float, float],
dx: float,
dy: float,
resize_handle: str,
page_size: Tuple[float, float],
dpi: int = 300,
project=None) -> Tuple[Tuple[float, float], Tuple[float, float]]:
def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""
Apply snapping during resize operations
Args:
position: Current position (x, y) in pixels
size: Current size (width, height) in pixels
dx: Delta x movement in pixels
dy: Delta y movement in pixels
resize_handle: Which handle is being dragged ('nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w')
page_size: Page size (width, height) in mm
dpi: DPI for conversion
project: Optional project for global snapping settings
params: SnapResizeParams containing all resize parameters
Returns:
Tuple of (snapped_position, snapped_size) in pixels
"""
x, y = position
width, height = size
page_width_mm, page_height_mm = page_size
x, y = params.position
width, height = params.size
page_width_mm, page_height_mm = params.page_size
# Use project settings if available, otherwise use local settings
if project:
snap_threshold_mm = project.snap_threshold_mm
if params.project:
snap_threshold_mm = params.project.snap_threshold_mm
else:
snap_threshold_mm = self.snap_threshold_mm
# Convert threshold from mm to pixels
snap_threshold_px = snap_threshold_mm * dpi / 25.4
snap_threshold_px = snap_threshold_mm * params.dpi / 25.4
# Calculate new position and size based on resize handle
new_x, new_y = x, y
new_width, new_height = width, height
# Apply resize based on handle
if resize_handle in ['nw', 'n', 'ne']:
if params.resize_handle in ['nw', 'n', 'ne']:
# Top edge moving
new_y = y + dy
new_height = height - dy
if resize_handle in ['sw', 's', 'se']:
new_y = y + params.dy
new_height = height - params.dy
if params.resize_handle in ['sw', 's', 'se']:
# Bottom edge moving
new_height = height + dy
if resize_handle in ['nw', 'w', 'sw']:
new_height = height + params.dy
if params.resize_handle in ['nw', 'w', 'sw']:
# Left edge moving
new_x = x + dx
new_width = width - dx
if resize_handle in ['ne', 'e', 'se']:
new_x = x + params.dx
new_width = width - params.dx
if params.resize_handle in ['ne', 'e', 'se']:
# Right edge moving
new_width = width + dx
new_width = width + params.dx
# Now apply snapping to the edges that are being moved
# Use _snap_edge_to_targets consistently for all edges
# Snap left edge (for nw, w, sw handles)
if resize_handle in ['nw', 'w', 'sw']:
if params.resize_handle in ['nw', 'w', 'sw']:
# Try to snap the left edge
snapped_left = self._snap_edge_to_targets(
new_x, page_width_mm, dpi, snap_threshold_px, 'vertical', project
new_x, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project
)
if snapped_left is not None:
# Adjust width to compensate for position change
@ -259,21 +257,21 @@ class SnappingSystem:
new_width += width_adjustment
# Snap right edge (for ne, e, se handles)
if resize_handle in ['ne', 'e', 'se']:
if params.resize_handle in ['ne', 'e', 'se']:
# Calculate right edge position
right_edge = new_x + new_width
# Try to snap the right edge
snapped_right = self._snap_edge_to_targets(
right_edge, page_width_mm, dpi, snap_threshold_px, 'vertical', project
right_edge, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project
)
if snapped_right is not None:
new_width = snapped_right - new_x
# Snap top edge (for nw, n, ne handles)
if resize_handle in ['nw', 'n', 'ne']:
if params.resize_handle in ['nw', 'n', 'ne']:
# Try to snap the top edge
snapped_top = self._snap_edge_to_targets(
new_y, page_height_mm, dpi, snap_threshold_px, 'horizontal', project
new_y, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project
)
if snapped_top is not None:
# Adjust height to compensate for position change
@ -282,12 +280,12 @@ class SnappingSystem:
new_height += height_adjustment
# Snap bottom edge (for sw, s, se handles)
if resize_handle in ['sw', 's', 'se']:
if params.resize_handle in ['sw', 's', 'se']:
# Calculate bottom edge position
bottom_edge = new_y + new_height
# Try to snap the bottom edge
snapped_bottom = self._snap_edge_to_targets(
bottom_edge, page_height_mm, dpi, snap_threshold_px, 'horizontal', project
bottom_edge, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project
)
if snapped_bottom is not None:
new_height = snapped_bottom - new_y

View File

@ -103,3 +103,192 @@ def populated_page_layout(sample_image_data, sample_placeholder_data, sample_tex
layout.add_element(sample_placeholder_data)
layout.add_element(sample_textbox_data)
return layout
# GL Widget fixtures (moved from test_gl_widget_fixtures.py)
from unittest.mock import Mock, MagicMock
from PyQt6.QtCore import Qt, QPointF, QPoint
from PyQt6.QtGui import QMouseEvent, QWheelEvent
@pytest.fixture
def mock_main_window():
"""Create a mock main window with a basic project"""
window = Mock()
window.project = Project(name="Test Project")
# Add a test page
page = Page(
layout=PageLayout(width=210, height=297), # A4 size in mm
page_number=1
)
window.project.pages.append(page)
window.project.working_dpi = 96
window.project.page_size_mm = (210, 297)
window.project.page_spacing_mm = 10
# Mock status bar
window.status_bar = Mock()
window.status_bar.showMessage = Mock()
window.show_status = Mock()
return window
@pytest.fixture
def sample_image_element():
"""Create a sample ImageData element for testing"""
return ImageData(
image_path="test.jpg",
x=100,
y=100,
width=200,
height=150,
z_index=1
)
@pytest.fixture
def sample_placeholder_element():
"""Create a sample PlaceholderData element for testing"""
return PlaceholderData(
x=50,
y=50,
width=100,
height=100,
z_index=0
)
@pytest.fixture
def sample_textbox_element():
"""Create a sample TextBoxData element for testing"""
return TextBoxData(
x=10,
y=10,
width=180,
height=50,
text_content="Test Text",
z_index=2
)
@pytest.fixture
def mock_page_renderer():
"""Create a mock PageRenderer
NOTE: This fixture contains simplified coordinate conversion logic for testing.
It is NOT a replacement for testing with the real PageRenderer in integration tests.
"""
renderer = Mock()
renderer.screen_x = 50
renderer.screen_y = 50
renderer.zoom = 1.0
renderer.dpi = 96
# Mock coordinate conversion methods
def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom,
renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom,
(y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page
renderer.is_point_in_page = is_point_in_page
return renderer
@pytest.fixture
def create_mouse_event():
"""Factory fixture for creating QMouseEvent objects"""
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton,
modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QMouseEvent for testing
Args:
event_type: QEvent.Type (MouseButtonPress, MouseButtonRelease, MouseMove)
x, y: Position coordinates
button: Mouse button
modifiers: Keyboard modifiers
"""
pos = QPointF(x, y)
return QMouseEvent(
event_type,
pos,
button,
button,
modifiers
)
return _create_event
@pytest.fixture
def create_wheel_event():
"""Factory fixture for creating QWheelEvent objects"""
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QWheelEvent for testing
Args:
x, y: Position coordinates
delta_y: Wheel delta (positive = scroll up, negative = scroll down)
modifiers: Keyboard modifiers (e.g., ControlModifier for zoom)
"""
pos = QPointF(x, y)
global_pos = QPoint(int(x), int(y))
angle_delta = QPoint(0, delta_y)
return QWheelEvent(
pos,
global_pos,
QPoint(0, 0),
angle_delta,
Qt.MouseButton.NoButton,
modifiers,
Qt.ScrollPhase.NoScrollPhase,
False
)
return _create_event
@pytest.fixture
def populated_page():
"""Create a page with multiple elements for testing"""
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
# Add various elements
page.layout.add_element(ImageData(
image_path="img1.jpg",
x=10, y=10,
width=100, height=75,
z_index=0
))
page.layout.add_element(PlaceholderData(
x=120, y=10,
width=80, height=60,
z_index=1
))
page.layout.add_element(TextBoxData(
x=10, y=100,
width=190, height=40,
text_content="Sample Text",
z_index=2
))
return page

View File

@ -347,3 +347,254 @@ class TestDropEvent:
# 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

View File

@ -1,134 +0,0 @@
#!/usr/bin/env python
"""
Test to verify async loading doesn't block the main thread.
This test demonstrates that the UI remains responsive during image loading.
"""
import time
import sys
from pathlib import Path
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import QTimer
from pyPhotoAlbum.async_backend import AsyncImageLoader, ImageCache, LoadPriority
def test_nonblocking_load():
"""Test that async image loading doesn't block the main thread"""
print("Testing non-blocking async image loading...")
# Track if main thread stays responsive
main_thread_ticks = []
def main_thread_tick():
"""This should continue running during async loads"""
main_thread_ticks.append(time.time())
print(f"✓ Main thread tick {len(main_thread_ticks)} (responsive!)")
# Create Qt application
app = QApplication(sys.argv)
# Create async loader
cache = ImageCache(max_memory_mb=128)
loader = AsyncImageLoader(cache=cache, max_workers=2)
# Track loaded images
loaded_images = []
def on_image_loaded(path, image, user_data):
loaded_images.append(path)
print(f"✓ Loaded: {path} (size: {image.size})")
def on_load_failed(path, error_msg, user_data):
print(f"✗ Failed: {path} - {error_msg}")
loader.image_loaded.connect(on_image_loaded)
loader.load_failed.connect(on_load_failed)
# Start the async loader
loader.start()
print("✓ Async loader started")
# Request some image loads (these would normally block for 50-500ms each)
test_images = [
Path("assets/sample1.jpg"),
Path("assets/sample2.jpg"),
Path("assets/sample3.jpg"),
]
print(f"\nRequesting {len(test_images)} image loads...")
for img_path in test_images:
loader.request_load(img_path, priority=LoadPriority.HIGH)
print(f" → Queued: {img_path}")
print("\nMain thread should remain responsive while images load in background...")
# Setup main thread ticker (should run continuously)
ticker = QTimer()
ticker.timeout.connect(main_thread_tick)
ticker.start(100) # Tick every 100ms
# Setup test timeout
def check_completion():
elapsed = time.time() - start_time
if len(loaded_images) >= len(test_images):
print(f"\n✓ All images loaded in {elapsed:.2f}s")
print(f"✓ Main thread ticked {len(main_thread_ticks)} times during loading")
if len(main_thread_ticks) >= 3:
print("✓ SUCCESS: Main thread remained responsive!")
else:
print("✗ FAIL: Main thread was blocked!")
# Cleanup
ticker.stop()
loader.stop()
app.quit()
elif elapsed > 10.0:
print(f"\n✗ Timeout: Only loaded {len(loaded_images)}/{len(test_images)} images")
ticker.stop()
loader.stop()
app.quit()
# Check completion every 200ms
completion_timer = QTimer()
completion_timer.timeout.connect(check_completion)
completion_timer.start(200)
start_time = time.time()
# Run Qt event loop (this should NOT block)
app.exec()
print("\nTest completed!")
# Report results
print(f"\nResults:")
print(f" Images loaded: {len(loaded_images)}/{len(test_images)}")
print(f" Main thread ticks: {len(main_thread_ticks)}")
print(f" Cache stats: {cache.get_stats()}")
return len(main_thread_ticks) >= 3 # Success if main thread ticked at least 3 times
if __name__ == "__main__":
print("=" * 60)
print("Async Non-Blocking Test")
print("=" * 60)
print()
success = test_nonblocking_load()
print()
print("=" * 60)
if success:
print("✓ TEST PASSED: Async loading is non-blocking")
else:
print("✗ TEST FAILED: Main thread was blocked")
print("=" * 60)
sys.exit(0 if success else 1)

View File

@ -666,3 +666,173 @@ class TestCommandHistory:
undo_count += 1
assert undo_count == 3
def test_history_serialize_deserialize_add_element(self):
"""Test serializing and deserializing history with AddElementCommand"""
history = CommandHistory()
layout = PageLayout(width=210, height=297)
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = AddElementCommand(layout, element)
history.execute(cmd)
# Serialize
data = history.serialize()
assert len(data['undo_stack']) == 1
assert data['undo_stack'][0]['type'] == 'add_element'
# Create mock project for deserialization
mock_project = Mock()
mock_project.pages = [Mock(layout=layout)]
# Deserialize
new_history = CommandHistory()
new_history.deserialize(data, mock_project)
assert len(new_history.undo_stack) == 1
assert len(new_history.redo_stack) == 0
def test_history_serialize_deserialize_all_command_types(self):
"""Test serializing and deserializing all command types through history"""
history = CommandHistory()
layout = PageLayout(width=210, height=297)
# Create elements and add them to layout first
img = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
txt = TextBoxData(text_content="Test", x=50, y=50, width=100, height=50)
img2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=100, height=100)
# Build commands - serialize each type without executing them
# (we only care about serialization/deserialization, not execution)
cmd1 = AddElementCommand(layout, img)
cmd1.serialize() # Ensure it can serialize
cmd2 = DeleteElementCommand(layout, txt)
cmd2.serialize()
cmd3 = MoveElementCommand(img, (100, 100), (150, 150))
cmd3.serialize()
cmd4 = ResizeElementCommand(img, (100, 100), (200, 150), (120, 120), (180, 130))
cmd4.serialize()
cmd5 = RotateElementCommand(img, 0, 90)
cmd5.serialize()
cmd6 = AdjustImageCropCommand(img, (0, 0, 1, 1), (0.1, 0.1, 0.9, 0.9))
cmd6.serialize()
cmd7 = AlignElementsCommand([(img, (100, 100))])
cmd7.serialize()
cmd8 = ResizeElementsCommand([(img, (100, 100), (200, 150))])
cmd8.serialize()
layout.add_element(img2)
cmd9 = ChangeZOrderCommand(layout, img2, 0, 0)
cmd9.serialize()
# Manually build serialized history data
data = {
'undo_stack': [
cmd1.serialize(),
cmd2.serialize(),
cmd3.serialize(),
cmd4.serialize(),
cmd5.serialize(),
cmd6.serialize(),
cmd7.serialize(),
cmd8.serialize(),
cmd9.serialize(),
],
'redo_stack': [],
'max_history': 100
}
# Create mock project
mock_project = Mock()
mock_project.pages = [Mock(layout=layout)]
# Deserialize
new_history = CommandHistory()
new_history.deserialize(data, mock_project)
assert len(new_history.undo_stack) == 9
assert new_history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
assert new_history.undo_stack[1].__class__.__name__ == 'DeleteElementCommand'
assert new_history.undo_stack[2].__class__.__name__ == 'MoveElementCommand'
assert new_history.undo_stack[3].__class__.__name__ == 'ResizeElementCommand'
assert new_history.undo_stack[4].__class__.__name__ == 'RotateElementCommand'
assert new_history.undo_stack[5].__class__.__name__ == 'AdjustImageCropCommand'
assert new_history.undo_stack[6].__class__.__name__ == 'AlignElementsCommand'
assert new_history.undo_stack[7].__class__.__name__ == 'ResizeElementsCommand'
assert new_history.undo_stack[8].__class__.__name__ == 'ChangeZOrderCommand'
def test_history_deserialize_unknown_command_type(self):
"""Test deserializing unknown command type returns None and continues"""
history = CommandHistory()
mock_project = Mock()
data = {
'undo_stack': [
{'type': 'unknown_command', 'data': 'test'},
{'type': 'add_element', 'element': ImageData().serialize(), 'executed': True}
],
'redo_stack': [],
'max_history': 100
}
# Should not raise exception, just skip unknown command
history.deserialize(data, mock_project)
# Should only have the valid command
assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
def test_history_deserialize_malformed_command(self):
"""Test deserializing malformed command handles exception gracefully"""
history = CommandHistory()
mock_project = Mock()
data = {
'undo_stack': [
{'type': 'add_element'}, # Missing required 'element' field
{'type': 'move_element', 'element': ImageData().serialize(),
'old_position': (0, 0), 'new_position': (10, 10)}
],
'redo_stack': [],
'max_history': 100
}
# Should not raise exception, just skip malformed command
history.deserialize(data, mock_project)
# Should only have the valid command
assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'MoveElementCommand'
def test_history_serialize_deserialize_with_redo_stack(self):
"""Test serializing and deserializing with items in redo stack"""
history = CommandHistory()
layout = PageLayout(width=210, height=297)
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd1 = AddElementCommand(layout, element)
cmd2 = MoveElementCommand(element, (100, 100), (150, 150))
history.execute(cmd1)
history.execute(cmd2)
history.undo() # Move cmd2 to redo stack
# Serialize
data = history.serialize()
assert len(data['undo_stack']) == 1
assert len(data['redo_stack']) == 1
# Deserialize
mock_project = Mock()
new_history = CommandHistory()
new_history.deserialize(data, mock_project)
assert len(new_history.undo_stack) == 1
assert len(new_history.redo_stack) == 1

View File

@ -1,50 +0,0 @@
#!/usr/bin/env python3
"""
Test to demonstrate the asset drop bug
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.models import ImageData
def test_direct_path_assignment():
"""Simulate what happens when you drop an image on existing element"""
project = Project("Test Direct Path")
page = Page()
project.add_page(page)
# Add an image element
img = ImageData()
img.position = (10, 10)
img.size = (50, 50)
page.layout.add_element(img)
# Simulate dropping a new image on existing element (line 77 in asset_drop.py)
external_image = "/home/dtourolle/Pictures/some_photo.jpg"
print(f"\nSimulating drop on existing image element...")
print(f"Setting image_path directly to: {external_image}")
img.image_path = external_image # BUG: Not imported!
# Check assets folder
assets = os.listdir(project.asset_manager.assets_folder) if os.path.exists(project.asset_manager.assets_folder) else []
print(f"\nAssets in folder: {len(assets)}")
print(f" {assets if assets else '(empty)'}")
# The image path in the element points to external file
print(f"\nImage path in element: {img.image_path}")
print(f" Is absolute path: {os.path.isabs(img.image_path)}")
if os.path.isabs(img.image_path):
print("\n❌ BUG CONFIRMED: Image path is absolute, not copied to assets!")
print(" When saved to .ppz, this external file will NOT be included.")
return False
return True
if __name__ == "__main__":
test_direct_path_assignment()

View File

@ -204,9 +204,11 @@ class TestResizeElementWithSnap:
# Verify snap_resize was called
assert mock_snap_sys.snap_resize.called
call_args = mock_snap_sys.snap_resize.call_args
assert call_args[1]['dx'] == 50
assert call_args[1]['dy'] == 30
assert call_args[1]['resize_handle'] == 'se'
# snap_resize is called with a SnapResizeParams object as first positional arg
params = call_args[0][0]
assert params.dx == 50
assert params.dy == 30
assert params.resize_handle == 'se'
# Verify element was updated
assert elem.size == (250, 180)

View File

@ -0,0 +1,375 @@
"""
Unit tests for ElementMaximizer class.
Tests each atomic method independently for better test coverage and debugging.
"""
import pytest
from unittest.mock import Mock
from pyPhotoAlbum.alignment import ElementMaximizer
from pyPhotoAlbum.models import BaseLayoutElement
class TestElementMaximizer:
"""Test suite for ElementMaximizer class."""
@pytest.fixture
def mock_element(self):
"""Create a mock element for testing."""
elem = Mock(spec=BaseLayoutElement)
elem.position = (10.0, 10.0)
elem.size = (50.0, 50.0)
return elem
@pytest.fixture
def simple_elements(self):
"""Create a simple list of mock elements."""
elem1 = Mock(spec=BaseLayoutElement)
elem1.position = (10.0, 10.0)
elem1.size = (50.0, 50.0)
elem2 = Mock(spec=BaseLayoutElement)
elem2.position = (70.0, 10.0)
elem2.size = (50.0, 50.0)
return [elem1, elem2]
@pytest.fixture
def maximizer(self, simple_elements):
"""Create an ElementMaximizer instance with simple elements."""
page_size = (200.0, 200.0)
min_gap = 5.0
return ElementMaximizer(simple_elements, page_size, min_gap)
def test_init_records_initial_states(self, simple_elements):
"""Test that __init__ records initial states correctly."""
page_size = (200.0, 200.0)
min_gap = 5.0
maximizer = ElementMaximizer(simple_elements, page_size, min_gap)
assert len(maximizer.changes) == 2
assert maximizer.changes[0][0] is simple_elements[0]
assert maximizer.changes[0][1] == (10.0, 10.0) # position
assert maximizer.changes[0][2] == (50.0, 50.0) # size
def test_check_collision_with_left_boundary(self, maximizer):
"""Test collision detection with left page boundary."""
# Position element too close to left edge
maximizer.elements[0].position = (2.0, 10.0)
new_size = (50.0, 50.0)
assert maximizer.check_collision(0, new_size) is True
def test_check_collision_with_top_boundary(self, maximizer):
"""Test collision detection with top page boundary."""
# Position element too close to top edge
maximizer.elements[0].position = (10.0, 2.0)
new_size = (50.0, 50.0)
assert maximizer.check_collision(0, new_size) is True
def test_check_collision_with_right_boundary(self, maximizer):
"""Test collision detection with right page boundary."""
# Element would extend beyond right boundary
maximizer.elements[0].position = (150.0, 10.0)
new_size = (50.0, 50.0) # 150 + 50 = 200, needs min_gap
assert maximizer.check_collision(0, new_size) is True
def test_check_collision_with_bottom_boundary(self, maximizer):
"""Test collision detection with bottom page boundary."""
# Element would extend beyond bottom boundary
maximizer.elements[0].position = (10.0, 150.0)
new_size = (50.0, 50.0) # 150 + 50 = 200, needs min_gap
assert maximizer.check_collision(0, new_size) is True
def test_check_collision_with_other_element(self, maximizer):
"""Test collision detection with other elements."""
# Make elem1 grow into elem2's space
new_size = (65.0, 50.0) # Would overlap with elem2 at x=70
assert maximizer.check_collision(0, new_size) is True
def test_check_collision_no_collision(self, maximizer):
"""Test that valid sizes don't trigger collision."""
# Element has plenty of space
new_size = (55.0, 55.0)
assert maximizer.check_collision(0, new_size) is False
def test_find_max_scale_basic(self, maximizer):
"""Test binary search finds maximum scale factor."""
current_scale = 1.0
max_scale = maximizer.find_max_scale(0, current_scale)
# Should find a scale larger than 1.0 since there's room to grow
assert max_scale > current_scale
def test_find_max_scale_constrained_by_boundary(self):
"""Test scaling is constrained by page boundaries."""
elem = Mock(spec=BaseLayoutElement)
elem.position = (10.0, 10.0)
elem.size = (80.0, 80.0)
maximizer = ElementMaximizer([elem], (100.0, 100.0), 5.0)
current_scale = 1.0
max_scale = maximizer.find_max_scale(0, current_scale)
# Element at (10,10) with size (80,80) reaches (90,90)
# With min_gap=5, max is (95,95), so max_scale should be around 1.0625
assert 1.0 < max_scale < 1.1
def test_find_max_scale_constrained_by_element(self, maximizer):
"""Test scaling is constrained by nearby elements."""
# Elements are close together, limited growth
current_scale = 1.0
max_scale = maximizer.find_max_scale(0, current_scale)
# There's a gap of 10mm between elements (70-60), with min_gap=5
# So limited growth is possible
assert max_scale > 1.0
assert max_scale < 1.2 # Won't grow too much
def test_grow_iteration_with_space(self, maximizer):
"""Test grow_iteration when elements have space to grow."""
scales = [1.0, 1.0]
growth_rate = 0.05
result = maximizer.grow_iteration(scales, growth_rate)
assert result is True # Some growth occurred
assert scales[0] > 1.0
assert scales[1] > 1.0
def test_grow_iteration_no_space(self):
"""Test grow_iteration when elements have no space to grow."""
# Create elements that fill the entire page
elem1 = Mock(spec=BaseLayoutElement)
elem1.position = (5.0, 5.0)
elem1.size = (190.0, 190.0)
maximizer = ElementMaximizer([elem1], (200.0, 200.0), 5.0)
scales = [1.0]
growth_rate = 0.05
result = maximizer.grow_iteration(scales, growth_rate)
# Should return False since no growth is possible
assert result is False
assert scales[0] == 1.0
def test_check_element_collision_with_overlap(self, maximizer):
"""Test element collision detection with overlap."""
elem = maximizer.elements[0]
new_pos = (65.0, 10.0) # Would overlap with elem2 at (70, 10)
assert maximizer.check_element_collision(elem, new_pos) is True
def test_check_element_collision_no_overlap(self, maximizer):
"""Test element collision detection without overlap."""
elem = maximizer.elements[0]
new_pos = (15.0, 15.0) # Safe position
assert maximizer.check_element_collision(elem, new_pos) is False
def test_center_element_horizontally_centering(self):
"""Test horizontal centering when space is available."""
elem = Mock(spec=BaseLayoutElement)
elem.position = (20.0, 50.0) # Off-center
elem.size = (50.0, 50.0)
maximizer = ElementMaximizer([elem], (200.0, 200.0), 5.0)
maximizer.center_element_horizontally(elem)
# Element should move towards center
# space_left = 20 - 5 = 15
# space_right = (200 - 5) - (20 + 50) = 125
# adjust_x = (125 - 15) / 4 = 27.5
# new_x should be around 47.5
new_x = elem.position[0]
assert new_x > 20.0 # Moved right towards center
def test_center_element_horizontally_already_centered(self):
"""Test horizontal centering when already centered."""
elem = Mock(spec=BaseLayoutElement)
# Centered position: (200 - 50) / 2 = 75
elem.position = (75.0, 50.0)
elem.size = (50.0, 50.0)
maximizer = ElementMaximizer([elem], (200.0, 200.0), 5.0)
original_x = elem.position[0]
maximizer.center_element_horizontally(elem)
# Should stay approximately the same
assert abs(elem.position[0] - original_x) < 1.0
def test_center_element_vertically_centering(self):
"""Test vertical centering when space is available."""
elem = Mock(spec=BaseLayoutElement)
elem.position = (50.0, 20.0) # Off-center vertically
elem.size = (50.0, 50.0)
maximizer = ElementMaximizer([elem], (200.0, 200.0), 5.0)
maximizer.center_element_vertically(elem)
# Element should move towards vertical center
new_y = elem.position[1]
assert new_y > 20.0 # Moved down towards center
def test_center_element_vertically_already_centered(self):
"""Test vertical centering when already centered."""
elem = Mock(spec=BaseLayoutElement)
# Centered position: (200 - 50) / 2 = 75
elem.position = (50.0, 75.0)
elem.size = (50.0, 50.0)
maximizer = ElementMaximizer([elem], (200.0, 200.0), 5.0)
original_y = elem.position[1]
maximizer.center_element_vertically(elem)
# Should stay approximately the same
assert abs(elem.position[1] - original_y) < 1.0
def test_center_elements_calls_both_directions(self, maximizer):
"""Test that center_elements processes both horizontal and vertical."""
initial_positions = [elem.position for elem in maximizer.elements]
maximizer.center_elements()
# At least some elements should potentially move
# (or stay same if already centered)
assert len(maximizer.elements) == 2
def test_maximize_integration(self, maximizer):
"""Test the full maximize method integration."""
initial_sizes = [elem.size for elem in maximizer.elements]
changes = maximizer.maximize(max_iterations=50, growth_rate=0.05)
# Should return changes for undo
assert len(changes) == 2
assert changes[0][1] == (10.0, 10.0) # old position
assert changes[0][2] == (50.0, 50.0) # old size
# Elements should have grown
final_sizes = [elem.size for elem in maximizer.elements]
assert final_sizes[0][0] >= initial_sizes[0][0]
assert final_sizes[0][1] >= initial_sizes[0][1]
def test_maximize_empty_elements(self):
"""Test maximize with empty element list."""
from pyPhotoAlbum.alignment import AlignmentManager
result = AlignmentManager.maximize_pattern([], (200.0, 200.0))
assert result == []
def test_maximize_single_element_grows_to_fill_page(self):
"""Test that a single element grows to fill the available page."""
elem = Mock(spec=BaseLayoutElement)
elem.position = (50.0, 50.0)
elem.size = (10.0, 10.0) # Small initial size
maximizer = ElementMaximizer([elem], (200.0, 200.0), 5.0)
maximizer.maximize(max_iterations=100, growth_rate=0.1)
# Element should grow significantly
final_width, final_height = elem.size
assert final_width > 50.0 # Much larger than initial 10.0
assert final_height > 50.0
class TestElementMaximizerEdgeCases:
"""Test edge cases and boundary conditions."""
def test_zero_min_gap(self):
"""Test with zero minimum gap."""
elem = Mock(spec=BaseLayoutElement)
elem.position = (0.0, 0.0)
elem.size = (100.0, 100.0)
maximizer = ElementMaximizer([elem], (100.0, 100.0), 0.0)
# Should not collide with boundaries at exact edges
assert maximizer.check_collision(0, (100.0, 100.0)) is False
def test_very_large_min_gap(self):
"""Test with very large minimum gap."""
elem = Mock(spec=BaseLayoutElement)
elem.position = (50.0, 50.0)
elem.size = (10.0, 10.0)
maximizer = ElementMaximizer([elem], (200.0, 200.0), 50.0)
# Element at (50,50) with size (10,10) is OK since:
# - left edge at 50 > min_gap (50)
# - top edge at 50 > min_gap (50)
# - right edge at 60 < page_width (200) - min_gap (50) = 150
# Current size should NOT collide
assert maximizer.check_collision(0, (10.0, 10.0)) is False
# But if we try to position too close to an edge, it should collide
elem.position = (40.0, 50.0) # Left edge at 40 < min_gap
assert maximizer.check_collision(0, (10.0, 10.0)) is True
def test_elements_touching_with_exact_min_gap(self):
"""Test elements that are exactly min_gap apart."""
elem1 = Mock(spec=BaseLayoutElement)
elem1.position = (10.0, 10.0)
elem1.size = (50.0, 50.0)
elem2 = Mock(spec=BaseLayoutElement)
elem2.position = (65.0, 10.0) # Exactly 5mm gap (60 + 5 = 65)
elem2.size = (50.0, 50.0)
maximizer = ElementMaximizer([elem1, elem2], (200.0, 200.0), 5.0)
# Should not grow since they're at minimum gap
result = maximizer.check_collision(0, (50.0, 50.0))
assert result is False # Current size is OK
# But slightly larger would collide
result = maximizer.check_collision(0, (51.0, 50.0))
assert result is True
def test_find_max_scale_tolerance(self):
"""Test that binary search respects tolerance parameter."""
elem = Mock(spec=BaseLayoutElement)
elem.position = (10.0, 10.0)
elem.size = (50.0, 50.0)
maximizer = ElementMaximizer([elem], (200.0, 200.0), 5.0)
# Test with different tolerances
scale_loose = maximizer.find_max_scale(0, 1.0, tolerance=0.1)
scale_tight = maximizer.find_max_scale(0, 1.0, tolerance=0.0001)
# Tighter tolerance might find slightly different result
# Both should be greater than 1.0
assert scale_loose > 1.0
assert scale_tight > 1.0
assert abs(scale_loose - scale_tight) < 0.15 # Should be similar
def test_grow_iteration_alternating_growth(self):
"""Test that elements can alternate growth in tight spaces."""
# Create two elements side by side with limited space
elem1 = Mock(spec=BaseLayoutElement)
elem1.position = (10.0, 10.0)
elem1.size = (40.0, 40.0)
elem2 = Mock(spec=BaseLayoutElement)
elem2.position = (60.0, 10.0)
elem2.size = (40.0, 40.0)
maximizer = ElementMaximizer([elem1, elem2], (200.0, 100.0), 5.0)
scales = [1.0, 1.0]
# First iteration should allow growth
result1 = maximizer.grow_iteration(scales, 0.05)
assert result1 is True
# Continue growing until no more growth
for _ in range(50):
if not maximizer.grow_iteration(scales, 0.05):
break
# Both should have grown
assert scales[0] > 1.0
assert scales[1] > 1.0

View File

@ -1,190 +0,0 @@
"""
Shared fixtures for GLWidget mixin tests
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtCore import Qt, QPointF
from PyQt6.QtGui import QMouseEvent, QWheelEvent
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
@pytest.fixture
def mock_main_window():
"""Create a mock main window with a basic project"""
window = Mock()
window.project = Project(name="Test Project")
# Add a test page
page = Page(
layout=PageLayout(width=210, height=297), # A4 size in mm
page_number=1
)
window.project.pages.append(page)
window.project.working_dpi = 96
window.project.page_size_mm = (210, 297)
window.project.page_spacing_mm = 10
# Mock status bar
window.status_bar = Mock()
window.status_bar.showMessage = Mock()
window.show_status = Mock()
return window
@pytest.fixture
def sample_image_element():
"""Create a sample ImageData element for testing"""
return ImageData(
image_path="test.jpg",
x=100,
y=100,
width=200,
height=150,
z_index=1
)
@pytest.fixture
def sample_placeholder_element():
"""Create a sample PlaceholderData element for testing"""
return PlaceholderData(
x=50,
y=50,
width=100,
height=100,
z_index=0
)
@pytest.fixture
def sample_textbox_element():
"""Create a sample TextBoxData element for testing"""
return TextBoxData(
x=10,
y=10,
width=180,
height=50,
text_content="Test Text",
z_index=2
)
@pytest.fixture
def mock_page_renderer():
"""Create a mock PageRenderer"""
renderer = Mock()
renderer.screen_x = 50
renderer.screen_y = 50
renderer.zoom = 1.0
renderer.dpi = 96
# Mock coordinate conversion methods
def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom,
renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom,
(y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page
renderer.is_point_in_page = is_point_in_page
return renderer
@pytest.fixture
def create_mouse_event():
"""Factory fixture for creating QMouseEvent objects"""
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton,
modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QMouseEvent for testing
Args:
event_type: QEvent.Type (MouseButtonPress, MouseButtonRelease, MouseMove)
x, y: Position coordinates
button: Mouse button
modifiers: Keyboard modifiers
"""
pos = QPointF(x, y)
return QMouseEvent(
event_type,
pos,
button,
button,
modifiers
)
return _create_event
@pytest.fixture
def create_wheel_event():
"""Factory fixture for creating QWheelEvent objects"""
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QWheelEvent for testing
Args:
x, y: Position coordinates
delta_y: Wheel delta (positive = scroll up, negative = scroll down)
modifiers: Keyboard modifiers (e.g., ControlModifier for zoom)
"""
from PyQt6.QtCore import QPoint
pos = QPointF(x, y)
global_pos = QPoint(int(x), int(y))
angle_delta = QPoint(0, delta_y)
return QWheelEvent(
pos,
global_pos,
QPoint(0, 0),
angle_delta,
Qt.MouseButton.NoButton,
modifiers,
Qt.ScrollPhase.NoScrollPhase,
False
)
return _create_event
@pytest.fixture
def populated_page():
"""Create a page with multiple elements for testing"""
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
# Add various elements
page.layout.add_element(ImageData(
image_path="img1.jpg",
x=10, y=10,
width=100, height=75,
z_index=0
))
page.layout.add_element(PlaceholderData(
x=120, y=10,
width=80, height=60,
z_index=1
))
page.layout.add_element(TextBoxData(
x=10, y=100,
width=190, height=40,
text_content="Sample Text",
z_index=2
))
return page

View File

@ -124,15 +124,15 @@ class TestGLWidgetMixinIntegration:
# Begin operation (should be tracked for undo)
widget._begin_move(element)
assert widget._interaction_element is not None
assert widget._interaction_type == 'move'
assert widget._interaction_start_pos == (100, 100)
assert widget._interaction_state.element is not None
assert widget._interaction_state.interaction_type == 'move'
assert widget._interaction_state.position == (100, 100)
# End operation
widget._end_interaction()
# Interaction state should be cleared after operation
assert widget._interaction_element is None
assert widget._interaction_type is None
assert widget._interaction_state.element is None
assert widget._interaction_state.interaction_type is None
class TestGLWidgetKeyEvents:

View File

@ -1,155 +0,0 @@
#!/usr/bin/env python3
"""
Test to verify the heal function can fix old files with missing assets
"""
import os
import sys
import tempfile
import shutil
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
def test_heal_external_paths():
"""Test healing a project with external (absolute) image paths"""
print("=" * 70)
print("Test: Healing External Image Paths")
print("=" * 70)
# Create a test image in a temp location
with tempfile.TemporaryDirectory() as temp_dir:
# Create a test image file
test_image_source = "./projects/project_with_image/assets/test_image.jpg"
external_image_path = os.path.join(temp_dir, "external_photo.jpg")
shutil.copy2(test_image_source, external_image_path)
print(f"\n1. Created external image at: {external_image_path}")
# Create a project
project = Project("Test Heal")
page = Page()
project.add_page(page)
# Add an image element with ABSOLUTE path (simulating the bug)
img = ImageData()
img.image_path = external_image_path # BUG: Absolute path!
img.position = (10, 10)
img.size = (50, 50)
page.layout.add_element(img)
print(f"2. Created project with absolute path: {img.image_path}")
# Save to zip
with tempfile.NamedTemporaryFile(suffix='.ppz', delete=False) as tmp:
zip_path = tmp.name
save_to_zip(project, zip_path)
print(f"3. Saved to: {zip_path}")
# Check what was saved
import zipfile
with zipfile.ZipFile(zip_path, 'r') as zf:
files = zf.namelist()
asset_files = [f for f in files if f.startswith('assets/')]
print(f"\n4. Assets in zip: {len(asset_files)}")
if len(asset_files) == 0:
print(" ❌ No assets saved (expected - this is the bug!)")
else:
print(f" ✅ Assets: {asset_files}")
# Load the project
try:
loaded_project = load_from_zip(zip_path)
print(f"\n5. Loaded project from zip")
except Exception as error:
print(f" ❌ Failed to load: {error}")
return False
# Check for missing assets
from pyPhotoAlbum.models import ImageData as ImageDataCheck
missing_count = 0
for page in loaded_project.pages:
for element in page.layout.elements:
if isinstance(element, ImageDataCheck) and element.image_path:
if os.path.isabs(element.image_path):
full_path = element.image_path
else:
full_path = os.path.join(loaded_project.folder_path, element.image_path)
if not os.path.exists(full_path):
missing_count += 1
print(f" ❌ Missing: {element.image_path}")
print(f"\n6. Missing assets detected: {missing_count}")
if missing_count == 0:
print(" ⚠️ No missing assets - test may not be accurate")
return False
# Now test the healing logic
print(f"\n7. Testing heal function...")
# Simulate what the heal dialog does
from pyPhotoAlbum.models import set_asset_resolution_context
search_paths = [temp_dir] # The directory where our external image is
set_asset_resolution_context(loaded_project.folder_path, search_paths)
healed_count = 0
for page in loaded_project.pages:
for element in page.layout.elements:
if isinstance(element, ImageDataCheck) and element.image_path:
# Check if missing
if os.path.isabs(element.image_path):
full_path = element.image_path
else:
full_path = os.path.join(loaded_project.folder_path, element.image_path)
if not os.path.exists(full_path):
# Try to find and import
filename = os.path.basename(element.image_path)
for search_path in search_paths:
candidate = os.path.join(search_path, filename)
if os.path.exists(candidate):
# Import it!
new_asset_path = loaded_project.asset_manager.import_asset(candidate)
element.image_path = new_asset_path
healed_count += 1
print(f" ✅ Healed: {filename}{new_asset_path}")
break
print(f"\n8. Healed {healed_count} asset(s)")
# Save the healed project
healed_zip_path = zip_path.replace('.ppz', '_healed.ppz')
save_to_zip(loaded_project, healed_zip_path)
print(f"9. Saved healed project to: {healed_zip_path}")
# Verify the healed version has assets
with zipfile.ZipFile(healed_zip_path, 'r') as zf:
files = zf.namelist()
asset_files = [f for f in files if f.startswith('assets/')]
print(f"\n10. Assets in healed zip: {len(asset_files)}")
for asset in asset_files:
print(f" - {asset}")
# Cleanup
loaded_project.cleanup()
os.unlink(zip_path)
os.unlink(healed_zip_path)
if healed_count > 0 and len(asset_files) > 0:
print("\n✅ HEAL FUNCTION WORKS - Old files can be fixed!")
return True
else:
print("\n❌ Healing did not work as expected")
return False
if __name__ == "__main__":
success = test_heal_external_paths()
sys.exit(0 if success else 1)

View File

@ -0,0 +1,269 @@
"""
Unit tests for interaction command builders.
"""
import pytest
from unittest.mock import Mock, MagicMock
from pyPhotoAlbum.mixins.interaction_command_builders import (
MoveCommandBuilder,
ResizeCommandBuilder,
RotateCommandBuilder,
ImagePanCommandBuilder
)
from pyPhotoAlbum.mixins.interaction_validators import InteractionChangeDetector
class TestMoveCommandBuilder:
"""Tests for MoveCommandBuilder."""
def test_can_build_with_significant_change(self):
"""Test that can_build returns True for significant position changes."""
builder = MoveCommandBuilder()
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
assert builder.can_build(element, start_state)
def test_can_build_with_insignificant_change(self):
"""Test that can_build returns False for insignificant changes."""
builder = MoveCommandBuilder()
element = Mock()
element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)}
assert not builder.can_build(element, start_state)
def test_can_build_with_no_position(self):
"""Test that can_build returns False when no position in start_state."""
builder = MoveCommandBuilder()
element = Mock()
element.position = (10.0, 10.0)
start_state = {}
assert not builder.can_build(element, start_state)
def test_build_creates_command(self):
"""Test that build creates a MoveElementCommand."""
builder = MoveCommandBuilder()
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
command = builder.build(element, start_state)
assert command is not None
assert command.element == element
def test_build_returns_none_for_insignificant_change(self):
"""Test that build returns None for insignificant changes."""
builder = MoveCommandBuilder()
element = Mock()
element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)}
command = builder.build(element, start_state)
assert command is None
class TestResizeCommandBuilder:
"""Tests for ResizeCommandBuilder."""
def test_can_build_with_size_change(self):
"""Test that can_build returns True when size changes."""
builder = ResizeCommandBuilder()
element = Mock()
element.position = (0.0, 0.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert builder.can_build(element, start_state)
def test_can_build_with_position_change(self):
"""Test that can_build returns True when position changes."""
builder = ResizeCommandBuilder()
element = Mock()
element.position = (10.0, 10.0)
element.size = (100.0, 100.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert builder.can_build(element, start_state)
def test_can_build_with_both_changes(self):
"""Test that can_build returns True when both position and size change."""
builder = ResizeCommandBuilder()
element = Mock()
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert builder.can_build(element, start_state)
def test_can_build_with_no_change(self):
"""Test that can_build returns False when nothing changes."""
builder = ResizeCommandBuilder()
element = Mock()
element.position = (0.0, 0.0)
element.size = (100.0, 100.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert not builder.can_build(element, start_state)
def test_build_creates_command(self):
"""Test that build creates a ResizeElementCommand."""
builder = ResizeCommandBuilder()
element = Mock()
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
command = builder.build(element, start_state)
assert command is not None
assert command.element == element
class TestRotateCommandBuilder:
"""Tests for RotateCommandBuilder."""
def test_can_build_with_significant_change(self):
"""Test that can_build returns True for significant rotation changes."""
builder = RotateCommandBuilder()
element = Mock()
element.rotation = 45.0
start_state = {'rotation': 0.0}
assert builder.can_build(element, start_state)
def test_can_build_with_insignificant_change(self):
"""Test that can_build returns False for insignificant changes."""
builder = RotateCommandBuilder()
element = Mock()
element.rotation = 0.05
start_state = {'rotation': 0.0}
assert not builder.can_build(element, start_state)
def test_build_creates_command(self):
"""Test that build creates a RotateElementCommand."""
builder = RotateCommandBuilder()
element = Mock()
element.rotation = 45.0
start_state = {'rotation': 0.0}
command = builder.build(element, start_state)
assert command is not None
assert command.element == element
class TestImagePanCommandBuilder:
"""Tests for ImagePanCommandBuilder."""
def test_can_build_with_image_data(self):
"""Test that can_build works with ImageData elements."""
from pyPhotoAlbum.models import ImageData
builder = ImagePanCommandBuilder()
element = Mock(spec=ImageData)
element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
assert builder.can_build(element, start_state)
def test_can_build_with_non_image_data(self):
"""Test that can_build returns False for non-ImageData elements."""
builder = ImagePanCommandBuilder()
element = Mock()
element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
assert not builder.can_build(element, start_state)
def test_can_build_with_insignificant_change(self):
"""Test that can_build returns False for insignificant crop changes."""
from pyPhotoAlbum.models import ImageData
builder = ImagePanCommandBuilder()
element = Mock(spec=ImageData)
element.crop_info = (0.0001, 0.0001, 1.0, 1.0)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
assert not builder.can_build(element, start_state)
def test_build_creates_command(self):
"""Test that build creates an AdjustImageCropCommand."""
from pyPhotoAlbum.models import ImageData
builder = ImagePanCommandBuilder()
element = Mock(spec=ImageData)
element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
command = builder.build(element, start_state)
assert command is not None
assert command.element == element
class TestCommandBuilderIntegration:
"""Integration tests for command builders."""
def test_builders_use_custom_detector(self):
"""Test that builders can use custom change detectors."""
detector = InteractionChangeDetector(threshold=10.0)
builder = MoveCommandBuilder(change_detector=detector)
element = Mock()
element.position = (5.0, 5.0)
start_state = {'position': (0.0, 0.0)}
# With high threshold, this should not build
assert not builder.can_build(element, start_state)
def test_builder_logging(self, capsys):
"""Test that builders log command creation."""
builder = MoveCommandBuilder()
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
builder.build(element, start_state)
captured = capsys.readouterr()
assert "Move command created" in captured.out

View File

@ -0,0 +1,277 @@
"""
Unit tests for interaction command factory.
"""
import pytest
from unittest.mock import Mock
from pyPhotoAlbum.mixins.interaction_command_factory import (
InteractionCommandFactory,
InteractionState
)
from pyPhotoAlbum.mixins.interaction_command_builders import CommandBuilder
class TestInteractionState:
"""Tests for InteractionState value object."""
def test_initialization(self):
"""Test that InteractionState initializes correctly."""
element = Mock()
state = InteractionState(
element=element,
interaction_type='move',
position=(0.0, 0.0),
size=(100.0, 100.0),
rotation=0.0
)
assert state.element == element
assert state.interaction_type == 'move'
assert state.position == (0.0, 0.0)
assert state.size == (100.0, 100.0)
assert state.rotation == 0.0
def test_to_dict(self):
"""Test that to_dict returns correct dictionary."""
state = InteractionState(
position=(0.0, 0.0),
size=(100.0, 100.0)
)
result = state.to_dict()
assert result == {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
def test_to_dict_excludes_none(self):
"""Test that to_dict excludes None values."""
state = InteractionState(
position=(0.0, 0.0),
size=None
)
result = state.to_dict()
assert 'position' in result
assert 'size' not in result
def test_is_valid_with_required_fields(self):
"""Test that is_valid returns True when required fields are present."""
element = Mock()
state = InteractionState(
element=element,
interaction_type='move'
)
assert state.is_valid()
def test_is_valid_without_element(self):
"""Test that is_valid returns False without element."""
state = InteractionState(
element=None,
interaction_type='move'
)
assert not state.is_valid()
def test_is_valid_without_interaction_type(self):
"""Test that is_valid returns False without interaction_type."""
element = Mock()
state = InteractionState(
element=element,
interaction_type=None
)
assert not state.is_valid()
def test_clear(self):
"""Test that clear resets all fields."""
element = Mock()
state = InteractionState(
element=element,
interaction_type='move',
position=(0.0, 0.0),
size=(100.0, 100.0),
rotation=0.0
)
state.clear()
assert state.element is None
assert state.interaction_type is None
assert state.position is None
assert state.size is None
assert state.rotation is None
class TestInteractionCommandFactory:
"""Tests for InteractionCommandFactory."""
def test_initialization_registers_default_builders(self):
"""Test that factory initializes with default builders."""
factory = InteractionCommandFactory()
assert factory.has_builder('move')
assert factory.has_builder('resize')
assert factory.has_builder('rotate')
assert factory.has_builder('image_pan')
def test_register_builder(self):
"""Test registering a custom builder."""
factory = InteractionCommandFactory()
custom_builder = Mock(spec=CommandBuilder)
factory.register_builder('custom', custom_builder)
assert factory.has_builder('custom')
def test_get_supported_types(self):
"""Test getting list of supported types."""
factory = InteractionCommandFactory()
types = factory.get_supported_types()
assert 'move' in types
assert 'resize' in types
assert 'rotate' in types
assert 'image_pan' in types
def test_create_command_move(self):
"""Test creating a move command."""
factory = InteractionCommandFactory()
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
command = factory.create_command('move', element, start_state)
assert command is not None
def test_create_command_resize(self):
"""Test creating a resize command."""
factory = InteractionCommandFactory()
element = Mock()
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
command = factory.create_command('resize', element, start_state)
assert command is not None
def test_create_command_rotate(self):
"""Test creating a rotate command."""
factory = InteractionCommandFactory()
element = Mock()
element.rotation = 45.0
start_state = {'rotation': 0.0}
command = factory.create_command('rotate', element, start_state)
assert command is not None
def test_create_command_unknown_type(self, capsys):
"""Test creating command with unknown type."""
factory = InteractionCommandFactory()
element = Mock()
command = factory.create_command('unknown', element, {})
assert command is None
captured = capsys.readouterr()
assert "No builder registered for interaction type 'unknown'" in captured.out
def test_create_command_no_significant_change(self):
"""Test that no command is created for insignificant changes."""
factory = InteractionCommandFactory()
element = Mock()
element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)}
command = factory.create_command('move', element, start_state)
assert command is None
def test_create_command_with_custom_builder(self):
"""Test using a custom builder."""
factory = InteractionCommandFactory()
# Create a mock builder that always returns a mock command
custom_builder = Mock(spec=CommandBuilder)
mock_command = Mock()
custom_builder.can_build.return_value = True
custom_builder.build.return_value = mock_command
factory.register_builder('custom', custom_builder)
element = Mock()
start_state = {'position': (0.0, 0.0)}
command = factory.create_command('custom', element, start_state)
assert command == mock_command
custom_builder.can_build.assert_called_once()
custom_builder.build.assert_called_once()
class TestInteractionStateIntegration:
"""Integration tests for InteractionState with factory."""
def test_state_to_dict_with_factory(self):
"""Test that state.to_dict() works with factory."""
factory = InteractionCommandFactory()
element = Mock()
element.position = (10.0, 10.0)
state = InteractionState(
element=element,
interaction_type='move',
position=(0.0, 0.0)
)
command = factory.create_command(
state.interaction_type,
state.element,
state.to_dict()
)
assert command is not None
def test_state_lifecycle(self):
"""Test complete lifecycle of interaction state."""
element = Mock()
element.position = (0.0, 0.0)
# Begin interaction
state = InteractionState()
state.element = element
state.interaction_type = 'move'
state.position = element.position
assert state.is_valid()
# Simulate movement
element.position = (10.0, 10.0)
# Create command
factory = InteractionCommandFactory()
command = factory.create_command(
state.interaction_type,
state.element,
state.to_dict()
)
assert command is not None
# Clear state
state.clear()
assert not state.is_valid()

View File

@ -28,19 +28,16 @@ class TestUndoableInteractionInitialization:
widget = TestUndoableWidget()
qtbot.addWidget(widget)
# Should have initialized tracking state
assert hasattr(widget, '_interaction_element')
assert hasattr(widget, '_interaction_type')
assert hasattr(widget, '_interaction_start_pos')
assert hasattr(widget, '_interaction_start_size')
assert hasattr(widget, '_interaction_start_rotation')
# Should have initialized tracking state object
assert hasattr(widget, '_interaction_state')
assert hasattr(widget, '_command_factory')
# All should be None initially
assert widget._interaction_element is None
assert widget._interaction_type is None
assert widget._interaction_start_pos is None
assert widget._interaction_start_size is None
assert widget._interaction_start_rotation is None
# State should be clear initially
assert widget._interaction_state.element is None
assert widget._interaction_state.interaction_type is None
assert widget._interaction_state.position is None
assert widget._interaction_state.size is None
assert widget._interaction_state.rotation is None
class TestBeginMove:
@ -55,11 +52,11 @@ class TestBeginMove:
widget._begin_move(element)
assert widget._interaction_element is element
assert widget._interaction_type == 'move'
assert widget._interaction_start_pos == (100, 100)
assert widget._interaction_start_size is None
assert widget._interaction_start_rotation is None
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'move'
assert widget._interaction_state.position == (100, 100)
assert widget._interaction_state.size is None
assert widget._interaction_state.rotation is None
def test_begin_move_updates_existing_state(self, qtbot):
"""Test that begin_move overwrites previous interaction state"""
@ -73,8 +70,8 @@ class TestBeginMove:
widget._begin_move(element2)
# Should have element2's state
assert widget._interaction_element is element2
assert widget._interaction_start_pos == (100, 100)
assert widget._interaction_state.element is element2
assert widget._interaction_state.position == (100, 100)
class TestBeginResize:
@ -89,11 +86,11 @@ class TestBeginResize:
widget._begin_resize(element)
assert widget._interaction_element is element
assert widget._interaction_type == 'resize'
assert widget._interaction_start_pos == (100, 100)
assert widget._interaction_start_size == (200, 150)
assert widget._interaction_start_rotation is None
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'resize'
assert widget._interaction_state.position == (100, 100)
assert widget._interaction_state.size == (200, 150)
assert widget._interaction_state.rotation is None
class TestBeginRotate:
@ -109,11 +106,11 @@ class TestBeginRotate:
widget._begin_rotate(element)
assert widget._interaction_element is element
assert widget._interaction_type == 'rotate'
assert widget._interaction_start_pos is None
assert widget._interaction_start_size is None
assert widget._interaction_start_rotation == 45.0
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'rotate'
assert widget._interaction_state.position is None
assert widget._interaction_state.size is None
assert widget._interaction_state.rotation == 45.0
class TestBeginImagePan:
@ -133,9 +130,9 @@ class TestBeginImagePan:
widget._begin_image_pan(element)
assert widget._interaction_element is element
assert widget._interaction_type == 'image_pan'
assert widget._interaction_start_crop_info == (0.1, 0.2, 0.8, 0.7)
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'image_pan'
assert widget._interaction_state.crop_info == (0.1, 0.2, 0.8, 0.7)
def test_begin_image_pan_ignores_non_image(self, qtbot):
"""Test that begin_image_pan ignores non-ImageData elements"""
@ -147,8 +144,8 @@ class TestBeginImagePan:
widget._begin_image_pan(element)
# Should not set any state for non-ImageData
assert widget._interaction_element is None
assert widget._interaction_type is None
assert widget._interaction_state.element is None
assert widget._interaction_state.interaction_type is None
class TestEndInteraction:
@ -328,9 +325,9 @@ class TestEndInteraction:
widget._end_interaction()
# State should be cleared
assert widget._interaction_element is None
assert widget._interaction_type is None
assert widget._interaction_start_pos is None
assert widget._interaction_state.element is None
assert widget._interaction_state.interaction_type is None
assert widget._interaction_state.position is None
def test_end_interaction_no_project(self, qtbot):
"""Test that end_interaction handles missing project gracefully"""
@ -351,7 +348,7 @@ class TestEndInteraction:
widget._end_interaction()
# State should be cleared
assert widget._interaction_element is None
assert widget._interaction_state.element is None
def test_end_interaction_no_element(self, qtbot):
"""Test that end_interaction handles no element gracefully"""
@ -362,7 +359,7 @@ class TestEndInteraction:
widget._end_interaction()
# Should not crash, state should remain clear
assert widget._interaction_element is None
assert widget._interaction_state.element is None
class TestClearInteractionState:
@ -377,17 +374,17 @@ class TestClearInteractionState:
# Set up some state
widget._begin_move(element)
assert widget._interaction_element is not None
assert widget._interaction_state.element is not None
# Clear it
widget._clear_interaction_state()
# Everything should be None
assert widget._interaction_element is None
assert widget._interaction_type is None
assert widget._interaction_start_pos is None
assert widget._interaction_start_size is None
assert widget._interaction_start_rotation is None
assert widget._interaction_state.element is None
assert widget._interaction_state.interaction_type is None
assert widget._interaction_state.position is None
assert widget._interaction_state.size is None
assert widget._interaction_state.rotation is None
def test_clear_interaction_state_with_crop_info(self, qtbot):
"""Test that clear_interaction_state handles crop info"""
@ -398,17 +395,17 @@ class TestClearInteractionState:
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
crop_info=(0.0, 0.0, 1.0, 1.0)
)
widget._begin_image_pan(element)
assert hasattr(widget, '_interaction_start_crop_info')
# After begin_image_pan, crop_info should be stored
assert widget._interaction_state.crop_info is not None
widget._clear_interaction_state()
# Crop info should be cleared
if hasattr(widget, '_interaction_start_crop_info'):
assert widget._interaction_start_crop_info is None
assert widget._interaction_state.crop_info is None
class TestCancelInteraction:
@ -436,8 +433,8 @@ class TestCancelInteraction:
assert not mock_window.project.history.execute.called
# State should be cleared
assert widget._interaction_element is None
assert widget._interaction_type is None
assert widget._interaction_state.element is None
assert widget._interaction_state.interaction_type is None
class TestInteractionEdgeCases:
@ -455,8 +452,8 @@ class TestInteractionEdgeCases:
widget._begin_rotate(element)
# Should have rotate state (last call wins)
assert widget._interaction_type == 'rotate'
assert widget._interaction_start_rotation == 0
assert widget._interaction_state.interaction_type == 'rotate'
assert widget._interaction_state.rotation == 0
@patch('pyPhotoAlbum.commands.ResizeElementCommand')
def test_resize_with_only_size_change(self, mock_cmd_class, qtbot):

View File

@ -0,0 +1,331 @@
"""
Integration tests for the refactored UndoableInteractionMixin.
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin
from pyPhotoAlbum.models import BaseLayoutElement
class MockWidget(UndoableInteractionMixin):
"""Mock widget that uses the UndoableInteractionMixin."""
def __init__(self):
# Simulate QWidget initialization
self._mock_window = Mock()
self._mock_window.project = Mock()
self._mock_window.project.history = Mock()
super().__init__()
def window(self):
"""Mock window() method."""
return self._mock_window
class TestUndoableInteractionMixinRefactored:
"""Tests for refactored UndoableInteractionMixin."""
def test_initialization(self):
"""Test that mixin initializes correctly."""
widget = MockWidget()
assert hasattr(widget, '_command_factory')
assert hasattr(widget, '_interaction_state')
def test_begin_move(self):
"""Test beginning a move interaction."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
widget._begin_move(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'move'
assert widget._interaction_state.position == (0.0, 0.0)
def test_begin_resize(self):
"""Test beginning a resize interaction."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
element.size = (100.0, 100.0)
widget._begin_resize(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'resize'
assert widget._interaction_state.position == (0.0, 0.0)
assert widget._interaction_state.size == (100.0, 100.0)
def test_begin_rotate(self):
"""Test beginning a rotate interaction."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.rotation = 0.0
widget._begin_rotate(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'rotate'
assert widget._interaction_state.rotation == 0.0
def test_begin_image_pan(self):
"""Test beginning an image pan interaction."""
from pyPhotoAlbum.models import ImageData
widget = MockWidget()
element = Mock(spec=ImageData)
element.crop_info = (0.0, 0.0, 1.0, 1.0)
widget._begin_image_pan(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'image_pan'
assert widget._interaction_state.crop_info == (0.0, 0.0, 1.0, 1.0)
def test_begin_image_pan_non_image_element(self):
"""Test that image pan doesn't start for non-ImageData elements."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
widget._begin_image_pan(element)
# Should not set interaction state
assert widget._interaction_state.element is None
def test_end_interaction_creates_move_command(self):
"""Test that ending a move interaction creates a command."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
widget._begin_move(element)
# Simulate movement
element.position = (10.0, 10.0)
widget._end_interaction()
# Verify command was executed
widget._mock_window.project.history.execute.assert_called_once()
def test_end_interaction_creates_resize_command(self):
"""Test that ending a resize interaction creates a command."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
element.size = (100.0, 100.0)
widget._begin_resize(element)
# Simulate resize
element.size = (200.0, 200.0)
widget._end_interaction()
# Verify command was executed
widget._mock_window.project.history.execute.assert_called_once()
def test_end_interaction_creates_rotate_command(self):
"""Test that ending a rotate interaction creates a command."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.rotation = 0.0
element.position = (0.0, 0.0) # Required by RotateElementCommand
element.size = (100.0, 100.0) # Required by RotateElementCommand
widget._begin_rotate(element)
# Simulate rotation
element.rotation = 45.0
widget._end_interaction()
# Verify command was executed
widget._mock_window.project.history.execute.assert_called_once()
def test_end_interaction_no_command_for_insignificant_change(self):
"""Test that no command is created for insignificant changes."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
widget._begin_move(element)
# Insignificant movement
element.position = (0.05, 0.05)
widget._end_interaction()
# Verify no command was executed
widget._mock_window.project.history.execute.assert_not_called()
def test_end_interaction_clears_state(self):
"""Test that ending interaction clears state."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
widget._begin_move(element)
widget._end_interaction()
assert widget._interaction_state.element is None
assert widget._interaction_state.interaction_type is None
def test_end_interaction_without_begin(self):
"""Test that ending interaction without beginning is safe."""
widget = MockWidget()
widget._end_interaction()
# Should not crash or execute commands
widget._mock_window.project.history.execute.assert_not_called()
def test_cancel_interaction(self):
"""Test canceling an interaction."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
widget._begin_move(element)
widget._cancel_interaction()
assert widget._interaction_state.element is None
assert widget._interaction_state.interaction_type is None
def test_clear_interaction_state(self):
"""Test clearing interaction state directly."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
widget._begin_move(element)
widget._clear_interaction_state()
assert widget._interaction_state.element is None
def test_end_interaction_without_project(self):
"""Test that ending interaction without project is safe."""
widget = MockWidget()
# Remove the project attribute entirely
delattr(widget._mock_window, 'project')
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
widget._begin_move(element)
element.position = (10.0, 10.0)
widget._end_interaction()
# Should clear state without crashing
assert widget._interaction_state.element is None
class TestMixinIntegrationWithFactory:
"""Integration tests between mixin and factory."""
def test_move_interaction_complete_flow(self):
"""Test complete flow of a move interaction."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
# Begin
widget._begin_move(element)
assert widget._interaction_state.is_valid()
# Modify
element.position = (50.0, 75.0)
# End
widget._end_interaction()
# Verify
widget._mock_window.project.history.execute.assert_called_once()
assert not widget._interaction_state.is_valid()
def test_resize_interaction_complete_flow(self):
"""Test complete flow of a resize interaction."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
element.size = (100.0, 100.0)
# Begin
widget._begin_resize(element)
# Modify
element.position = (10.0, 10.0)
element.size = (200.0, 150.0)
# End
widget._end_interaction()
# Verify
widget._mock_window.project.history.execute.assert_called_once()
def test_rotate_interaction_complete_flow(self):
"""Test complete flow of a rotate interaction."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.rotation = 0.0
element.position = (0.0, 0.0) # Required by RotateElementCommand
element.size = (100.0, 100.0) # Required by RotateElementCommand
# Begin
widget._begin_rotate(element)
# Modify
element.rotation = 90.0
# End
widget._end_interaction()
# Verify
widget._mock_window.project.history.execute.assert_called_once()
def test_multiple_interactions_in_sequence(self):
"""Test multiple interactions in sequence."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
element.size = (100.0, 100.0)
element.rotation = 0.0
# First interaction: move
widget._begin_move(element)
element.position = (10.0, 10.0)
widget._end_interaction()
# Second interaction: resize
widget._begin_resize(element)
element.size = (200.0, 200.0)
widget._end_interaction()
# Third interaction: rotate
widget._begin_rotate(element)
element.rotation = 45.0
widget._end_interaction()
# Should have created 3 commands
assert widget._mock_window.project.history.execute.call_count == 3
def test_interaction_with_cancel(self):
"""Test interaction flow with cancellation."""
widget = MockWidget()
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
# Begin
widget._begin_move(element)
element.position = (50.0, 50.0)
# Cancel instead of end
widget._cancel_interaction()
# No command should be created
widget._mock_window.project.history.execute.assert_not_called()

View File

@ -0,0 +1,179 @@
"""
Unit tests for interaction validators and change detection.
"""
import pytest
from pyPhotoAlbum.mixins.interaction_validators import (
ChangeValidator,
InteractionChangeDetector
)
class TestChangeValidator:
"""Tests for ChangeValidator class."""
def test_position_changed_significant(self):
"""Test that significant position changes are detected."""
validator = ChangeValidator()
old_pos = (0.0, 0.0)
new_pos = (5.0, 5.0)
assert validator.position_changed(old_pos, new_pos, threshold=0.1)
def test_position_changed_insignificant(self):
"""Test that insignificant position changes are not detected."""
validator = ChangeValidator()
old_pos = (0.0, 0.0)
new_pos = (0.05, 0.05)
assert not validator.position_changed(old_pos, new_pos, threshold=0.1)
def test_position_changed_none_values(self):
"""Test that None values return False."""
validator = ChangeValidator()
assert not validator.position_changed(None, (1.0, 1.0))
assert not validator.position_changed((1.0, 1.0), None)
assert not validator.position_changed(None, None)
def test_size_changed_significant(self):
"""Test that significant size changes are detected."""
validator = ChangeValidator()
old_size = (100.0, 100.0)
new_size = (150.0, 150.0)
assert validator.size_changed(old_size, new_size, threshold=0.1)
def test_size_changed_insignificant(self):
"""Test that insignificant size changes are not detected."""
validator = ChangeValidator()
old_size = (100.0, 100.0)
new_size = (100.05, 100.05)
assert not validator.size_changed(old_size, new_size, threshold=0.1)
def test_rotation_changed_significant(self):
"""Test that significant rotation changes are detected."""
validator = ChangeValidator()
old_rotation = 0.0
new_rotation = 45.0
assert validator.rotation_changed(old_rotation, new_rotation, threshold=0.1)
def test_rotation_changed_insignificant(self):
"""Test that insignificant rotation changes are not detected."""
validator = ChangeValidator()
old_rotation = 0.0
new_rotation = 0.05
assert not validator.rotation_changed(old_rotation, new_rotation, threshold=0.1)
def test_crop_changed_significant(self):
"""Test that significant crop changes are detected."""
validator = ChangeValidator()
old_crop = (0.0, 0.0, 1.0, 1.0)
new_crop = (0.1, 0.1, 0.9, 0.9)
assert validator.crop_changed(old_crop, new_crop, threshold=0.001)
def test_crop_changed_insignificant(self):
"""Test that insignificant crop changes are not detected."""
validator = ChangeValidator()
old_crop = (0.0, 0.0, 1.0, 1.0)
new_crop = (0.0001, 0.0001, 1.0, 1.0)
assert not validator.crop_changed(old_crop, new_crop, threshold=0.001)
def test_crop_changed_identical(self):
"""Test that identical crop values return False."""
validator = ChangeValidator()
crop = (0.0, 0.0, 1.0, 1.0)
assert not validator.crop_changed(crop, crop)
class TestInteractionChangeDetector:
"""Tests for InteractionChangeDetector class."""
def test_detect_position_change_significant(self):
"""Test detecting significant position changes."""
detector = InteractionChangeDetector(threshold=0.1)
old_pos = (0.0, 0.0)
new_pos = (5.0, 3.0)
change = detector.detect_position_change(old_pos, new_pos)
assert change is not None
assert change['old_position'] == old_pos
assert change['new_position'] == new_pos
assert change['delta_x'] == 5.0
assert change['delta_y'] == 3.0
def test_detect_position_change_insignificant(self):
"""Test that insignificant position changes return None."""
detector = InteractionChangeDetector(threshold=0.1)
old_pos = (0.0, 0.0)
new_pos = (0.05, 0.05)
change = detector.detect_position_change(old_pos, new_pos)
assert change is None
def test_detect_size_change_significant(self):
"""Test detecting significant size changes."""
detector = InteractionChangeDetector(threshold=0.1)
old_size = (100.0, 100.0)
new_size = (150.0, 120.0)
change = detector.detect_size_change(old_size, new_size)
assert change is not None
assert change['old_size'] == old_size
assert change['new_size'] == new_size
assert change['delta_width'] == 50.0
assert change['delta_height'] == 20.0
def test_detect_rotation_change_significant(self):
"""Test detecting significant rotation changes."""
detector = InteractionChangeDetector(threshold=0.1)
old_rotation = 0.0
new_rotation = 45.0
change = detector.detect_rotation_change(old_rotation, new_rotation)
assert change is not None
assert change['old_rotation'] == old_rotation
assert change['new_rotation'] == new_rotation
assert change['delta_angle'] == 45.0
def test_detect_crop_change_significant(self):
"""Test detecting significant crop changes."""
detector = InteractionChangeDetector(threshold=0.1)
old_crop = (0.0, 0.0, 1.0, 1.0)
new_crop = (0.1, 0.1, 0.9, 0.9)
change = detector.detect_crop_change(old_crop, new_crop)
assert change is not None
assert change['old_crop'] == old_crop
assert change['new_crop'] == new_crop
# Use approximate comparison for floating point
assert abs(change['delta'][0] - 0.1) < 0.001
assert abs(change['delta'][1] - 0.1) < 0.001
assert abs(change['delta'][2] - (-0.1)) < 0.001
assert abs(change['delta'][3] - (-0.1)) < 0.001
def test_custom_threshold(self):
"""Test using custom threshold values."""
detector = InteractionChangeDetector(threshold=5.0)
old_pos = (0.0, 0.0)
new_pos = (3.0, 3.0)
# Should be insignificant with threshold of 5.0
change = detector.detect_position_change(old_pos, new_pos)
assert change is None
# Change detector with smaller threshold
detector2 = InteractionChangeDetector(threshold=1.0)
change2 = detector2.detect_position_change(old_pos, new_pos)
assert change2 is not None

View File

@ -1,116 +0,0 @@
#!/usr/bin/env python3
"""
Test script for the loading widget functionality
"""
import sys
import time
from pathlib import Path
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget
from PyQt6.QtCore import QTimer
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))
from pyPhotoAlbum.loading_widget import LoadingWidget
class TestWindow(QMainWindow):
"""Test window for loading widget"""
def __init__(self):
super().__init__()
self.setWindowTitle("Loading Widget Test")
self.resize(800, 600)
# Central widget
central = QWidget()
layout = QVBoxLayout()
# Test buttons
btn1 = QPushButton("Show Loading (Determinate)")
btn1.clicked.connect(self.test_determinate)
layout.addWidget(btn1)
btn2 = QPushButton("Show Loading (Indeterminate)")
btn2.clicked.connect(self.test_indeterminate)
layout.addWidget(btn2)
btn3 = QPushButton("Simulate File Loading")
btn3.clicked.connect(self.simulate_file_loading)
layout.addWidget(btn3)
central.setLayout(layout)
self.setCentralWidget(central)
# Create loading widget
self.loading_widget = LoadingWidget(self)
# Timer for progress simulation
self.timer = QTimer()
self.timer.timeout.connect(self.update_progress)
self.progress = 0
def test_determinate(self):
"""Test determinate progress"""
self.loading_widget.show_loading("Loading...")
self.loading_widget.set_indeterminate(False)
self.loading_widget.set_progress(50, 100)
# Auto hide after 3 seconds
QTimer.singleShot(3000, self.loading_widget.hide_loading)
def test_indeterminate(self):
"""Test indeterminate progress"""
self.loading_widget.show_loading("Processing...")
self.loading_widget.set_indeterminate(True)
# Auto hide after 3 seconds
QTimer.singleShot(3000, self.loading_widget.hide_loading)
def simulate_file_loading(self):
"""Simulate file loading with progress"""
self.progress = 0
self.loading_widget.show_loading("Extracting files...")
self.loading_widget.set_indeterminate(False)
self.timer.start(100) # Update every 100ms
def update_progress(self):
"""Update progress during simulation"""
self.progress += 5
if self.progress <= 40:
self.loading_widget.set_status(f"Extracting files... ({self.progress}%)")
elif self.progress <= 70:
self.loading_widget.set_status("Loading project data...")
elif self.progress <= 95:
self.loading_widget.set_status("Normalizing asset paths...")
else:
self.loading_widget.set_status("Loading complete!")
self.loading_widget.set_progress(self.progress, 100)
if self.progress >= 100:
self.timer.stop()
QTimer.singleShot(500, self.loading_widget.hide_loading)
def resizeEvent(self, event):
"""Handle resize"""
super().resizeEvent(event)
self.loading_widget.resizeParent()
def main():
"""Run test"""
app = QApplication(sys.argv)
window = TestWindow()
window.show()
print("Loading widget test window opened")
print("Click buttons to test different loading states")
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@ -238,6 +238,10 @@ def run_all_tests():
("Same Project Merge", test_same_project_merge),
("Different Project Concatenation", test_different_project_concatenation),
("No-Conflict Merge", test_no_conflicts),
("Helper: _add_missing_pages", test_merge_helper_add_missing_pages),
("Helper: _is_element_in_conflict", test_merge_helper_is_element_in_conflict),
("Helper: _merge_by_timestamp", test_merge_helper_merge_by_timestamp),
("Helper: _merge_element", test_merge_helper_merge_element),
]
results = []
@ -266,6 +270,241 @@ def run_all_tests():
return all_passed
def test_merge_helper_add_missing_pages():
"""Test _add_missing_pages helper method"""
print("=" * 60)
print("Test 4: _add_missing_pages Helper Method")
print("=" * 60)
# Create projects with different pages
project_a = Project("Project A")
page_a1 = Page(page_number=1)
project_a.add_page(page_a1)
project_b = Project("Project B")
# Give page_b1 the same UUID as page_a1 so it won't be added
page_b1 = Page(page_number=1)
page_b1.uuid = page_a1.uuid
page_b2 = Page(page_number=2)
project_b.add_page(page_b1)
project_b.add_page(page_b2)
data_a = project_a.serialize()
data_b = project_b.serialize()
# Make them same project
data_b['project_id'] = data_a['project_id']
merge_manager = MergeManager()
merge_manager.detect_conflicts(data_a, data_b)
# Test _add_missing_pages
merged_data = data_a.copy()
merged_data['pages'] = list(data_a['pages'])
initial_page_count = len(merged_data['pages'])
merge_manager._add_missing_pages(merged_data, data_b)
# Should have added only page_b2 since page_b1 has same UUID as page_a1
assert len(merged_data['pages']) == initial_page_count + 1
print(f" ✓ Added missing page: {len(merged_data['pages'])} total pages")
print(f"\n{'=' * 60}")
print("✅ _add_missing_pages test PASSED")
print(f"{'=' * 60}\n")
return True
def test_merge_helper_is_element_in_conflict():
"""Test _is_element_in_conflict helper method"""
print("=" * 60)
print("Test 5: _is_element_in_conflict Helper Method")
print("=" * 60)
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
merge_manager = MergeManager()
# Create a conflict
conflict = ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid="page-123",
element_uuid="elem-456",
our_version={},
their_version={},
description="Test conflict"
)
merge_manager.conflicts.append(conflict)
# Test detection
assert merge_manager._is_element_in_conflict("elem-456", "page-123") is True
assert merge_manager._is_element_in_conflict("elem-999", "page-123") is False
assert merge_manager._is_element_in_conflict("elem-456", "page-999") is False
print(f" ✓ Correctly identified conflicting element")
print(f" ✓ Correctly identified non-conflicting elements")
print(f"\n{'=' * 60}")
print("✅ _is_element_in_conflict test PASSED")
print(f"{'=' * 60}\n")
return True
def test_merge_helper_merge_by_timestamp():
"""Test _merge_by_timestamp helper method"""
print("=" * 60)
print("Test 6: _merge_by_timestamp Helper Method")
print("=" * 60)
from datetime import datetime, timezone, timedelta
merge_manager = MergeManager()
# Create page with elements
now = datetime.now(timezone.utc)
older = (now - timedelta(hours=1)).isoformat()
newer = (now + timedelta(hours=1)).isoformat()
our_page = {
'layout': {
'elements': [
{
'uuid': 'elem-1',
'text_content': 'Older version',
'last_modified': older
}
]
}
}
our_elem = our_page['layout']['elements'][0]
their_elem = {
'uuid': 'elem-1',
'text_content': 'Newer version',
'last_modified': newer
}
# Test: their version is newer, should replace
merge_manager._merge_by_timestamp(our_page, 'elem-1', their_elem, our_elem)
assert our_page['layout']['elements'][0]['text_content'] == 'Newer version'
print(f" ✓ Correctly replaced with newer version")
# Test: our version is newer, should not replace
our_page['layout']['elements'][0] = {
'uuid': 'elem-2',
'text_content': 'Our newer version',
'last_modified': newer
}
their_elem_older = {
'uuid': 'elem-2',
'text_content': 'Their older version',
'last_modified': older
}
merge_manager._merge_by_timestamp(
our_page, 'elem-2',
their_elem_older,
our_page['layout']['elements'][0]
)
assert our_page['layout']['elements'][0]['text_content'] == 'Our newer version'
print(f" ✓ Correctly kept our newer version")
print(f"\n{'=' * 60}")
print("✅ _merge_by_timestamp test PASSED")
print(f"{'=' * 60}\n")
return True
def test_merge_helper_merge_element():
"""Test _merge_element helper method"""
print("=" * 60)
print("Test 7: _merge_element Helper Method")
print("=" * 60)
from datetime import datetime, timezone
merge_manager = MergeManager()
now = datetime.now(timezone.utc).isoformat()
# Setup: page with one element
our_page = {
'uuid': 'page-1',
'layout': {
'elements': [
{
'uuid': 'elem-existing',
'text_content': 'Existing',
'last_modified': now
}
]
}
}
our_elements = {
'elem-existing': our_page['layout']['elements'][0]
}
# Test 1: Adding new element
their_new_elem = {
'uuid': 'elem-new',
'text_content': 'New element',
'last_modified': now
}
merge_manager._merge_element(
our_page=our_page,
page_uuid='page-1',
their_elem=their_new_elem,
our_elements=our_elements
)
assert len(our_page['layout']['elements']) == 2
assert our_page['layout']['elements'][1]['uuid'] == 'elem-new'
print(f" ✓ Correctly added new element")
# Test 2: Element in conflict should be skipped
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
conflict_elem = {
'uuid': 'elem-conflict',
'text_content': 'Conflict element',
'last_modified': now
}
conflict = ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid='page-1',
element_uuid='elem-conflict',
our_version={},
their_version={},
description="Test"
)
merge_manager.conflicts.append(conflict)
our_elements['elem-conflict'] = {'uuid': 'elem-conflict', 'text_content': 'Ours'}
our_page['layout']['elements'].append(our_elements['elem-conflict'])
initial_count = len(our_page['layout']['elements'])
merge_manager._merge_element(
our_page=our_page,
page_uuid='page-1',
their_elem=conflict_elem,
our_elements=our_elements
)
# Should not change anything since it's in conflict
assert len(our_page['layout']['elements']) == initial_count
print(f" ✓ Correctly skipped conflicting element")
print(f"\n{'=' * 60}")
print("✅ _merge_element test PASSED")
print(f"{'=' * 60}\n")
return True
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

View File

@ -1,90 +0,0 @@
#!/usr/bin/env python3
"""
Test script to verify Page Setup functionality
"""
import sys
from PyQt6.QtWidgets import QApplication
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
def test_page_setup_behavior():
"""Test that page setup works correctly with multiple pages"""
# Create a project with default size
project = Project("Test Project")
print(f"Initial project default page size: {project.page_size_mm}")
# Add first page - should use project default
page1_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
page1 = Page(layout=page1_layout, page_number=1)
page1.manually_sized = False
project.add_page(page1)
print(f"Page 1 size: {page1.layout.size}, manually_sized: {page1.manually_sized}")
# Add second page - should also use project default
page2_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
page2 = Page(layout=page2_layout, page_number=2)
page2.manually_sized = False
project.add_page(page2)
print(f"Page 2 size: {page2.layout.size}, manually_sized: {page2.manually_sized}")
# Simulate changing page 1 size without setting as default
print("\n--- Simulating Page Setup on Page 1 (without setting as default) ---")
page1.layout.size = (200, 200)
page1.layout.base_width = 200
page1.manually_sized = True
print(f"Page 1 size after change: {page1.layout.size}, manually_sized: {page1.manually_sized}")
print(f"Project default still: {project.page_size_mm}")
print(f"Page 2 unchanged: {page2.layout.size}, manually_sized: {page2.manually_sized}")
# Add third page - should use original project default
page3_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
page3 = Page(layout=page3_layout, page_number=3)
page3.manually_sized = False
project.add_page(page3)
print(f"Page 3 size: {page3.layout.size}, manually_sized: {page3.manually_sized}")
# Simulate changing page 2 size AND setting as default
print("\n--- Simulating Page Setup on Page 2 (with setting as default) ---")
page2.layout.size = (250, 250)
page2.layout.base_width = 250
page2.manually_sized = True
project.page_size_mm = (250, 250) # Set as default
print(f"Page 2 size after change: {page2.layout.size}, manually_sized: {page2.manually_sized}")
print(f"Project default updated to: {project.page_size_mm}")
# Add fourth page - should use NEW project default
page4_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
page4 = Page(layout=page4_layout, page_number=4)
page4.manually_sized = False
project.add_page(page4)
print(f"Page 4 size: {page4.layout.size}, manually_sized: {page4.manually_sized}")
# Test double spread
print("\n--- Testing Double Spread ---")
page5_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1], is_facing_page=True)
page5 = Page(layout=page5_layout, page_number=5, is_double_spread=True)
page5.manually_sized = False
project.add_page(page5)
print(f"Page 5 (double spread) size: {page5.layout.size}, base_width: {page5.layout.base_width}")
print(f"Expected: width should be 2x base_width = {project.page_size_mm[0] * 2}")
print("\n--- Summary ---")
for i, page in enumerate(project.pages, 1):
spread_info = " (double spread)" if page.is_double_spread else ""
manual_info = " *manually sized*" if page.manually_sized else ""
print(f"Page {i}: {page.layout.size}{spread_info}{manual_info}")
print(f"Project default: {project.page_size_mm}")
print("\n✓ All tests passed!")
if __name__ == "__main__":
# Initialize Qt application (needed for PyQt6 widgets even in tests)
app = QApplication(sys.argv)
test_page_setup_behavior()
print("\nTest completed successfully!")

View File

@ -0,0 +1,731 @@
"""
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

View File

@ -0,0 +1,434 @@
"""
Unit tests for PageSetupDialog with mocked Qt widgets
These tests mock Qt widgets to avoid dependencies on the display system
and test the dialog logic in isolation.
"""
import pytest
from unittest.mock import Mock, MagicMock, patch, call
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
class TestPageSetupDialogWithMocks:
"""Test PageSetupDialog with fully mocked Qt widgets"""
def test_dialog_stores_initialization_params(self):
"""Test dialog stores project and initial page index"""
# We test that the dialog class properly stores init parameters
# without actually creating Qt widgets
from pyPhotoAlbum.dialogs.page_setup_dialog import PageSetupDialog
project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
# We can verify the class signature and that it would accept these params
# This is a structural test rather than a full initialization test
assert hasattr(PageSetupDialog, '__init__')
# The actual widget creation tests are in test_page_setup_dialog.py
# using qtbot which handles Qt properly
def test_on_page_changed_logic_isolated(self):
"""Test _on_page_changed logic without Qt dependencies"""
from pyPhotoAlbum.dialogs.page_setup_dialog import PageSetupDialog
# Setup project
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]
# Mock the dialog instance
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
# Manually set required attributes
dialog.project = project
dialog._cover_group = Mock()
dialog.cover_checkbox = Mock()
dialog.width_spinbox = Mock()
dialog.height_spinbox = Mock()
dialog.set_default_checkbox = Mock()
# Mock the update spine info method
dialog._update_spine_info = Mock()
# Test with first page (index 0)
dialog._on_page_changed(0)
# Verify cover group was made visible (first page)
dialog._cover_group.setVisible.assert_called_with(True)
# Verify cover checkbox was updated
dialog.cover_checkbox.setChecked.assert_called_once()
# Verify spine info was updated
dialog._update_spine_info.assert_called_once()
# Reset mocks
dialog._cover_group.reset_mock()
dialog._update_spine_info.reset_mock()
# Test with second page (index 1)
dialog._on_page_changed(1)
# Verify cover group was hidden (not first page)
dialog._cover_group.setVisible.assert_called_with(False)
# Verify spine info was NOT updated (not first page)
dialog._update_spine_info.assert_not_called()
def test_on_page_changed_invalid_indices(self):
"""Test _on_page_changed handles invalid indices"""
from pyPhotoAlbum.dialogs.page_setup_dialog import PageSetupDialog
project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
dialog._cover_group = Mock()
# Test negative index - should return early
dialog._on_page_changed(-1)
dialog._cover_group.setVisible.assert_not_called()
# Test out of bounds index - should return early
dialog._on_page_changed(999)
dialog._cover_group.setVisible.assert_not_called()
def test_update_spine_info_calculation(self):
"""Test spine info calculation logic"""
from pyPhotoAlbum.dialogs.page_setup_dialog import PageSetupDialog
project = Project(name="Test")
project.page_size_mm = (210, 297)
project.paper_thickness_mm = 0.1
project.cover_bleed_mm = 3.0
# Create 3 content pages (not covers)
for i in range(3):
page = Page(layout=PageLayout(width=210, height=297), page_number=i+1)
page.is_cover = False
project.pages.append(page)
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
dialog.cover_checkbox = Mock()
dialog.thickness_spinbox = Mock()
dialog.bleed_spinbox = Mock()
dialog.spine_info_label = Mock()
# Test when cover is enabled
dialog.cover_checkbox.isChecked.return_value = True
dialog.thickness_spinbox.value.return_value = 0.1
dialog.bleed_spinbox.value.return_value = 3.0
dialog._update_spine_info()
# Verify spine info was set (not empty)
assert dialog.spine_info_label.setText.called
call_args = dialog.spine_info_label.setText.call_args[0][0]
assert "Cover Layout" in call_args
assert "Spine" in call_args
assert "Front" in call_args
# Reset
dialog.spine_info_label.reset_mock()
# Test when cover is disabled
dialog.cover_checkbox.isChecked.return_value = False
dialog._update_spine_info()
# Verify spine info was cleared
dialog.spine_info_label.setText.assert_called_once_with("")
def test_get_values_data_extraction(self):
"""Test get_values extracts all data correctly"""
from pyPhotoAlbum.dialogs.page_setup_dialog import PageSetupDialog
project = Project(name="Test")
project.page_size_mm = (210, 297)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
# Mock all input widgets
dialog.page_combo = Mock()
dialog.page_combo.currentData.return_value = 0
dialog.cover_checkbox = Mock()
dialog.cover_checkbox.isChecked.return_value = True
dialog.thickness_spinbox = Mock()
dialog.thickness_spinbox.value.return_value = 0.15
dialog.bleed_spinbox = Mock()
dialog.bleed_spinbox.value.return_value = 5.0
dialog.width_spinbox = Mock()
dialog.width_spinbox.value.return_value = 200.0
dialog.height_spinbox = Mock()
dialog.height_spinbox.value.return_value = 280.0
dialog.working_dpi_spinbox = Mock()
dialog.working_dpi_spinbox.value.return_value = 150
dialog.export_dpi_spinbox = Mock()
dialog.export_dpi_spinbox.value.return_value = 600
dialog.set_default_checkbox = Mock()
dialog.set_default_checkbox.isChecked.return_value = True
# Get values
values = dialog.get_values()
# Verify all values were extracted
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.0
assert values['height_mm'] == 280.0
assert values['working_dpi'] == 150
assert values['export_dpi'] == 600
assert values['set_as_default'] is True
def test_cover_page_width_display(self):
"""Test cover page shows full width, not base width"""
from pyPhotoAlbum.dialogs.page_setup_dialog import PageSetupDialog
project = Project(name="Test")
project.page_size_mm = (210, 297)
# Create cover page with special width
page = Page(layout=PageLayout(width=500, height=297), page_number=1)
page.is_cover = True
project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
dialog._cover_group = Mock()
dialog.cover_checkbox = Mock()
dialog.width_spinbox = Mock()
dialog.height_spinbox = Mock()
dialog.set_default_checkbox = Mock()
dialog._update_spine_info = Mock()
# Call _on_page_changed for cover page
dialog._on_page_changed(0)
# Verify width was set to full cover width (500), not base width
dialog.width_spinbox.setValue.assert_called()
width_call = dialog.width_spinbox.setValue.call_args[0][0]
assert width_call == 500
# Verify widgets were disabled for cover
dialog.width_spinbox.setEnabled.assert_called_with(False)
dialog.height_spinbox.setEnabled.assert_called_with(False)
dialog.set_default_checkbox.setEnabled.assert_called_with(False)
# Note: Additional widget state tests are covered in test_page_setup_dialog.py
# using qtbot which properly handles Qt widget initialization
class TestDialogMixinMocked:
"""Test DialogMixin with mocked dialogs"""
def test_create_dialog_flow(self):
"""Test create_dialog method flow"""
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
class TestWindow(DialogMixin):
pass
window = TestWindow()
# Mock dialog class
mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted
mock_dialog_instance.get_values.return_value = {'key': 'value'}
mock_dialog_class = Mock(return_value=mock_dialog_instance)
# Call create_dialog
result = window.create_dialog(mock_dialog_class, title="Test Title", extra_param="test")
# Verify dialog was created with correct params
mock_dialog_class.assert_called_once_with(parent=window, extra_param="test")
# Verify title was set
mock_dialog_instance.setWindowTitle.assert_called_once_with("Test Title")
# Verify dialog was executed
mock_dialog_instance.exec.assert_called_once()
# Verify get_values was called
mock_dialog_instance.get_values.assert_called_once()
# Verify result
assert result == {'key': 'value'}
def test_show_dialog_with_callback_flow(self):
"""Test show_dialog method with callback"""
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
class TestWindow(DialogMixin):
pass
window = TestWindow()
# Mock dialog
mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted
mock_dialog_instance.get_values.return_value = {'data': 'test'}
mock_dialog_class = Mock(return_value=mock_dialog_instance)
# Mock callback
callback = Mock()
# Call show_dialog
result = window.show_dialog(mock_dialog_class, on_accept=callback, param="value")
# Verify callback was called with dialog values
callback.assert_called_once_with({'data': 'test'})
# Verify result
assert result is True
def test_show_dialog_rejected_no_callback(self):
"""Test show_dialog when dialog is rejected"""
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
class TestWindow(DialogMixin):
pass
window = TestWindow()
# Mock rejected dialog
mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 0 # Rejected
mock_dialog_class = Mock(return_value=mock_dialog_instance)
callback = Mock()
# Call show_dialog
result = window.show_dialog(mock_dialog_class, on_accept=callback)
# Verify callback was NOT called
callback.assert_not_called()
# Verify result
assert result is False
class TestDialogActionDecoratorMocked:
"""Test @dialog_action decorator with mocks"""
def test_decorator_creates_and_shows_dialog(self):
"""Test decorator creates dialog and passes values to function"""
from pyPhotoAlbum.decorators import dialog_action
from PyQt6.QtWidgets import QDialog
# Mock dialog instance
mock_dialog = Mock()
mock_dialog.exec.return_value = QDialog.DialogCode.Accepted # Accepted
mock_dialog.get_values.return_value = {'test': 'data'}
# Mock dialog class
mock_dialog_cls = Mock(return_value=mock_dialog)
# Create decorated function
@dialog_action(dialog_class=mock_dialog_cls, requires_pages=True)
def test_function(self, values):
return values['test']
# Mock instance with required attributes
instance = Mock()
instance.project = Mock()
instance.project.pages = [Mock()] # Has pages
instance._get_most_visible_page_index = Mock(return_value=0)
# Call decorated function
result = test_function(instance)
# Verify dialog was created
mock_dialog_cls.assert_called_once()
# Verify dialog was shown
mock_dialog.exec.assert_called_once()
# Verify values were extracted
mock_dialog.get_values.assert_called_once()
# Verify original function received values
assert result == 'data'
def test_decorator_returns_early_when_no_pages(self):
"""Test decorator returns early when pages required but not present"""
from pyPhotoAlbum.decorators import dialog_action
mock_dialog_cls = Mock()
@dialog_action(dialog_class=mock_dialog_cls, requires_pages=True)
def test_function(self, values):
return "should not reach"
# Mock instance with no pages
instance = Mock()
instance.project = Mock()
instance.project.pages = [] # No pages
# Call decorated function
result = test_function(instance)
# Verify dialog was NOT created
mock_dialog_cls.assert_not_called()
# Verify result is None
assert result is None
def test_decorator_works_without_pages_requirement(self):
"""Test decorator works when pages not required"""
from pyPhotoAlbum.decorators import dialog_action
mock_dialog = Mock()
mock_dialog.exec.return_value = 1
mock_dialog.get_values.return_value = {'key': 'val'}
mock_dialog_cls = Mock(return_value=mock_dialog)
@dialog_action(dialog_class=mock_dialog_cls, requires_pages=False)
def test_function(self, values):
return values
# Mock instance with no pages
instance = Mock()
instance.project = Mock()
instance.project.pages = [] # No pages, but that's OK
# Call decorated function
result = test_function(instance)
# Verify dialog WAS created (pages not required)
mock_dialog_cls.assert_called_once()
# Verify result
assert result == {'key': 'val'}
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@ -202,6 +202,7 @@ class TestSnappingSystem:
def test_snap_resize_bottom_right_handle(self):
"""Test snap_resize with bottom-right handle"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_grid = True
system.grid_size_mm = 10.0
@ -213,9 +214,16 @@ class TestSnappingSystem:
resize_handle = 'se'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
params = SnapResizeParams(
position=position,
size=size,
dx=dx,
dy=dy,
resize_handle=resize_handle,
page_size=page_size,
dpi=300
)
new_pos, new_size = system.snap_resize(params)
# Position shouldn't change for bottom-right handle
assert new_pos == position
@ -225,6 +233,7 @@ class TestSnappingSystem:
def test_snap_resize_top_left_handle(self):
"""Test snap_resize with top-left handle"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
@ -235,9 +244,9 @@ class TestSnappingSystem:
resize_handle = 'nw'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
)
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=resize_handle, page_size=page_size, dpi=300)
new_pos, new_size = system.snap_resize(params)
# Both position and size should change for top-left handle
assert new_pos != position
@ -245,6 +254,7 @@ class TestSnappingSystem:
def test_snap_resize_top_handle(self):
"""Test snap_resize with top handle only"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
@ -255,9 +265,9 @@ class TestSnappingSystem:
resize_handle = 'n'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
)
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=resize_handle, page_size=page_size, dpi=300)
new_pos, new_size = system.snap_resize(params)
# X position should stay same, Y should change
assert new_pos[0] == position[0]
@ -268,6 +278,7 @@ class TestSnappingSystem:
def test_snap_resize_right_handle(self):
"""Test snap_resize with right handle only"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
@ -278,9 +289,9 @@ class TestSnappingSystem:
resize_handle = 'e'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
)
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=resize_handle, page_size=page_size, dpi=300)
new_pos, new_size = system.snap_resize(params)
# Position should stay same
assert new_pos == position
@ -290,6 +301,7 @@ class TestSnappingSystem:
def test_snap_resize_minimum_size(self):
"""Test snap_resize enforces minimum size"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = False
@ -300,9 +312,9 @@ class TestSnappingSystem:
resize_handle = 'se'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
)
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=resize_handle, page_size=page_size, dpi=300)
new_pos, new_size = system.snap_resize(params)
# Should enforce minimum size of 10 pixels
assert new_size[0] >= 10
@ -310,6 +322,7 @@ class TestSnappingSystem:
def test_snap_resize_all_handles(self):
"""Test snap_resize works with all handle types"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = False
@ -322,9 +335,9 @@ class TestSnappingSystem:
handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
for handle in handles:
new_pos, new_size = system.snap_resize(
position, size, dx, dy, handle, page_size, dpi=300
)
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=handle, page_size=page_size, dpi=300)
new_pos, new_size = system.snap_resize(params)
# Should return valid position and size
assert isinstance(new_pos, tuple)
assert len(new_pos) == 2

View File

@ -1,139 +0,0 @@
#!/usr/bin/env python3
"""
Test script to verify that images are being embedded in .ppz files
"""
import os
import sys
import zipfile
import tempfile
import shutil
# Add project to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
def test_zip_embedding():
"""Test that images are properly embedded in the zip file"""
# Create a test project with an image
project = Project("Test Embedding")
page = Page()
project.add_page(page)
# Use an existing test image
test_image = "./projects/project_with_image/assets/test_image.jpg"
if not os.path.exists(test_image):
print(f"ERROR: Test image not found: {test_image}")
return False
print(f"Using test image: {test_image}")
print(f"Test image size: {os.path.getsize(test_image)} bytes")
# Import the image through the asset manager
print("\n1. Importing image through asset manager...")
asset_path = project.asset_manager.import_asset(test_image)
print(f" Asset path: {asset_path}")
# Check that the asset was copied
full_asset_path = project.asset_manager.get_absolute_path(asset_path)
if os.path.exists(full_asset_path):
print(f" ✓ Asset exists at: {full_asset_path}")
print(f" Asset size: {os.path.getsize(full_asset_path)} bytes")
else:
print(f" ✗ ERROR: Asset not found at {full_asset_path}")
return False
# Add image to page
img = ImageData()
img.image_path = asset_path
img.position = (10, 10)
img.size = (50, 50)
page.layout.add_element(img)
# Save to a temporary zip file
print("\n2. Saving project to .ppz file...")
with tempfile.NamedTemporaryFile(suffix='.ppz', delete=False) as tmp:
zip_path = tmp.name
success, error = save_to_zip(project, zip_path)
if not success:
print(f" ✗ ERROR: Failed to save: {error}")
return False
print(f" ✓ Saved to: {zip_path}")
zip_size = os.path.getsize(zip_path)
print(f" Zip file size: {zip_size:,} bytes")
# Inspect the zip file contents
print("\n3. Inspecting zip file contents...")
with zipfile.ZipFile(zip_path, 'r') as zf:
files = zf.namelist()
print(f" Files in zip: {len(files)}")
for fname in files:
info = zf.getinfo(fname)
print(f" - {fname} ({info.file_size:,} bytes)")
# Check if assets are included
asset_files = [f for f in files if f.startswith('assets/')]
print(f"\n4. Checking for embedded assets...")
print(f" Assets found: {len(asset_files)}")
if len(asset_files) == 0:
print(" ✗ ERROR: No assets embedded in zip file!")
print(f"\n DEBUG INFO:")
print(f" Project folder: {project.folder_path}")
print(f" Assets folder: {project.asset_manager.assets_folder}")
print(f" Assets folder exists: {os.path.exists(project.asset_manager.assets_folder)}")
if os.path.exists(project.asset_manager.assets_folder):
assets = os.listdir(project.asset_manager.assets_folder)
print(f" Files in assets folder: {assets}")
# Cleanup
os.unlink(zip_path)
return False
print(f" ✓ Found {len(asset_files)} asset file(s) in zip")
# Load the project back
print("\n5. Loading project from zip...")
try:
loaded_project = load_from_zip(zip_path)
print(f" ✓ Loaded project: {loaded_project.name}")
except Exception as e:
print(f" ✗ ERROR: Failed to load: {e}")
os.unlink(zip_path)
return False
# Check that the image is accessible
print("\n6. Verifying loaded image...")
if loaded_project.pages and loaded_project.pages[0].layout.elements:
img_elem = loaded_project.pages[0].layout.elements[0]
if isinstance(img_elem, ImageData):
loaded_img_path = loaded_project.asset_manager.get_absolute_path(img_elem.image_path)
if os.path.exists(loaded_img_path):
print(f" ✓ Image accessible at: {loaded_img_path}")
print(f" Image size: {os.path.getsize(loaded_img_path)} bytes")
else:
print(f" ✗ ERROR: Image not found at {loaded_img_path}")
os.unlink(zip_path)
return False
# Cleanup
os.unlink(zip_path)
loaded_project.cleanup()
print("\n✅ ALL TESTS PASSED - Images are being embedded correctly!")
return True
if __name__ == "__main__":
print("=" * 70)
print("Testing .ppz file image embedding")
print("=" * 70)
success = test_zip_embedding()
sys.exit(0 if success else 1)