pyPhotoAlbum/tests/test_page_layout_extended.py
Duncan Tourolle 254a95d83c
All checks were successful
Python CI / test (push) Successful in 1m24s
Lint / lint (push) Successful in 1m13s
Tests / test (3.11) (push) Successful in 1m37s
Tests / test (3.12) (push) Successful in 1m41s
Tests / test (3.13) (push) Successful in 1m34s
Tests / test (3.14) (push) Successful in 1m12s
fix tests
2025-12-13 17:33:12 +01:00

657 lines
22 KiB
Python

"""
Extended tests for PageLayout to improve coverage
Tests rendering logic, snapping, and edge cases
"""
import pytest
from unittest.mock import Mock, MagicMock, patch, call
from pyPhotoAlbum.page_layout import PageLayout, GridLayout
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.snapping import SnappingSystem
class TestPageLayoutRendering:
"""Test PageLayout rendering methods"""
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_basic_empty_page(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering empty page calls OpenGL functions correctly"""
layout = PageLayout(width=210, height=297)
# Render with default DPI
layout.render(dpi=300)
# Verify OpenGL depth test was disabled then re-enabled
assert mock_disable.call_count >= 1
assert mock_enable.call_count >= 1
# Verify colors were set (shadow, background, border)
assert mock_color3f.call_count >= 3
# Verify vertices were drawn (quads for shadow, background, line loop for border)
assert mock_vertex.call_count >= 8
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_facing_page_draws_center_line(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering facing page draws center divider line"""
layout = PageLayout(width=210, height=297, is_facing_page=True)
layout.render(dpi=300)
# Verify line width was set for center line
assert any(call(1.5) in mock_linewidth.call_args_list for call in mock_linewidth.call_args_list)
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_with_elements_calls_element_render(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering page with elements calls render on each element"""
layout = PageLayout()
# Add elements with mock render methods
elem1 = ImageData(image_path="test1.jpg", x=10, y=20)
elem2 = PlaceholderData(x=30, y=40)
elem3 = TextBoxData(text_content="Hello", x=50, y=60)
elem1.render = Mock()
elem2.render = Mock()
elem3.render = Mock()
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
layout.render(dpi=300)
# Verify each element's render was called
elem1.render.assert_called_once()
elem2.render.assert_called_once()
elem3.render.assert_called_once()
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_with_image_async_loading_requested(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering image without texture triggers async load request"""
layout = PageLayout()
# Create mock parent widget with async loader
mock_parent = Mock()
mock_parent.async_image_loader = Mock()
mock_parent.request_image_load = Mock()
layout._parent_widget = mock_parent
# Add image element without texture
elem = ImageData(image_path="test.jpg", x=10, y=20)
elem.render = Mock()
elem._async_load_requested = False
layout.add_element(elem)
layout.render(dpi=300)
# Verify async load was requested
assert elem._async_load_requested is True
assert elem._async_loading is True
mock_parent.request_image_load.assert_called_once()
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_with_different_dpi_scales_correctly(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering with different DPI values"""
layout = PageLayout(width=210, height=297)
# Render with different DPIs
layout.render(dpi=72)
layout.render(dpi=150)
layout.render(dpi=600)
# Should not raise any errors
assert mock_vertex.call_count > 0
class TestPageLayoutSnapLines:
"""Test snap line rendering"""
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_snap_lines_with_project_settings(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test snap lines use project settings when available"""
layout = PageLayout()
# Create mock project with global snapping settings
mock_project = Mock()
mock_project.snap_to_grid = True
mock_project.snap_to_edges = True
mock_project.snap_to_guides = True
mock_project.grid_size_mm = 10.0
mock_project.snap_threshold_mm = 5.0
mock_project.show_grid = True
mock_project.show_snap_lines = True
layout.render(dpi=300, project=mock_project)
# Verify blending was enabled for transparent lines
# Check that glEnable was called (for GL_BLEND)
assert mock_enable.call_count > 0
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_snap_lines_fallback_to_local_settings(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test snap lines fall back to local settings when no project"""
layout = PageLayout()
layout.snapping_system.snap_to_grid = True
layout.snapping_system.grid_size_mm = 15.0
# Render without project
layout.render(dpi=300, project=None)
# Should complete without error
assert mock_vertex.call_count > 0
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_snap_lines_with_guides(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering snap lines with custom guides"""
layout = PageLayout()
# Add custom guides using the proper method (position, orientation)
layout.snapping_system.add_guide(100, "vertical")
layout.snapping_system.add_guide(150, "horizontal")
mock_project = Mock()
mock_project.snap_to_grid = False
mock_project.snap_to_edges = False
mock_project.snap_to_guides = True
mock_project.grid_size_mm = 10.0
mock_project.snap_threshold_mm = 5.0
mock_project.show_grid = False
mock_project.show_snap_lines = True
layout.render(dpi=300, project=mock_project)
# Cyan color should be used for guides (0.0, 0.7, 0.9)
# Check if cyan color was set (at least once)
cyan_calls = [call for call in mock_color3f.call_args_list if call[0] == (0.0, 0.7, 0.9)]
assert len(cyan_calls) > 0, "Cyan color for guides should be set"
class TestPageLayoutEdgeCases:
"""Test edge cases and error conditions"""
def test_page_layout_with_zero_dimensions(self):
"""Test page layout with very small dimensions"""
layout = PageLayout(width=0.1, height=0.1)
assert layout.size == (0.1, 0.1)
def test_page_layout_with_large_dimensions(self):
"""Test page layout with very large dimensions"""
layout = PageLayout(width=10000, height=10000)
assert layout.size == (10000, 10000)
def test_multiple_grid_layout_changes(self):
"""Test changing grid layout multiple times"""
layout = PageLayout()
grid1 = GridLayout(rows=2, columns=2)
grid2 = GridLayout(rows=3, columns=3)
grid3 = GridLayout(rows=4, columns=4)
layout.set_grid_layout(grid1)
assert layout.grid_layout == grid1
layout.set_grid_layout(grid2)
assert layout.grid_layout == grid2
layout.set_grid_layout(grid3)
assert layout.grid_layout == grid3
def test_deserialize_with_missing_snapping_system(self):
"""Test deserialization handles missing snapping_system gracefully"""
layout = PageLayout()
data = {
"size": (210, 297),
"elements": [],
# No snapping_system key
}
layout.deserialize(data)
# Should use existing snapping system
assert layout.snapping_system is not None
def test_deserialize_element_with_invalid_crop_info(self):
"""Test deserialization handles elements with missing optional fields"""
layout = PageLayout()
data = {
"size": (210, 297),
"elements": [
{
"type": "image",
"image_path": "test.jpg",
"position": (10, 20),
"size": (100, 100),
"rotation": 0,
"z_index": 0,
# Missing crop_info - should use default
}
],
}
layout.deserialize(data)
assert len(layout.elements) == 1
assert isinstance(layout.elements[0], ImageData)
def test_element_z_index_handling(self):
"""Test that elements maintain correct z-order"""
layout = PageLayout()
elem1 = ImageData(image_path="test1.jpg", x=10, y=20)
elem2 = ImageData(image_path="test2.jpg", x=30, y=40)
elem3 = ImageData(image_path="test3.jpg", x=50, y=60)
elem1.z_index = 10
elem2.z_index = 5
elem3.z_index = 15
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Elements added in order, z_index is just metadata
assert layout.elements[0] == elem1
assert layout.elements[1] == elem2
assert layout.elements[2] == elem3
class TestGridLayoutEdgeCases:
"""Test GridLayout edge cases"""
def test_grid_with_single_cell(self):
"""Test grid layout with single cell (1x1)"""
grid = GridLayout(rows=1, columns=1, spacing=5.0)
pos = grid.get_cell_position(0, 0, page_width=100, page_height=100)
size = grid.get_cell_size(page_width=100, page_height=100)
assert pos == (5.0, 5.0)
# (100 - 10) / 1 = 90
assert size == (90.0, 90.0)
def test_grid_with_no_spacing(self):
"""Test grid layout with zero spacing"""
grid = GridLayout(rows=2, columns=2, spacing=0.0)
pos = grid.get_cell_position(0, 0, page_width=100, page_height=100)
size = grid.get_cell_size(page_width=100, page_height=100)
assert pos == (0.0, 0.0)
assert size == (50.0, 50.0)
def test_grid_with_large_spacing(self):
"""Test grid layout with large spacing"""
grid = GridLayout(rows=2, columns=2, spacing=20.0)
size = grid.get_cell_size(page_width=200, page_height=200)
# (200 - 60) / 2 = 70
assert size == (70.0, 70.0)
def test_grid_merge_multiple_cells(self):
"""Test merging many cells"""
grid = GridLayout(rows=5, columns=5)
for row in range(3):
for col in range(3):
grid.merge_cells(row, col)
assert len(grid.merged_cells) == 9
def test_grid_cell_position_at_boundaries(self):
"""Test cell positions at grid boundaries"""
grid = GridLayout(rows=3, columns=3, spacing=10.0)
# Top-left corner
pos_tl = grid.get_cell_position(0, 0, page_width=300, page_height=300)
assert pos_tl[0] == 10.0
assert pos_tl[1] == 10.0
# Bottom-right corner (2, 2)
pos_br = grid.get_cell_position(2, 2, page_width=300, page_height=300)
# (300 - 40) / 3 = 86.67, x = 10 + 2 * (86.67 + 10) = 203.33
assert pos_br[0] == pytest.approx(203.333, rel=0.01)
assert pos_br[1] == pytest.approx(203.333, rel=0.01)
class TestPageLayoutSnappingIntegration:
"""Test integration with snapping system"""
def test_snapping_system_initialized(self):
"""Test that PageLayout initializes with snapping system"""
layout = PageLayout()
assert isinstance(layout.snapping_system, SnappingSystem)
def test_snapping_system_serialization(self):
"""Test that snapping system is included in serialization"""
layout = PageLayout()
layout.snapping_system.snap_to_grid = True
layout.snapping_system.grid_size_mm = 20.0
data = layout.serialize()
assert "snapping_system" in data
assert data["snapping_system"]["snap_to_grid"] is True
assert data["snapping_system"]["grid_size_mm"] == 20.0
def test_snapping_system_deserialization(self):
"""Test that snapping system is restored from serialization"""
layout = PageLayout()
# First create a layout with snapping settings
layout.snapping_system.snap_to_grid = True
layout.snapping_system.snap_to_edges = False
layout.snapping_system.snap_to_guides = True
layout.snapping_system.grid_size_mm = 25.0
layout.snapping_system.snap_threshold_mm = 3.0
layout.snapping_system.add_guide(100, "vertical")
layout.snapping_system.add_guide(150, "horizontal")
# Serialize and deserialize
data = layout.serialize()
restored = PageLayout()
restored.deserialize(data)
assert restored.snapping_system.snap_to_grid is True
assert restored.snapping_system.snap_to_edges is False
assert restored.snapping_system.snap_to_guides is True
assert restored.snapping_system.grid_size_mm == 25.0
assert restored.snapping_system.snap_threshold_mm == 3.0
assert len(restored.snapping_system.guides) == 2
class TestPageLayoutBackwardCompatibility:
"""Test backward compatibility with older data formats"""
def test_deserialize_without_base_width(self):
"""Test deserializing data without base_width field"""
layout = PageLayout()
data = {
"size": (200, 280),
# No base_width field
"elements": [],
}
layout.deserialize(data)
# Should default to width from size
assert layout.base_width == 200
def test_deserialize_without_is_facing_page(self):
"""Test deserializing data without is_facing_page field"""
layout = PageLayout()
data = {
"size": (210, 297),
# No is_facing_page field
"elements": [],
}
layout.deserialize(data)
# Should default to False
assert layout.is_facing_page is False
def test_deserialize_without_show_snap_lines(self):
"""Test deserializing data without show_snap_lines field"""
layout = PageLayout()
data = {
"size": (210, 297),
"elements": [],
# No show_snap_lines field
}
layout.deserialize(data)
# Should default to True
assert layout.show_snap_lines is True
class TestPageLayoutComplexScenarios:
"""Test complex real-world scenarios"""
def test_layout_with_many_elements(self):
"""Test layout with large number of elements"""
layout = PageLayout()
# Add 100 elements
for i in range(100):
elem = ImageData(image_path=f"test{i}.jpg", x=i * 10, y=i * 10)
elem.z_index = i
layout.add_element(elem)
assert len(layout.elements) == 100
# Serialize and deserialize
data = layout.serialize()
restored = PageLayout()
restored.deserialize(data)
assert len(restored.elements) == 100
# Elements should be sorted by z_index
for i in range(100):
assert restored.elements[i].z_index == i
def test_layout_with_mixed_element_types(self):
"""Test layout with all element types mixed"""
layout = PageLayout()
for i in range(10):
if i % 3 == 0:
elem = ImageData(image_path=f"img{i}.jpg", x=i * 20, y=i * 20)
elif i % 3 == 1:
elem = PlaceholderData(x=i * 20, y=i * 20)
else:
elem = TextBoxData(text_content=f"Text {i}", x=i * 20, y=i * 20)
elem.z_index = i
layout.add_element(elem)
data = layout.serialize()
restored = PageLayout()
restored.deserialize(data)
# Count element types
images = sum(1 for e in restored.elements if isinstance(e, ImageData))
placeholders = sum(1 for e in restored.elements if isinstance(e, PlaceholderData))
textboxes = sum(1 for e in restored.elements if isinstance(e, TextBoxData))
assert images == 4 # 0, 3, 6, 9
assert placeholders == 3 # 1, 4, 7
assert textboxes == 3 # 2, 5, 8
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_facing_page_with_elements(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering facing page with elements"""
layout = PageLayout(width=210, height=297, is_facing_page=True)
# Add elements on both sides
left_elem = ImageData(image_path="left.jpg", x=50, y=100)
right_elem = ImageData(image_path="right.jpg", x=250, y=100)
left_elem.render = Mock()
right_elem.render = Mock()
layout.add_element(left_elem)
layout.add_element(right_elem)
layout.render(dpi=300)
# Both elements should be rendered
left_elem.render.assert_called_once()
right_elem.render.assert_called_once()
# Center line should be drawn
assert any(call(1.5) in mock_linewidth.call_args_list for call in mock_linewidth.call_args_list)