pyPhotoAlbum/tests/test_commands.py
Duncan Tourolle 47e5ea4b3e
All checks were successful
Python CI / test (push) Successful in 1m7s
Lint / lint (push) Successful in 1m11s
Tests / test (3.10) (push) Successful in 51s
Tests / test (3.11) (push) Successful in 52s
Tests / test (3.9) (push) Successful in 49s
additional testing
2025-11-11 11:49:16 +01:00

658 lines
21 KiB
Python

"""
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
cmd = RotateElementCommand(element, old_rotation=0, new_rotation=90)
cmd.execute()
assert element.rotation == 90
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
cmd = RotateElementCommand(element, old_rotation=0, new_rotation=90)
cmd.execute()
cmd.undo()
assert element.rotation == 0
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