""" 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, 0.8) # Check if cyan color was set (at least once) assert mock_color4f.call_count > 0 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)