""" Tests for Command pattern implementation """ import pytest from unittest.mock import Mock, MagicMock from pyPhotoAlbum.commands import ( AddElementCommand, DeleteElementCommand, MoveElementCommand, ResizeElementCommand, RotateElementCommand, AdjustImageCropCommand, AlignElementsCommand, ResizeElementsCommand, ChangeZOrderCommand, StateChangeCommand, CommandHistory, _normalize_asset_path ) from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.project import Project, Page class TestNormalizeAssetPath: """Test _normalize_asset_path helper function""" def test_normalize_absolute_path(self): """Test converting absolute path to relative""" mock_manager = Mock() mock_manager.project_folder = "/project" result = _normalize_asset_path("/project/assets/image.jpg", mock_manager) assert result == "assets/image.jpg" def test_normalize_relative_path_unchanged(self): """Test relative path stays unchanged""" mock_manager = Mock() mock_manager.project_folder = "/project" result = _normalize_asset_path("assets/image.jpg", mock_manager) assert result == "assets/image.jpg" def test_normalize_no_asset_manager(self): """Test with no asset manager returns unchanged""" result = _normalize_asset_path("/path/to/image.jpg", None) assert result == "/path/to/image.jpg" def test_normalize_empty_path(self): """Test with empty path""" mock_manager = Mock() result = _normalize_asset_path("", mock_manager) assert result == "" class TestAddElementCommand: """Test AddElementCommand""" def test_add_element_execute(self): """Test adding element to layout""" layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = AddElementCommand(layout, element) assert len(layout.elements) == 0 cmd.execute() assert len(layout.elements) == 1 assert element in layout.elements def test_add_element_undo(self): """Test undoing element addition""" layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = AddElementCommand(layout, element) cmd.execute() assert len(layout.elements) == 1 cmd.undo() assert len(layout.elements) == 0 def test_add_element_redo(self): """Test redoing element addition""" layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = AddElementCommand(layout, element) cmd.execute() cmd.undo() cmd.redo() assert len(layout.elements) == 1 assert element in layout.elements def test_add_element_serialization(self): """Test serializing add element command""" layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = AddElementCommand(layout, element) cmd.execute() data = cmd.serialize() assert data['type'] == 'add_element' assert 'element' in data assert data['executed'] is True def test_add_element_with_asset_manager(self): """Test add element with asset manager reference""" layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) mock_asset_manager = Mock() mock_asset_manager.project_folder = "/project" mock_asset_manager.acquire_reference = Mock() cmd = AddElementCommand(layout, element, asset_manager=mock_asset_manager) # Should acquire reference on creation assert mock_asset_manager.acquire_reference.called class TestDeleteElementCommand: """Test DeleteElementCommand""" def test_delete_element_execute(self): """Test deleting element from layout""" layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) layout.add_element(element) cmd = DeleteElementCommand(layout, element) cmd.execute() assert len(layout.elements) == 0 assert element not in layout.elements def test_delete_element_undo(self): """Test undoing element deletion""" layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) layout.add_element(element) cmd = DeleteElementCommand(layout, element) cmd.execute() cmd.undo() assert len(layout.elements) == 1 assert element in layout.elements def test_delete_element_serialization(self): """Test serializing delete element command""" layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) layout.add_element(element) cmd = DeleteElementCommand(layout, element) data = cmd.serialize() assert data['type'] == 'delete_element' assert 'element' in data class TestMoveElementCommand: """Test MoveElementCommand""" def test_move_element_execute(self): """Test moving element""" element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200)) cmd.execute() assert element.position == (200, 200) def test_move_element_undo(self): """Test undoing element move""" element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200)) cmd.execute() cmd.undo() assert element.position == (100, 100) def test_move_element_serialization(self): """Test serializing move command""" element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200)) data = cmd.serialize() assert data['type'] == 'move_element' assert data['old_position'] == (100, 100) assert data['new_position'] == (200, 200) class TestResizeElementCommand: """Test ResizeElementCommand""" def test_resize_element_execute(self): """Test resizing element""" element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = ResizeElementCommand( element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225) ) cmd.execute() assert element.size == (300, 225) def test_resize_element_undo(self): """Test undoing element resize""" element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = ResizeElementCommand( element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225) ) cmd.execute() cmd.undo() assert element.size == (200, 150) def test_resize_changes_position(self): """Test resize that also changes position""" element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = ResizeElementCommand( element, old_position=(100, 100), old_size=(200, 150), new_position=(90, 90), new_size=(220, 165) ) cmd.execute() assert element.position == (90, 90) assert element.size == (220, 165) class TestRotateElementCommand: """Test RotateElementCommand""" def test_rotate_element_execute(self): """Test rotating element""" element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) element.rotation = 0 element.pil_rotation_90 = 0 cmd = RotateElementCommand(element, old_rotation=0, new_rotation=90) cmd.execute() # After rotation refactoring, ImageData keeps rotation at 0 and uses pil_rotation_90 assert element.rotation == 0 assert element.pil_rotation_90 == 1 # 90 degrees = 1 rotation # Position and size should be swapped for 90 degree rotation assert element.size == (150, 200) # width and height swapped def test_rotate_element_undo(self): """Test undoing element rotation""" element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) element.rotation = 0 element.pil_rotation_90 = 0 original_size = element.size original_position = element.position cmd = RotateElementCommand(element, old_rotation=0, new_rotation=90) cmd.execute() cmd.undo() assert element.rotation == 0 assert element.pil_rotation_90 == 0 assert element.size == original_size assert element.position == original_position def test_rotate_element_serialization(self): """Test serializing rotate command""" element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45) data = cmd.serialize() assert data['type'] == 'rotate_element' assert data['old_rotation'] == 0 assert data['new_rotation'] == 45 class TestAdjustImageCropCommand: """Test AdjustImageCropCommand""" def test_adjust_crop_execute(self): """Test adjusting image crop""" element = ImageData( image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0} ) new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8} cmd = AdjustImageCropCommand( element, old_crop_info=element.crop_info.copy(), new_crop_info=new_crop ) cmd.execute() assert element.crop_info == new_crop def test_adjust_crop_undo(self): """Test undoing crop adjustment""" old_crop = {'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0} element = ImageData( image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=old_crop.copy() ) new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8} cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop) cmd.execute() cmd.undo() assert element.crop_info == old_crop class TestAlignElementsCommand: """Test AlignElementsCommand""" def test_align_elements_execute(self): """Test aligning elements""" element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) element2 = ImageData(image_path="/test2.jpg", x=200, y=150, width=100, height=100) # Set new positions before creating command element2.position = (100, 150) # Align left # Command expects list of (element, old_position) tuples changes = [(element1, (100, 100)), (element2, (200, 150))] cmd = AlignElementsCommand(changes) cmd.execute() # Execute does nothing (positions already set), check they remain assert element1.position == (100, 100) assert element2.position == (100, 150) def test_align_elements_undo(self): """Test undoing alignment""" element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) element2 = ImageData(image_path="/test2.jpg", x=200, y=150, width=100, height=100) # Set new positions before creating command element2.position = (100, 150) # Align left # Command expects list of (element, old_position) tuples changes = [(element1, (100, 100)), (element2, (200, 150))] cmd = AlignElementsCommand(changes) cmd.execute() cmd.undo() # Should restore old positions assert element1.position == (100, 100) assert element2.position == (200, 150) class TestResizeElementsCommand: """Test ResizeElementsCommand""" def test_resize_elements_execute(self): """Test resizing multiple elements""" element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) element2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=150, height=150) # Set new sizes before creating command element1.size = (200, 200) element2.size = (300, 300) # Command expects list of (element, old_position, old_size) tuples changes = [ (element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150)) ] cmd = ResizeElementsCommand(changes) cmd.execute() # Execute does nothing (sizes already set), check they remain assert element1.size == (200, 200) assert element2.size == (300, 300) def test_resize_elements_undo(self): """Test undoing multiple element resize""" element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) element2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=150, height=150) # Set new sizes before creating command element1.size = (200, 200) element2.size = (300, 300) # Command expects list of (element, old_position, old_size) tuples changes = [ (element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150)) ] cmd = ResizeElementsCommand(changes) cmd.execute() cmd.undo() # Should restore old sizes assert element1.size == (100, 100) assert element2.size == (150, 150) class TestChangeZOrderCommand: """Test ChangeZOrderCommand""" def test_change_zorder_execute(self): """Test changing z-order""" layout = PageLayout(width=210, height=297) element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) element2 = ImageData(image_path="/test2.jpg", x=120, y=120, width=100, height=100) layout.add_element(element1) layout.add_element(element2) # Move element1 to front (swap order) cmd = ChangeZOrderCommand(layout, element1, 0, 1) cmd.execute() assert layout.elements[1] == element1 def test_change_zorder_undo(self): """Test undoing z-order change""" layout = PageLayout(width=210, height=297) element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) element2 = ImageData(image_path="/test2.jpg", x=120, y=120, width=100, height=100) layout.add_element(element1) layout.add_element(element2) cmd = ChangeZOrderCommand(layout, element1, 0, 1) cmd.execute() cmd.undo() assert layout.elements[0] == element1 class TestStateChangeCommand: """Test StateChangeCommand for generic state changes""" def test_state_change_undo(self): """Test undoing state change""" element = TextBoxData( text_content="Old Text", x=100, y=100, width=200, height=100 ) # Define restore function def restore_state(state): element.text_content = state['text_content'] old_state = {'text_content': 'Old Text'} new_state = {'text_content': 'New Text'} # Apply new state first element.text_content = 'New Text' cmd = StateChangeCommand( description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state ) # Undo should restore old state cmd.undo() assert element.text_content == 'Old Text' def test_state_change_redo(self): """Test redoing state change""" element = TextBoxData( text_content="Old Text", x=100, y=100, width=200, height=100 ) # Define restore function def restore_state(state): element.text_content = state['text_content'] old_state = {'text_content': 'Old Text'} new_state = {'text_content': 'New Text'} # Apply new state first element.text_content = 'New Text' cmd = StateChangeCommand( description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state ) # Undo then redo cmd.undo() assert element.text_content == 'Old Text' cmd.redo() assert element.text_content == 'New Text' def test_state_change_serialization(self): """Test serializing state change command""" def restore_func(state): pass cmd = StateChangeCommand( description="Test operation", restore_func=restore_func, before_state={'test': 'before'}, after_state={'test': 'after'} ) data = cmd.serialize() assert data['type'] == 'state_change' assert data['description'] == 'Test operation' class TestCommandHistory: """Test CommandHistory for undo/redo management""" def test_history_execute_command(self): """Test executing command through history""" history = CommandHistory() layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = AddElementCommand(layout, element) history.execute(cmd) assert len(layout.elements) == 1 assert history.can_undo() assert not history.can_redo() def test_history_undo(self): """Test undo through history""" history = CommandHistory() layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = AddElementCommand(layout, element) history.execute(cmd) history.undo() assert len(layout.elements) == 0 assert not history.can_undo() assert history.can_redo() def test_history_redo(self): """Test redo through history""" history = CommandHistory() layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = AddElementCommand(layout, element) history.execute(cmd) history.undo() history.redo() assert len(layout.elements) == 1 assert history.can_undo() assert not history.can_redo() def test_history_multiple_commands(self): """Test history with multiple commands""" history = CommandHistory() layout = PageLayout(width=210, height=297) element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) element2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=100, height=100) history.execute(AddElementCommand(layout, element1)) history.execute(AddElementCommand(layout, element2)) assert len(layout.elements) == 2 history.undo() assert len(layout.elements) == 1 history.undo() assert len(layout.elements) == 0 def test_history_clears_redo_on_new_command(self): """Test that new command clears redo stack""" history = CommandHistory() layout = PageLayout(width=210, height=297) element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) element2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=100, height=100) history.execute(AddElementCommand(layout, element1)) history.undo() assert history.can_redo() # Execute new command should clear redo stack history.execute(AddElementCommand(layout, element2)) assert not history.can_redo() def test_history_clear(self): """Test clearing history""" history = CommandHistory() layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) history.execute(AddElementCommand(layout, element)) history.clear() assert not history.can_undo() assert not history.can_redo() def test_history_max_size(self): """Test history respects max size limit""" history = CommandHistory(max_history=3) layout = PageLayout(width=210, height=297) for i in range(5): element = ImageData(image_path=f"/test{i}.jpg", x=i*10, y=i*10, width=100, height=100) history.execute(AddElementCommand(layout, element)) # Should only have 3 commands in history (max_history) undo_count = 0 while history.can_undo(): history.undo() undo_count += 1 assert undo_count == 3 def test_history_serialize_deserialize_add_element(self): """Test serializing and deserializing history with AddElementCommand""" history = CommandHistory() layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd = AddElementCommand(layout, element) history.execute(cmd) # Serialize data = history.serialize() assert len(data['undo_stack']) == 1 assert data['undo_stack'][0]['type'] == 'add_element' # Create mock project for deserialization mock_project = Mock() mock_project.pages = [Mock(layout=layout)] # Deserialize new_history = CommandHistory() new_history.deserialize(data, mock_project) assert len(new_history.undo_stack) == 1 assert len(new_history.redo_stack) == 0 def test_history_serialize_deserialize_all_command_types(self): """Test serializing and deserializing all command types through history""" history = CommandHistory() layout = PageLayout(width=210, height=297) # Create elements and add them to layout first img = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) txt = TextBoxData(text_content="Test", x=50, y=50, width=100, height=50) img2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=100, height=100) # Build commands - serialize each type without executing them # (we only care about serialization/deserialization, not execution) cmd1 = AddElementCommand(layout, img) cmd1.serialize() # Ensure it can serialize cmd2 = DeleteElementCommand(layout, txt) cmd2.serialize() cmd3 = MoveElementCommand(img, (100, 100), (150, 150)) cmd3.serialize() cmd4 = ResizeElementCommand(img, (100, 100), (200, 150), (120, 120), (180, 130)) cmd4.serialize() cmd5 = RotateElementCommand(img, 0, 90) cmd5.serialize() cmd6 = AdjustImageCropCommand(img, (0, 0, 1, 1), (0.1, 0.1, 0.9, 0.9)) cmd6.serialize() cmd7 = AlignElementsCommand([(img, (100, 100))]) cmd7.serialize() cmd8 = ResizeElementsCommand([(img, (100, 100), (200, 150))]) cmd8.serialize() layout.add_element(img2) cmd9 = ChangeZOrderCommand(layout, img2, 0, 0) cmd9.serialize() # Manually build serialized history data data = { 'undo_stack': [ cmd1.serialize(), cmd2.serialize(), cmd3.serialize(), cmd4.serialize(), cmd5.serialize(), cmd6.serialize(), cmd7.serialize(), cmd8.serialize(), cmd9.serialize(), ], 'redo_stack': [], 'max_history': 100 } # Create mock project mock_project = Mock() mock_project.pages = [Mock(layout=layout)] # Deserialize new_history = CommandHistory() new_history.deserialize(data, mock_project) assert len(new_history.undo_stack) == 9 assert new_history.undo_stack[0].__class__.__name__ == 'AddElementCommand' assert new_history.undo_stack[1].__class__.__name__ == 'DeleteElementCommand' assert new_history.undo_stack[2].__class__.__name__ == 'MoveElementCommand' assert new_history.undo_stack[3].__class__.__name__ == 'ResizeElementCommand' assert new_history.undo_stack[4].__class__.__name__ == 'RotateElementCommand' assert new_history.undo_stack[5].__class__.__name__ == 'AdjustImageCropCommand' assert new_history.undo_stack[6].__class__.__name__ == 'AlignElementsCommand' assert new_history.undo_stack[7].__class__.__name__ == 'ResizeElementsCommand' assert new_history.undo_stack[8].__class__.__name__ == 'ChangeZOrderCommand' def test_history_deserialize_unknown_command_type(self): """Test deserializing unknown command type returns None and continues""" history = CommandHistory() mock_project = Mock() data = { 'undo_stack': [ {'type': 'unknown_command', 'data': 'test'}, {'type': 'add_element', 'element': ImageData().serialize(), 'executed': True} ], 'redo_stack': [], 'max_history': 100 } # Should not raise exception, just skip unknown command history.deserialize(data, mock_project) # Should only have the valid command assert len(history.undo_stack) == 1 assert history.undo_stack[0].__class__.__name__ == 'AddElementCommand' def test_history_deserialize_malformed_command(self): """Test deserializing malformed command handles exception gracefully""" history = CommandHistory() mock_project = Mock() data = { 'undo_stack': [ {'type': 'add_element'}, # Missing required 'element' field {'type': 'move_element', 'element': ImageData().serialize(), 'old_position': (0, 0), 'new_position': (10, 10)} ], 'redo_stack': [], 'max_history': 100 } # Should not raise exception, just skip malformed command history.deserialize(data, mock_project) # Should only have the valid command assert len(history.undo_stack) == 1 assert history.undo_stack[0].__class__.__name__ == 'MoveElementCommand' def test_history_serialize_deserialize_with_redo_stack(self): """Test serializing and deserializing with items in redo stack""" history = CommandHistory() layout = PageLayout(width=210, height=297) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) cmd1 = AddElementCommand(layout, element) cmd2 = MoveElementCommand(element, (100, 100), (150, 150)) history.execute(cmd1) history.execute(cmd2) history.undo() # Move cmd2 to redo stack # Serialize data = history.serialize() assert len(data['undo_stack']) == 1 assert len(data['redo_stack']) == 1 # Deserialize mock_project = Mock() new_history = CommandHistory() new_history.deserialize(data, mock_project) assert len(new_history.undo_stack) == 1 assert len(new_history.redo_stack) == 1