pyPhotoAlbum/tests/test_commands.py
Duncan Tourolle 293b568772
All checks were successful
Python CI / test (push) Successful in 1m40s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m48s
Tests / test (3.12) (push) Successful in 1m51s
Tests / test (3.13) (push) Successful in 1m46s
Tests / test (3.14) (push) Successful in 1m29s
simplified deserialisation
2026-01-01 14:17:16 +01:00

807 lines
27 KiB
Python
Executable File

"""
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()
mock_project.pages = []
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()
mock_project.pages = []
new_history = CommandHistory()
new_history.deserialize(data, mock_project)
assert len(new_history.undo_stack) == 1
assert len(new_history.redo_stack) == 1