Refactor to allow indepth testing
This commit is contained in:
parent
0d698a83b4
commit
6755549dfd
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
10
pyPhotoAlbum/dialogs/__init__.py
Normal file
10
pyPhotoAlbum/dialogs/__init__.py
Normal 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']
|
||||
330
pyPhotoAlbum/dialogs/page_setup_dialog.py
Normal file
330
pyPhotoAlbum/dialogs/page_setup_dialog.py
Normal 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()
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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}")
|
||||
|
||||
76
pyPhotoAlbum/mixins/dialog_mixin.py
Normal file
76
pyPhotoAlbum/mixins/dialog_mixin.py
Normal 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
|
||||
@ -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
|
||||
|
||||
199
pyPhotoAlbum/mixins/interaction_command_builders.py
Normal file
199
pyPhotoAlbum/mixins/interaction_command_builders.py
Normal 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
|
||||
148
pyPhotoAlbum/mixins/interaction_command_factory.py
Normal file
148
pyPhotoAlbum/mixins/interaction_command_factory.py
Normal 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
|
||||
@ -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"""
|
||||
|
||||
156
pyPhotoAlbum/mixins/interaction_validators.py
Normal file
156
pyPhotoAlbum/mixins/interaction_validators.py
Normal 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))
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
@ -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)
|
||||
|
||||
375
tests/test_element_maximizer.py
Normal file
375
tests/test_element_maximizer.py
Normal 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
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
269
tests/test_interaction_command_builders.py
Normal file
269
tests/test_interaction_command_builders.py
Normal 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
|
||||
277
tests/test_interaction_command_factory.py
Normal file
277
tests/test_interaction_command_factory.py
Normal 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()
|
||||
@ -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):
|
||||
|
||||
331
tests/test_interaction_undo_refactored.py
Normal file
331
tests/test_interaction_undo_refactored.py
Normal 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()
|
||||
179
tests/test_interaction_validators.py
Normal file
179
tests/test_interaction_validators.py
Normal 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
|
||||
@ -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()
|
||||
@ -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)
|
||||
|
||||
@ -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!")
|
||||
731
tests/test_page_setup_dialog.py
Normal file
731
tests/test_page_setup_dialog.py
Normal 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
|
||||
434
tests/test_page_setup_dialog_mocked.py
Normal file
434
tests/test_page_setup_dialog_mocked.py
Normal 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'])
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user