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 enum import Enum
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
import threading
@ -230,7 +231,7 @@ class AsyncImageLoader(QObject):
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader")
# 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._active_tasks: Dict[Path, asyncio.Task] = {}
self._next_request_id = 0
@ -373,7 +374,7 @@ class AsyncImageLoader(QObject):
self._pending_requests.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).
@ -528,7 +529,7 @@ class AsyncPDFGenerator(QObject):
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator")
# Export state
self._current_export: Optional[asyncio.Task] = None
self._current_export: Optional[Future[Any]] = None
self._cancel_requested = False
self._lock = threading.Lock()
self._shutdown = False
@ -675,7 +676,7 @@ class AsyncPDFGenerator(QObject):
with self._lock:
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.

View File

@ -10,7 +10,7 @@ import json
import shutil
from pathlib import Path
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
@ -186,7 +186,7 @@ class AutosaveManager:
max_count: Maximum number of checkpoints to keep per project
"""
now = datetime.now()
checkpoints_by_project = {}
checkpoints_by_project: Dict[str, List[Tuple[Path, dict]]] = {}
# Group checkpoints by project
for checkpoint_path, metadata in self.list_checkpoints():

View File

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

View File

@ -4,7 +4,7 @@ Decorator system for pyPhotoAlbum ribbon UI
import copy
from functools import wraps
from typing import Optional, Callable
from typing import Any, Optional, Callable
class RibbonAction:
@ -75,11 +75,11 @@ class RibbonAction:
"""
@wraps(func)
def wrapper(*args, **kwargs):
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
# Store metadata on wrapper function
wrapper._ribbon_action = {
wrapper._ribbon_action = { # type: ignore[attr-defined]
"label": self.label,
"tooltip": self.tooltip,
"tab": self.tab,
@ -178,11 +178,11 @@ class NumericalInput:
"""
@wraps(func)
def wrapper(*args, **kwargs):
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
# Store metadata on wrapper function
wrapper._numerical_input = {"fields": self.fields}
wrapper._numerical_input = {"fields": self.fields} # type: ignore[attr-defined]
return wrapper
@ -218,7 +218,7 @@ class UndoableOperation:
# 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.
@ -298,10 +298,11 @@ class UndoableOperation:
current_page.layout.elements.clear()
# 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:
elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "image":
elem = ImageData()
elif elem_type == "placeholder":
@ -315,7 +316,7 @@ class UndoableOperation:
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.

View File

@ -30,11 +30,11 @@ def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
angle = pil_rotation_90 * 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:
return image.transpose(Image.ROTATE_180)
return image.transpose(Image.Transpose.ROTATE_180)
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

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.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty # type: ignore[attr-defined]
from PyQt6.QtGui import QPalette, QColor
@ -98,8 +98,8 @@ class LoadingWidget(QWidget):
"""Get opacity for animation"""
return self._opacity
@opacity.setter
def opacity(self, value):
@opacity.setter # type: ignore[no-redef]
def opacity(self, value: float) -> None:
"""Set opacity for animation"""
self._opacity = value
self.setWindowOpacity(value)

View File

@ -79,7 +79,7 @@ class MergeManager:
print("MergeManager: One or both projects lack project_id, assuming concatenation")
return False
return project_a_id == project_b_id
return bool(project_a_id == project_b_id)
def detect_conflicts(
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.
# 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.

View File

@ -4,17 +4,31 @@ Async loading mixin for non-blocking image loading and PDF generation.
import os
from pathlib import Path
from typing import Optional
from typing import TYPE_CHECKING, Optional
import logging
from PyQt6.QtCore import QObject
from PyQt6.QtWidgets import QProgressDialog
from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
logger = logging.getLogger(__name__)
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.

View File

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

View File

@ -2,18 +2,31 @@
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:
"""
Mixin providing element transformation functionality.
# Type hints for expected attributes from mixing class
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
snapping support and cross-page element transfers.
"""
def window(self) -> "QMainWindow":
"""Expected from QWidget"""
...
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)
# Resize state

View File

@ -2,11 +2,21 @@
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
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.
@ -73,8 +83,8 @@ class ElementSelectionMixin:
# Simple bounds check (no rotation transformation needed - images are already rotated)
if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
# Store the renderer with the element for later use
element._page_renderer = renderer
element._parent_page = page
element._page_renderer = renderer # type: ignore[attr-defined]
element._parent_page = page # type: ignore[attr-defined]
return element
return None
@ -103,7 +113,7 @@ class ElementSelectionMixin:
if not hasattr(self.selected_element, "_page_renderer"):
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
elem_x, elem_y = self.selected_element.position

View File

@ -2,11 +2,16 @@
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
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.
@ -21,7 +26,7 @@ class ImagePanMixin:
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
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.

View File

@ -3,6 +3,7 @@ File operations mixin for pyPhotoAlbum
"""
import os
from typing import TYPE_CHECKING, Optional, cast
from PyQt6.QtWidgets import (
QFileDialog,
@ -18,6 +19,8 @@ from PyQt6.QtWidgets import (
QButtonGroup,
QLineEdit,
QTextEdit,
QWidget,
QMessageBox,
)
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project, Page
@ -32,6 +35,19 @@ from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class FileOperationsMixin:
"""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")
def new_project(self):
"""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 += f"\n ... and {len(missing_assets) - 5} more"
msg = QMessageBox(self)
msg = QMessageBox(cast(QWidget, self))
msg.setIcon(QMessageBox.Icon.Warning)
msg.setWindowTitle("Missing Assets")
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
"""
from typing import Optional, Tuple, List
from typing import TYPE_CHECKING, Optional, Tuple, List
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
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.

View File

@ -2,7 +2,11 @@
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.snapping import SnappingSystem
from pyPhotoAlbum.gl_imports import (
@ -45,6 +49,7 @@ class PageLayout:
self.background_color = (1.0, 1.0, 1.0) # White background
self.snapping_system = SnappingSystem()
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):
"""Add a layout element to the page"""
@ -251,9 +256,10 @@ class PageLayout:
# Deserialize elements and sort by z_index to establish list order
# This ensures backward compatibility with projects that used z_index
elem_list = []
elem_list: List[BaseLayoutElement] = []
for elem_data in data.get("elements", []):
elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "image":
elem = ImageData()
elif elem_type == "placeholder":

View File

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

View File

@ -6,7 +6,8 @@ import os
import math
import uuid
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.commands import CommandHistory
from pyPhotoAlbum.asset_manager import AssetManager
@ -170,7 +171,7 @@ class Project:
# Temporary directory management (if loaded from .ppz)
# 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)
self.snap_to_grid = False
@ -323,7 +324,7 @@ class Project:
else:
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.
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
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)
for attr_name in dir(window_class):

View File

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

View File

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