additional test coverage and mypy fixes
All checks were successful
Python CI / test (push) Successful in 1m18s
Lint / lint (push) Successful in 1m5s
Tests / test (3.11) (push) Successful in 1m29s
Tests / test (3.12) (push) Successful in 1m33s
Tests / test (3.13) (push) Successful in 1m26s
Tests / test (3.14) (push) Successful in 1m14s

This commit is contained in:
Duncan Tourolle 2025-11-27 23:20:43 +01:00
parent f6ed11b0bc
commit 558b7be01a
21 changed files with 570 additions and 65 deletions

View File

@ -14,7 +14,8 @@ from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Optional, Callable, Dict, Any, Tuple from typing import Optional, Callable, Dict, Any, Tuple, Union
from concurrent.futures import Future
from collections import OrderedDict from collections import OrderedDict
import threading import threading
@ -230,7 +231,7 @@ class AsyncImageLoader(QObject):
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader") self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader")
# Priority queue and tracking # Priority queue and tracking
self._queue: asyncio.PriorityQueue = None # Created when event loop starts self._queue: Optional[asyncio.PriorityQueue[Any]] = None # Created when event loop starts
self._pending_requests: Dict[Path, LoadRequest] = {} self._pending_requests: Dict[Path, LoadRequest] = {}
self._active_tasks: Dict[Path, asyncio.Task] = {} self._active_tasks: Dict[Path, asyncio.Task] = {}
self._next_request_id = 0 self._next_request_id = 0
@ -373,7 +374,7 @@ class AsyncImageLoader(QObject):
self._pending_requests.pop(path, None) self._pending_requests.pop(path, None)
self._active_tasks.pop(path, None) self._active_tasks.pop(path, None)
def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> Image.Image: def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> "Image.Image":
""" """
Load image from disk and process (runs in thread pool). Load image from disk and process (runs in thread pool).
@ -528,7 +529,7 @@ class AsyncPDFGenerator(QObject):
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator") self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator")
# Export state # Export state
self._current_export: Optional[asyncio.Task] = None self._current_export: Optional[Future[Any]] = None
self._cancel_requested = False self._cancel_requested = False
self._lock = threading.Lock() self._lock = threading.Lock()
self._shutdown = False self._shutdown = False
@ -675,7 +676,7 @@ class AsyncPDFGenerator(QObject):
with self._lock: with self._lock:
self._current_export = None self._current_export = None
def _export_with_cache(self, exporter, output_path: str, progress_callback) -> Tuple[bool, list]: def _export_with_cache(self, exporter: Any, output_path: str, progress_callback: Any) -> Tuple[bool, list[Any]]:
""" """
Run PDF export with image cache integration. Run PDF export with image cache integration.

View File

@ -10,7 +10,7 @@ import json
import shutil import shutil
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, List, Tuple from typing import Dict, Optional, List, Tuple
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
@ -186,7 +186,7 @@ class AutosaveManager:
max_count: Maximum number of checkpoints to keep per project max_count: Maximum number of checkpoints to keep per project
""" """
now = datetime.now() now = datetime.now()
checkpoints_by_project = {} checkpoints_by_project: Dict[str, List[Tuple[Path, dict]]] = {}
# Group checkpoints by project # Group checkpoints by project
for checkpoint_path, metadata in self.list_checkpoints(): for checkpoint_path, metadata in self.list_checkpoints():

View File

@ -98,6 +98,7 @@ class AddElementCommand(Command):
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -156,6 +157,7 @@ class DeleteElementCommand(Command):
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -207,6 +209,7 @@ class MoveElementCommand(Command):
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -264,6 +267,7 @@ class ResizeElementCommand(Command):
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -388,6 +392,7 @@ class RotateElementCommand(Command):
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -485,6 +490,7 @@ class AlignElementsCommand(Command):
elem_data = change_data["element"] elem_data = change_data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -546,6 +552,7 @@ class ResizeElementsCommand(Command):
elem_data = change_data["element"] elem_data = change_data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -605,6 +612,7 @@ class ChangeZOrderCommand(Command):
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":

View File

@ -4,7 +4,7 @@ Decorator system for pyPhotoAlbum ribbon UI
import copy import copy
from functools import wraps from functools import wraps
from typing import Optional, Callable from typing import Any, Optional, Callable
class RibbonAction: class RibbonAction:
@ -75,11 +75,11 @@ class RibbonAction:
""" """
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs) return func(*args, **kwargs)
# Store metadata on wrapper function # Store metadata on wrapper function
wrapper._ribbon_action = { wrapper._ribbon_action = { # type: ignore[attr-defined]
"label": self.label, "label": self.label,
"tooltip": self.tooltip, "tooltip": self.tooltip,
"tab": self.tab, "tab": self.tab,
@ -178,11 +178,11 @@ class NumericalInput:
""" """
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs) return func(*args, **kwargs)
# Store metadata on wrapper function # Store metadata on wrapper function
wrapper._numerical_input = {"fields": self.fields} wrapper._numerical_input = {"fields": self.fields} # type: ignore[attr-defined]
return wrapper return wrapper
@ -218,7 +218,7 @@ class UndoableOperation:
# Decorator handles undo/redo automatically # Decorator handles undo/redo automatically
""" """
def __init__(self, capture: str = "page_elements", description: str = None): def __init__(self, capture: str = "page_elements", description: Optional[str] = None):
""" """
Initialize the undoable operation decorator. Initialize the undoable operation decorator.
@ -298,10 +298,11 @@ class UndoableOperation:
current_page.layout.elements.clear() current_page.layout.elements.clear()
# Restore elements from serialized state # Restore elements from serialized state
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
for elem_data in state: for elem_data in state:
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
elem = ImageData() elem = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -315,7 +316,7 @@ class UndoableOperation:
current_page.layout.add_element(elem) current_page.layout.add_element(elem)
def undoable_operation(capture: str = "page_elements", description: str = None) -> Callable: def undoable_operation(capture: str = "page_elements", description: Optional[str] = None) -> Callable:
""" """
Convenience function for the UndoableOperation decorator. Convenience function for the UndoableOperation decorator.

View File

@ -30,11 +30,11 @@ def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
angle = pil_rotation_90 * 90 angle = pil_rotation_90 * 90
if angle == 90: if angle == 90:
return image.transpose(Image.ROTATE_270) # CCW 90 = rotate right return image.transpose(Image.Transpose.ROTATE_270) # CCW 90 = rotate right
elif angle == 180: elif angle == 180:
return image.transpose(Image.ROTATE_180) return image.transpose(Image.Transpose.ROTATE_180)
elif angle == 270: elif angle == 270:
return image.transpose(Image.ROTATE_90) # CCW 270 = rotate left return image.transpose(Image.Transpose.ROTATE_90) # CCW 270 = rotate left
return image return image

View File

@ -5,7 +5,7 @@ Displays loading progress in the lower-right corner of the window.
""" """
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty # type: ignore[attr-defined]
from PyQt6.QtGui import QPalette, QColor from PyQt6.QtGui import QPalette, QColor
@ -98,8 +98,8 @@ class LoadingWidget(QWidget):
"""Get opacity for animation""" """Get opacity for animation"""
return self._opacity return self._opacity
@opacity.setter @opacity.setter # type: ignore[no-redef]
def opacity(self, value): def opacity(self, value: float) -> None:
"""Set opacity for animation""" """Set opacity for animation"""
self._opacity = value self._opacity = value
self.setWindowOpacity(value) self.setWindowOpacity(value)

View File

@ -79,7 +79,7 @@ class MergeManager:
print("MergeManager: One or both projects lack project_id, assuming concatenation") print("MergeManager: One or both projects lack project_id, assuming concatenation")
return False return False
return project_a_id == project_b_id return bool(project_a_id == project_b_id)
def detect_conflicts( def detect_conflicts(
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any] self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any]
@ -303,7 +303,7 @@ class MergeManager:
# Note: If timestamps differ, we assume one version modified it and the other didn't. # Note: If timestamps differ, we assume one version modified it and the other didn't.
# The _merge_non_conflicting_changes method will automatically use the newer version. # The _merge_non_conflicting_changes method will automatically use the newer version.
def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[str, Any]: def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[int, str]:
""" """
Automatically resolve conflicts based on a strategy. Automatically resolve conflicts based on a strategy.

View File

@ -4,17 +4,31 @@ Async loading mixin for non-blocking image loading and PDF generation.
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import TYPE_CHECKING, Optional
import logging import logging
from PyQt6.QtCore import QObject from PyQt6.QtCore import QObject
from PyQt6.QtWidgets import QProgressDialog
from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AsyncLoadingMixin: class AsyncLoadingMixin:
# Type hints for expected attributes from mixing class
_pdf_progress_dialog: Optional[QProgressDialog]
def update(self) -> None:
"""Expected from QWidget"""
...
def window(self) -> "QMainWindow":
"""Expected from QWidget"""
...
""" """
Mixin to add async loading capabilities to GLWidget. Mixin to add async loading capabilities to GLWidget.

View File

@ -2,8 +2,8 @@
Base mixin providing shared application state access Base mixin providing shared application state access
""" """
from typing import Optional from typing import Any, Optional, cast
from PyQt6.QtWidgets import QStatusBar, QMessageBox from PyQt6.QtWidgets import QStatusBar, QMessageBox, QWidget
class ApplicationStateMixin: class ApplicationStateMixin:
@ -44,7 +44,7 @@ class ApplicationStateMixin:
"""Access to status bar""" """Access to status bar"""
if not hasattr(self, "_status_bar"): if not hasattr(self, "_status_bar"):
raise AttributeError("MainWindow must set _status_bar attribute") raise AttributeError("MainWindow must set _status_bar attribute")
return self._status_bar return cast(QStatusBar, self._status_bar)
@property @property
def template_manager(self): def template_manager(self):
@ -117,7 +117,7 @@ class ApplicationStateMixin:
""" """
if not self.project or not self.project.pages: if not self.project or not self.project.pages:
return -1 return -1
return self.gl_widget.current_page_index return int(self.gl_widget.current_page_index)
def show_status(self, message: str, timeout: int = 2000): def show_status(self, message: str, timeout: int = 2000):
""" """
@ -138,7 +138,7 @@ class ApplicationStateMixin:
title: Dialog title title: Dialog title
message: Error message message: Error message
""" """
QMessageBox.critical(self, title, message) QMessageBox.critical(cast(QWidget, self), title, message)
def show_warning(self, title: str, message: str): def show_warning(self, title: str, message: str):
""" """
@ -148,7 +148,7 @@ class ApplicationStateMixin:
title: Dialog title title: Dialog title
message: Warning message message: Warning message
""" """
QMessageBox.warning(self, title, message) QMessageBox.warning(cast(QWidget, self), title, message)
def show_info(self, title: str, message: str): def show_info(self, title: str, message: str):
""" """
@ -158,7 +158,7 @@ class ApplicationStateMixin:
title: Dialog title title: Dialog title
message: Information message message: Information message
""" """
QMessageBox.information(self, title, message) QMessageBox.information(cast(QWidget, self), title, message)
def require_page(self, show_warning: bool = True) -> bool: def require_page(self, show_warning: bool = True) -> bool:
""" """

View File

@ -2,18 +2,31 @@
Element manipulation mixin for GLWidget - handles element transformations Element manipulation mixin for GLWidget - handles element transformations
""" """
from typing import Optional, Tuple from typing import TYPE_CHECKING, Any, Optional, Tuple
if TYPE_CHECKING:
from pyPhotoAlbum.models import BaseLayoutElement
from PyQt6.QtWidgets import QMainWindow
class ElementManipulationMixin: class ElementManipulationMixin:
""" # Type hints for expected attributes from mixing class
Mixin providing element transformation functionality. selected_element: Optional["BaseLayoutElement"]
drag_start_pos: Optional[Tuple[float, float]]
drag_start_element_pos: Optional[Tuple[float, float]]
This mixin handles resizing, rotating, and moving elements, including def window(self) -> "QMainWindow":
snapping support and cross-page element transfers. """Expected from QWidget"""
""" ...
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""
Initialize element manipulation mixin.
This mixin provides element transformation functionality including
resizing, rotating, moving elements, snapping support and cross-page
element transfers.
"""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Resize state # Resize state

View File

@ -2,11 +2,21 @@
Element selection mixin for GLWidget - handles element selection and hit detection Element selection mixin for GLWidget - handles element selection and hit detection
""" """
from typing import Optional, Set from typing import Any, TYPE_CHECKING, Optional, Set
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
from pyPhotoAlbum.models import BaseLayoutElement from pyPhotoAlbum.models import BaseLayoutElement
class ElementSelectionMixin: class ElementSelectionMixin:
# Type hints for expected attributes from mixing class
_page_renderers: list
def window(self) -> "QMainWindow":
"""Expected from QWidget"""
...
""" """
Mixin providing element selection and hit detection functionality. Mixin providing element selection and hit detection functionality.
@ -73,8 +83,8 @@ class ElementSelectionMixin:
# Simple bounds check (no rotation transformation needed - images are already rotated) # Simple bounds check (no rotation transformation needed - images are already rotated)
if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh: if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
# Store the renderer with the element for later use # Store the renderer with the element for later use
element._page_renderer = renderer element._page_renderer = renderer # type: ignore[attr-defined]
element._parent_page = page element._parent_page = page # type: ignore[attr-defined]
return element return element
return None return None
@ -103,7 +113,7 @@ class ElementSelectionMixin:
if not hasattr(self.selected_element, "_page_renderer"): if not hasattr(self.selected_element, "_page_renderer"):
return None return None
renderer = self.selected_element._page_renderer renderer: Any = self.selected_element._page_renderer # type: ignore[attr-defined]
# Get element position and size in page-local coordinates # Get element position and size in page-local coordinates
elem_x, elem_y = self.selected_element.position elem_x, elem_y = self.selected_element.position

View File

@ -2,11 +2,16 @@
Image pan mixin for GLWidget - handles panning images within frames Image pan mixin for GLWidget - handles panning images within frames
""" """
from typing import Optional, Tuple from typing import TYPE_CHECKING, Optional, Tuple
from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.models import ImageData
class ImagePanMixin: class ImagePanMixin:
# Type hints for expected attributes from mixing class
drag_start_pos: Optional[Tuple[float, float]]
zoom_level: float
""" """
Mixin providing image panning functionality. Mixin providing image panning functionality.
@ -21,7 +26,7 @@ class ImagePanMixin:
self.image_pan_mode: bool = False # True when Control+dragging an ImageData element self.image_pan_mode: bool = False # True when Control+dragging an ImageData element
self.image_pan_start_crop: Optional[Tuple[float, float, float, float]] = None # Starting crop_info self.image_pan_start_crop: Optional[Tuple[float, float, float, float]] = None # Starting crop_info
def _handle_image_pan_move(self, x: float, y: float, element: ImageData): def _handle_image_pan_move(self, x: float, y: float, element: "ImageData"):
""" """
Handle image panning within a frame during mouse move. Handle image panning within a frame during mouse move.

View File

@ -3,6 +3,7 @@ File operations mixin for pyPhotoAlbum
""" """
import os import os
from typing import TYPE_CHECKING, Optional, cast
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QFileDialog, QFileDialog,
@ -18,6 +19,8 @@ from PyQt6.QtWidgets import (
QButtonGroup, QButtonGroup,
QLineEdit, QLineEdit,
QTextEdit, QTextEdit,
QWidget,
QMessageBox,
) )
from pyPhotoAlbum.decorators import ribbon_action, numerical_input from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
@ -32,6 +35,19 @@ from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class FileOperationsMixin: class FileOperationsMixin:
"""Mixin providing file-related operations""" """Mixin providing file-related operations"""
# Type hints for expected attributes from mixing class
def show_status(self, message: str, timeout: int = 0) -> None:
"""Expected from ApplicationStateMixin"""
...
def show_error(self, title: str, message: str) -> None:
"""Expected from ApplicationStateMixin"""
...
def resolve_asset_path(self, path: str) -> Optional[str]:
"""Expected from asset path mixin"""
...
@ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N") @ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N")
def new_project(self): def new_project(self):
"""Create a new project with initial setup dialog""" """Create a new project with initial setup dialog"""
@ -324,7 +340,7 @@ class FileOperationsMixin:
asset_list = "\n".join(f"{path}" for path in missing_assets[:5]) asset_list = "\n".join(f"{path}" for path in missing_assets[:5])
asset_list += f"\n ... and {len(missing_assets) - 5} more" asset_list += f"\n ... and {len(missing_assets) - 5} more"
msg = QMessageBox(self) msg = QMessageBox(cast(QWidget, self))
msg.setIcon(QMessageBox.Icon.Warning) msg.setIcon(QMessageBox.Icon.Warning)
msg.setWindowTitle("Missing Assets") msg.setWindowTitle("Missing Assets")
msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:") msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")

View File

@ -2,10 +2,24 @@
Page navigation mixin for GLWidget - handles page detection and ghost pages Page navigation mixin for GLWidget - handles page detection and ghost pages
""" """
from typing import Optional, Tuple, List from typing import TYPE_CHECKING, Optional, Tuple, List
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
class PageNavigationMixin: class PageNavigationMixin:
# Type hints for expected attributes from mixing class
pan_offset: Tuple[float, float]
zoom_level: float
def update(self) -> None:
"""Expected from QWidget"""
...
def window(self) -> "QMainWindow":
"""Expected from QWidget"""
...
""" """
Mixin providing page navigation and ghost page functionality. Mixin providing page navigation and ghost page functionality.

View File

@ -2,7 +2,11 @@
Page layout and template system for pyPhotoAlbum Page layout and template system for pyPhotoAlbum
""" """
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from PyQt6.QtWidgets import QWidget
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.snapping import SnappingSystem from pyPhotoAlbum.snapping import SnappingSystem
from pyPhotoAlbum.gl_imports import ( from pyPhotoAlbum.gl_imports import (
@ -45,6 +49,7 @@ class PageLayout:
self.background_color = (1.0, 1.0, 1.0) # White background self.background_color = (1.0, 1.0, 1.0) # White background
self.snapping_system = SnappingSystem() self.snapping_system = SnappingSystem()
self.show_snap_lines = True # Show snap lines while dragging self.show_snap_lines = True # Show snap lines while dragging
self._parent_widget: Optional["QWidget"] = None # Set by renderer
def add_element(self, element: BaseLayoutElement): def add_element(self, element: BaseLayoutElement):
"""Add a layout element to the page""" """Add a layout element to the page"""
@ -251,9 +256,10 @@ class PageLayout:
# Deserialize elements and sort by z_index to establish list order # Deserialize elements and sort by z_index to establish list order
# This ensures backward compatibility with projects that used z_index # This ensures backward compatibility with projects that used z_index
elem_list = [] elem_list: List[BaseLayoutElement] = []
for elem_data in data.get("elements", []): for elem_data in data.get("elements", []):
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
elem = ImageData() elem = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":

View File

@ -3,7 +3,7 @@ PDF export functionality for pyPhotoAlbum
""" """
import os import os
from typing import List, Tuple, Optional from typing import Any, List, Tuple, Optional, Union
from dataclasses import dataclass from dataclasses import dataclass
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
@ -44,7 +44,7 @@ class SplitRenderParams:
"""Parameters for rendering a split element""" """Parameters for rendering a split element"""
canvas: canvas.Canvas canvas: canvas.Canvas
element: any element: Any
x_offset_mm: float x_offset_mm: float
split_line_mm: float split_line_mm: float
page_width_pt: float page_width_pt: float
@ -71,7 +71,7 @@ class PDFExporter:
""" """
self.project = project self.project = project
self.export_dpi = export_dpi self.export_dpi = export_dpi
self.warnings = [] self.warnings: List[str] = []
self.current_pdf_page = 1 self.current_pdf_page = 1
def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]: def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
@ -293,7 +293,7 @@ class PDFExporter:
x_offset_mm: float, x_offset_mm: float,
page_width_pt: float, page_width_pt: float,
page_height_pt: float, page_height_pt: float,
page_number: int, page_number: Union[int, str],
): ):
""" """
Render a single element on the PDF canvas. Render a single element on the PDF canvas.

View File

@ -6,7 +6,8 @@ import os
import math import math
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Tuple from tempfile import TemporaryDirectory
from typing import List, Dict, Any, Optional, Tuple, Union
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory from pyPhotoAlbum.commands import CommandHistory
from pyPhotoAlbum.asset_manager import AssetManager from pyPhotoAlbum.asset_manager import AssetManager
@ -170,7 +171,7 @@ class Project:
# Temporary directory management (if loaded from .ppz) # Temporary directory management (if loaded from .ppz)
# Using TemporaryDirectory instance that auto-cleans on deletion # Using TemporaryDirectory instance that auto-cleans on deletion
self._temp_dir = None self._temp_dir: Optional[TemporaryDirectory[str]] = None
# Global snapping settings (apply to all pages) # Global snapping settings (apply to all pages)
self.snap_to_grid = False self.snap_to_grid = False
@ -323,7 +324,7 @@ class Project:
else: else:
return f"Page {adjusted_num}" return f"Page {adjusted_num}"
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]: def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Optional["Page"], int]]:
""" """
Calculate page layout including ghost pages for alignment. Calculate page layout including ghost pages for alignment.
Excludes cover from spread calculations. Excludes cover from spread calculations.

View File

@ -41,7 +41,7 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
} }
""" """
# Structure to collect actions by tab and group # Structure to collect actions by tab and group
tabs = defaultdict(lambda: defaultdict(list)) tabs: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(lambda: defaultdict(list))
# Scan all methods in the class and its bases (mixins) # Scan all methods in the class and its bases (mixins)
for attr_name in dir(window_class): for attr_name in dir(window_class):

View File

@ -4,7 +4,7 @@ Provides grid snapping, edge snapping, and custom guide snapping
""" """
import math import math
from typing import List, Tuple, Optional from typing import Any, Dict, List, Tuple, Optional
from dataclasses import dataclass from dataclasses import dataclass
@ -36,7 +36,7 @@ class SnapResizeParams:
resize_handle: str resize_handle: str
page_size: Tuple[float, float] page_size: Tuple[float, float]
dpi: int = 300 dpi: int = 300
project: Optional[any] = None project: Optional[Any] = None
class SnappingSystem: class SnappingSystem:
@ -333,12 +333,12 @@ class SnappingSystem:
snap_to_guides = self.snap_to_guides snap_to_guides = self.snap_to_guides
grid_size_mm = self.grid_size_mm grid_size_mm = self.grid_size_mm
snap_candidates = [] snap_candidates: List[Tuple[float, float]] = []
# 1. Page edge snapping # 1. Page edge snapping
if snap_to_edges: if snap_to_edges:
# Snap to start edge (0) # Snap to start edge (0)
snap_candidates.append((0, abs(edge_position - 0))) snap_candidates.append((0.0, abs(edge_position - 0)))
# Snap to end edge # Snap to end edge
page_size_px = page_size_mm * dpi / 25.4 page_size_px = page_size_mm * dpi / 25.4
@ -387,12 +387,12 @@ class SnappingSystem:
Returns: Returns:
Snapped position in pixels Snapped position in pixels
""" """
snap_candidates = [] snap_candidates: List[Tuple[float, float]] = []
# 1. Page edge snapping # 1. Page edge snapping
if self.snap_to_edges: if self.snap_to_edges:
# Snap to start edge (0) # Snap to start edge (0)
snap_candidates.append((0, abs(position - 0))) snap_candidates.append((0.0, abs(position - 0)))
# Snap to end edge # Snap to end edge
page_size_px = page_size_mm * dpi / 25.4 page_size_px = page_size_mm * dpi / 25.4
@ -453,20 +453,20 @@ class SnappingSystem:
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
result = {"grid": [], "edges": [], "guides": []} result: Dict[str, List[Tuple[str, float]]] = {"grid": [], "edges": [], "guides": []}
# Grid lines # Grid lines
if self.snap_to_grid: if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4 grid_size_px = self.grid_size_mm * dpi / 25.4
# Vertical grid lines # Vertical grid lines
x = 0 x: float = 0
while x <= page_width_px: while x <= page_width_px:
result["grid"].append(("vertical", x)) result["grid"].append(("vertical", x))
x += grid_size_px x += grid_size_px
# Horizontal grid lines # Horizontal grid lines
y = 0 y: float = 0
while y <= page_height_px: while y <= page_height_px:
result["grid"].append(("horizontal", y)) result["grid"].append(("horizontal", y))
y += grid_size_px y += grid_size_px

View File

@ -47,6 +47,7 @@ class Template:
# Deserialize elements # Deserialize elements
for elem_data in data.get("elements", []): for elem_data in data.get("elements", []):
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "placeholder": if elem_type == "placeholder":
elem = PlaceholderData() elem = PlaceholderData()
elif elem_type == "textbox": elif elem_type == "textbox":
@ -316,9 +317,10 @@ class TemplateManager:
offset_x = (to_width - from_width) / 2 offset_x = (to_width - from_width) / 2
offset_y = (to_height - from_height) / 2 offset_y = (to_height - from_height) / 2
scaled_elements = [] scaled_elements: List[BaseLayoutElement] = []
for element in elements: for element in elements:
# Create a new element of the same type # Create a new element of the same type
new_elem: BaseLayoutElement
if isinstance(element, PlaceholderData): if isinstance(element, PlaceholderData):
new_elem = PlaceholderData( new_elem = PlaceholderData(
placeholder_type=element.placeholder_type, default_content=element.default_content placeholder_type=element.placeholder_type, default_content=element.default_content

View File

@ -0,0 +1,414 @@
"""
Tests for text_edit_dialog module
"""
import pytest
from unittest.mock import Mock, patch
from PyQt6.QtGui import QColor
class TestTextEditDialogInit:
"""Tests for TextEditDialog initialization"""
def test_init_basic(self, qtbot):
"""Test basic dialog initialization"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = "Hello World"
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
assert dialog.text_element == mock_text_element
assert dialog.windowTitle() == "Edit Text"
def test_init_loads_text_content(self, qtbot):
"""Test that init loads text content from element"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = "Test content here"
mock_text_element.font_settings = {"family": "Arial", "size": 14, "color": (0, 0, 0)}
mock_text_element.alignment = "center"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
assert dialog.text_edit.toPlainText() == "Test content here"
def test_init_loads_font_family(self, qtbot):
"""Test that init loads font family from element"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Georgia", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
assert dialog.font_combo.currentText() == "Georgia"
def test_init_loads_font_size(self, qtbot):
"""Test that init loads font size from element"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 24, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
assert dialog.font_size_spin.value() == 24
def test_init_loads_color_255_range(self, qtbot):
"""Test that init loads color in 0-255 range"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (255, 128, 64)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
assert dialog.current_color.red() == 255
assert dialog.current_color.green() == 128
assert dialog.current_color.blue() == 64
def test_init_loads_color_01_range(self, qtbot):
"""Test that init loads color in 0-1 range"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (1.0, 0.5, 0.0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
assert dialog.current_color.red() == 255
assert dialog.current_color.green() == 127
assert dialog.current_color.blue() == 0
def test_init_loads_alignment(self, qtbot):
"""Test that init loads alignment from element"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "right"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
assert dialog.alignment_combo.currentText() == "right"
def test_init_handles_unknown_font(self, qtbot):
"""Test that init handles unknown font family gracefully"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "UnknownFont", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
# Should not crash, will keep default selection
assert dialog.font_combo.currentIndex() >= 0
def test_init_handles_default_values(self, qtbot):
"""Test that init handles missing font settings"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {} # Empty settings
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
# Should use defaults
assert dialog.font_size_spin.value() == 12
class TestTextEditDialogUI:
"""Tests for TextEditDialog UI elements"""
def test_font_size_range(self, qtbot):
"""Test that font size spinner has correct range"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
assert dialog.font_size_spin.minimum() == 6
assert dialog.font_size_spin.maximum() == 72
def test_alignment_options(self, qtbot):
"""Test that alignment combo has correct options"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
options = [dialog.alignment_combo.itemText(i) for i in range(dialog.alignment_combo.count())]
assert "left" in options
assert "center" in options
assert "right" in options
def test_font_options(self, qtbot):
"""Test that font combo has expected fonts"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
fonts = [dialog.font_combo.itemText(i) for i in range(dialog.font_combo.count())]
assert "Arial" in fonts
assert "Times New Roman" in fonts
assert "Courier New" in fonts
class TestChooseColor:
"""Tests for _choose_color method"""
def test_choose_color_updates_current_color(self, qtbot):
"""Test that choosing color updates current_color"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
# Mock QColorDialog to return a specific color
with patch("pyPhotoAlbum.text_edit_dialog.QColorDialog.getColor") as mock_get_color:
mock_color = QColor(255, 0, 0)
mock_get_color.return_value = mock_color
dialog._choose_color()
assert dialog.current_color.red() == 255
assert dialog.current_color.green() == 0
assert dialog.current_color.blue() == 0
def test_choose_color_invalid_does_not_update(self, qtbot):
"""Test that invalid color choice does not update current_color"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
original_color = QColor(dialog.current_color)
# Mock QColorDialog to return invalid color (user cancelled)
with patch("pyPhotoAlbum.text_edit_dialog.QColorDialog.getColor") as mock_get_color:
mock_get_color.return_value = QColor() # Invalid color
dialog._choose_color()
# Color should remain unchanged
assert dialog.current_color == original_color
class TestUpdateColorButton:
"""Tests for _update_color_button method"""
def test_update_color_button_dark_color(self, qtbot):
"""Test color button with dark color uses white text"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
dialog.current_color = QColor(0, 0, 0) # Black
dialog._update_color_button()
stylesheet = dialog.color_button.styleSheet()
assert "color: white" in stylesheet
def test_update_color_button_light_color(self, qtbot):
"""Test color button with light color uses black text"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (255, 255, 255)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
dialog.current_color = QColor(255, 255, 255) # White
dialog._update_color_button()
stylesheet = dialog.color_button.styleSheet()
assert "color: black" in stylesheet
class TestGetValues:
"""Tests for get_values method"""
def test_get_values_returns_text(self, qtbot):
"""Test get_values returns text content"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = "Original"
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
dialog.text_edit.setPlainText("New text content")
values = dialog.get_values()
assert values["text_content"] == "New text content"
def test_get_values_returns_font_settings(self, qtbot):
"""Test get_values returns font settings"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
dialog.font_combo.setCurrentText("Georgia")
dialog.font_size_spin.setValue(18)
dialog.current_color = QColor(100, 150, 200)
values = dialog.get_values()
assert values["font_settings"]["family"] == "Georgia"
assert values["font_settings"]["size"] == 18
assert values["font_settings"]["color"] == (100, 150, 200)
def test_get_values_returns_alignment(self, qtbot):
"""Test get_values returns alignment"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
dialog.alignment_combo.setCurrentText("center")
values = dialog.get_values()
assert values["alignment"] == "center"
def test_get_values_complete_structure(self, qtbot):
"""Test get_values returns complete structure"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
mock_text_element = Mock()
mock_text_element.text_content = "Test"
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
values = dialog.get_values()
# Check structure
assert "text_content" in values
assert "font_settings" in values
assert "alignment" in values
assert "family" in values["font_settings"]
assert "size" in values["font_settings"]
assert "color" in values["font_settings"]
class TestDialogButtons:
"""Tests for dialog buttons"""
def test_cancel_button_rejects(self, qtbot):
"""Test that cancel button rejects the dialog"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
from PyQt6.QtWidgets import QPushButton
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
# Find cancel button
buttons = dialog.findChildren(QPushButton)
cancel_button = next(b for b in buttons if b.text() == "Cancel")
# Click should reject
with qtbot.waitSignal(dialog.rejected, timeout=1000):
cancel_button.click()
def test_ok_button_accepts(self, qtbot):
"""Test that OK button accepts the dialog"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
from PyQt6.QtWidgets import QPushButton
mock_text_element = Mock()
mock_text_element.text_content = ""
mock_text_element.font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
mock_text_element.alignment = "left"
dialog = TextEditDialog(mock_text_element)
qtbot.addWidget(dialog)
# Find OK button
buttons = dialog.findChildren(QPushButton)
ok_button = next(b for b in buttons if b.text() == "OK")
# Click should accept
with qtbot.waitSignal(dialog.accepted, timeout=1000):
ok_button.click()