Implement command pattern for undo/redo functionality in HostsManager

- Added command classes: ToggleEntryCommand, MoveEntryCommand, AddEntryCommand, DeleteEntryCommand, UpdateEntryCommand.
- Integrated UndoRedoHistory to manage command execution and history.
- Updated HostsManager to use command-based methods for editing entries with undo/redo support.
- Enhanced HostsManagerApp to display undo/redo status and handle undo/redo actions via keyboard shortcuts.
- Refactored navigation handler to utilize command-based methods for toggling and moving entries.
- Created comprehensive tests for command classes and integration with HostsManager.
This commit is contained in:
Philip Henning 2025-08-17 21:30:01 +02:00
parent 77d4a2e955
commit bc0f8b99e8
8 changed files with 1461 additions and 55 deletions

584
tests/test_commands.py Normal file
View file

@ -0,0 +1,584 @@
"""
Tests for the command pattern implementation in the hosts TUI application.
This module tests the undo/redo functionality, command pattern,
and integration with the HostsManager.
"""
import pytest
from unittest.mock import Mock, patch
from src.hosts.core.commands import (
Command,
OperationResult,
UndoRedoHistory,
ToggleEntryCommand,
MoveEntryCommand,
AddEntryCommand,
DeleteEntryCommand,
UpdateEntryCommand,
)
from src.hosts.core.models import HostEntry, HostsFile
from src.hosts.core.manager import HostsManager
class TestOperationResult:
"""Test the OperationResult class."""
def test_operation_result_success(self):
"""Test successful operation result."""
result = OperationResult(True, "Operation successful")
assert result.success is True
assert result.message == "Operation successful"
def test_operation_result_failure(self):
"""Test failed operation result."""
result = OperationResult(False, "Operation failed")
assert result.success is False
assert result.message == "Operation failed"
def test_operation_result_with_data(self):
"""Test operation result with additional data."""
result = OperationResult(True, "Operation successful", {"key": "value"})
assert result.success is True
assert result.message == "Operation successful"
assert result.data == {"key": "value"}
class TestUndoRedoHistory:
"""Test the UndoRedoHistory class."""
def test_initial_state(self):
"""Test initial state of undo/redo history."""
history = UndoRedoHistory()
assert not history.can_undo()
assert not history.can_redo()
assert history.get_undo_description() is None
assert history.get_redo_description() is None
def test_execute_command(self):
"""Test executing a command adds it to history."""
history = UndoRedoHistory()
hosts_file = HostsFile()
command = Mock(spec=Command)
command.execute.return_value = OperationResult(True, "Test executed")
command.get_description.return_value = "Test command"
result = history.execute_command(command, hosts_file)
assert result.success is True
assert result.message == "Test executed"
assert history.can_undo()
assert not history.can_redo()
assert history.get_undo_description() == "Test command"
command.execute.assert_called_once_with(hosts_file)
def test_execute_failed_command(self):
"""Test executing a failed command does not add it to history."""
history = UndoRedoHistory()
hosts_file = HostsFile()
command = Mock(spec=Command)
command.execute.return_value = OperationResult(False, "Test failed")
result = history.execute_command(command, hosts_file)
assert result.success is False
assert result.message == "Test failed"
assert not history.can_undo()
assert not history.can_redo()
def test_undo_operation(self):
"""Test undoing an operation."""
history = UndoRedoHistory()
hosts_file = HostsFile()
command = Mock(spec=Command)
command.execute.return_value = OperationResult(True, "Test executed")
command.undo.return_value = OperationResult(True, "Test undone")
command.get_description.return_value = "Test command"
# Execute then undo
history.execute_command(command, hosts_file)
result = history.undo(hosts_file)
assert result.success is True
assert result.message == "Test undone"
assert not history.can_undo()
assert history.can_redo()
assert history.get_redo_description() == "Test command"
command.undo.assert_called_once_with(hosts_file)
def test_redo_operation(self):
"""Test redoing an operation."""
history = UndoRedoHistory()
hosts_file = HostsFile()
command = Mock(spec=Command)
command.execute.return_value = OperationResult(True, "Test executed")
command.undo.return_value = OperationResult(True, "Test undone")
command.get_description.return_value = "Test command"
# Execute, undo, then redo
history.execute_command(command, hosts_file)
history.undo(hosts_file)
result = history.redo(hosts_file)
assert result.success is True
assert result.message == "Test executed"
assert history.can_undo()
assert not history.can_redo()
assert history.get_undo_description() == "Test command"
assert command.execute.call_count == 2
def test_undo_with_empty_history(self):
"""Test undo with empty history."""
history = UndoRedoHistory()
hosts_file = HostsFile()
result = history.undo(hosts_file)
assert result.success is False
assert "No operations to undo" in result.message
def test_redo_with_empty_history(self):
"""Test redo with empty history."""
history = UndoRedoHistory()
hosts_file = HostsFile()
result = history.redo(hosts_file)
assert result.success is False
assert "No operations to redo" in result.message
def test_max_history_limit(self):
"""Test that history respects the maximum limit."""
history = UndoRedoHistory(max_history=2)
hosts_file = HostsFile()
# Add 3 commands
for i in range(3):
command = Mock(spec=Command)
command.execute.return_value = OperationResult(True, f"Command {i}")
command.get_description.return_value = f"Command {i}"
history.execute_command(command, hosts_file)
# Should only keep the last 2 commands
assert len(history.undo_stack) == 2
def test_clear_history(self):
"""Test clearing the history."""
history = UndoRedoHistory()
hosts_file = HostsFile()
command = Mock(spec=Command)
command.execute.return_value = OperationResult(True, "Test executed")
command.get_description.return_value = "Test command"
history.execute_command(command, hosts_file)
assert history.can_undo()
history.clear_history()
assert not history.can_undo()
assert not history.can_redo()
def test_new_command_clears_redo_stack(self):
"""Test that executing a new command clears the redo stack."""
history = UndoRedoHistory()
hosts_file = HostsFile()
# Execute and undo a command
command1 = Mock(spec=Command)
command1.execute.return_value = OperationResult(True, "Command 1")
command1.undo.return_value = OperationResult(True, "Undo 1")
command1.get_description.return_value = "Command 1"
history.execute_command(command1, hosts_file)
history.undo(hosts_file)
assert history.can_redo()
# Execute a new command
command2 = Mock(spec=Command)
command2.execute.return_value = OperationResult(True, "Command 2")
command2.get_description.return_value = "Command 2"
history.execute_command(command2, hosts_file)
assert not history.can_redo() # Redo stack should be cleared
class TestToggleEntryCommand:
"""Test the ToggleEntryCommand class."""
def test_toggle_active_to_inactive(self):
"""Test toggling an active entry to inactive."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True)
hosts_file.entries.append(entry)
command = ToggleEntryCommand(0)
result = command.execute(hosts_file)
assert result.success is True
assert not hosts_file.entries[0].is_active
assert "deactivated" in result.message
def test_toggle_inactive_to_active(self):
"""Test toggling an inactive entry to active."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=False)
hosts_file.entries.append(entry)
command = ToggleEntryCommand(0)
result = command.execute(hosts_file)
assert result.success is True
assert hosts_file.entries[0].is_active
assert "activated" in result.message
def test_toggle_undo(self):
"""Test undoing a toggle operation."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True)
hosts_file.entries.append(entry)
command = ToggleEntryCommand(0)
command.execute(hosts_file) # Toggle to inactive
result = command.undo(hosts_file) # Toggle back to active
assert result.success is True
assert hosts_file.entries[0].is_active
assert "Undid toggle" in result.message
def test_toggle_invalid_index(self):
"""Test toggle with invalid index."""
hosts_file = HostsFile()
command = ToggleEntryCommand(0)
result = command.execute(hosts_file)
assert result.success is False
assert "Invalid entry index" in result.message
def test_toggle_get_description(self):
"""Test getting command description."""
command = ToggleEntryCommand(0)
description = command.get_description()
assert "Toggle entry at index 0" in description
class TestMoveEntryCommand:
"""Test the MoveEntryCommand class."""
def test_move_entry_up(self):
"""Test moving an entry up."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"])
entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"])
hosts_file.entries.extend([entry1, entry2])
command = MoveEntryCommand(1, 0) # Move from index 1 to index 0
result = command.execute(hosts_file)
assert result.success is True
assert hosts_file.entries[0] == entry2
assert hosts_file.entries[1] == entry1
assert "up" in result.message
def test_move_entry_down(self):
"""Test moving an entry down."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"])
entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"])
hosts_file.entries.extend([entry1, entry2])
command = MoveEntryCommand(0, 1) # Move from index 0 to index 1
result = command.execute(hosts_file)
assert result.success is True
assert hosts_file.entries[0] == entry2
assert hosts_file.entries[1] == entry1
assert "down" in result.message
def test_move_undo(self):
"""Test undoing a move operation."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"])
entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"])
hosts_file.entries.extend([entry1, entry2])
command = MoveEntryCommand(1, 0) # Move entry2 up
command.execute(hosts_file)
result = command.undo(hosts_file) # Move back down
assert result.success is True
assert hosts_file.entries[0] == entry1
assert hosts_file.entries[1] == entry2
assert "Undid move" in result.message
def test_move_invalid_indices(self):
"""Test move with invalid indices."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
hosts_file.entries.append(entry)
command = MoveEntryCommand(0, 5) # Invalid target index
result = command.execute(hosts_file)
assert result.success is False
assert "Invalid move" in result.message
def test_move_same_position(self):
"""Test moving entry to same position."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
hosts_file.entries.append(entry)
command = MoveEntryCommand(0, 0) # Same position
result = command.execute(hosts_file)
assert result.success is True
assert "No movement needed" in result.message
def test_move_get_description(self):
"""Test getting command description."""
command = MoveEntryCommand(1, 0)
description = command.get_description()
assert "Move entry up" in description
class TestAddEntryCommand:
"""Test the AddEntryCommand class."""
def test_add_entry(self):
"""Test adding an entry."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
command = AddEntryCommand(entry)
result = command.execute(hosts_file)
assert result.success is True
assert len(hosts_file.entries) == 1
assert hosts_file.entries[0] == entry
assert "Added entry" in result.message
def test_add_entry_at_specific_index(self):
"""Test adding an entry at a specific index."""
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"])
entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"])
hosts_file.entries.append(entry1)
command = AddEntryCommand(entry2, index=0) # Insert at beginning
result = command.execute(hosts_file)
assert result.success is True
assert len(hosts_file.entries) == 2
assert hosts_file.entries[0] == entry2
assert hosts_file.entries[1] == entry1
def test_add_entry_undo(self):
"""Test undoing an add operation."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
command = AddEntryCommand(entry)
command.execute(hosts_file)
result = command.undo(hosts_file)
assert result.success is True
assert len(hosts_file.entries) == 0
assert "Undid add" in result.message
def test_add_entry_invalid_index(self):
"""Test adding entry with invalid index."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
command = AddEntryCommand(entry, index=5) # Invalid index
result = command.execute(hosts_file)
assert result.success is False
assert "Invalid insertion index" in result.message
def test_add_get_description(self):
"""Test getting command description."""
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
command = AddEntryCommand(entry)
description = command.get_description()
assert "Add entry: 192.168.1.1 test.local" in description
class TestDeleteEntryCommand:
"""Test the DeleteEntryCommand class."""
def test_delete_entry(self):
"""Test deleting an entry."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
hosts_file.entries.append(entry)
command = DeleteEntryCommand(0)
result = command.execute(hosts_file)
assert result.success is True
assert len(hosts_file.entries) == 0
assert "Deleted entry" in result.message
def test_delete_entry_undo(self):
"""Test undoing a delete operation."""
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
hosts_file.entries.append(entry)
command = DeleteEntryCommand(0)
command.execute(hosts_file)
result = command.undo(hosts_file)
assert result.success is True
assert len(hosts_file.entries) == 1
assert hosts_file.entries[0].ip_address == "192.168.1.1"
assert "Undid delete" in result.message
def test_delete_invalid_index(self):
"""Test delete with invalid index."""
hosts_file = HostsFile()
command = DeleteEntryCommand(0)
result = command.execute(hosts_file)
assert result.success is False
assert "Invalid entry index" in result.message
def test_delete_get_description(self):
"""Test getting command description."""
command = DeleteEntryCommand(0)
description = command.get_description()
assert "Delete entry at index 0" in description
class TestUpdateEntryCommand:
"""Test the UpdateEntryCommand class."""
def test_update_entry(self):
"""Test updating an entry."""
hosts_file = HostsFile()
old_entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], comment="old comment", is_active=True)
hosts_file.entries.append(old_entry)
command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], "new comment", False)
result = command.execute(hosts_file)
assert result.success is True
assert hosts_file.entries[0].ip_address == "192.168.1.2"
assert hosts_file.entries[0].hostnames == ["updated.local"]
assert hosts_file.entries[0].comment == "new comment"
assert hosts_file.entries[0].is_active is False
assert "Updated entry" in result.message
def test_update_entry_undo(self):
"""Test undoing an update operation."""
hosts_file = HostsFile()
old_entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], comment="old comment", is_active=True)
hosts_file.entries.append(old_entry)
command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], "new comment", False)
command.execute(hosts_file)
result = command.undo(hosts_file)
assert result.success is True
assert hosts_file.entries[0].ip_address == "192.168.1.1"
assert hosts_file.entries[0].hostnames == ["test.local"]
assert hosts_file.entries[0].comment == "old comment"
assert hosts_file.entries[0].is_active is True
assert "Undid update" in result.message
def test_update_invalid_index(self):
"""Test update with invalid index."""
hosts_file = HostsFile()
command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], None, True)
result = command.execute(hosts_file)
assert result.success is False
assert "Invalid entry index" in result.message
def test_update_get_description(self):
"""Test getting command description."""
command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], None, True)
description = command.get_description()
assert "Update entry at index 0" in description
class TestHostsManagerIntegration:
"""Test integration of commands with HostsManager."""
def test_manager_undo_redo_properties(self):
"""Test undo/redo availability properties."""
manager = HostsManager()
# Initially no undo/redo available
assert not manager.can_undo()
assert not manager.can_redo()
assert manager.get_undo_description() is None
assert manager.get_redo_description() is None
def test_manager_undo_without_edit_mode(self):
"""Test undo when not in edit mode."""
manager = HostsManager()
hosts_file = HostsFile()
result = manager.undo_last_operation(hosts_file)
assert result.success is False
assert "Not in edit mode" in result.message
def test_manager_redo_without_edit_mode(self):
"""Test redo when not in edit mode."""
manager = HostsManager()
hosts_file = HostsFile()
result = manager.redo_last_operation(hosts_file)
assert result.success is False
assert "Not in edit mode" in result.message
@patch('src.hosts.core.manager.HostsManager.enter_edit_mode')
def test_manager_execute_toggle_command(self, mock_enter_edit):
"""Test executing a toggle command through the manager."""
mock_enter_edit.return_value = (True, "Edit mode enabled")
manager = HostsManager()
manager.edit_mode = True # Simulate edit mode
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True)
hosts_file.entries.append(entry)
result = manager.execute_toggle_command(hosts_file, 0)
assert result.success is True
assert not hosts_file.entries[0].is_active
@patch('src.hosts.core.manager.HostsManager.enter_edit_mode')
def test_manager_execute_move_command(self, mock_enter_edit):
"""Test executing a move command through the manager."""
mock_enter_edit.return_value = (True, "Edit mode enabled")
manager = HostsManager()
manager.edit_mode = True # Simulate edit mode
hosts_file = HostsFile()
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"])
entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"])
hosts_file.entries.extend([entry1, entry2])
result = manager.execute_move_command(hosts_file, 1, "up")
assert result.success is True
assert hosts_file.entries[0] == entry2
@patch('src.hosts.core.manager.HostsManager.enter_edit_mode')
def test_manager_command_not_in_edit_mode(self, mock_enter_edit):
"""Test executing commands when not in edit mode."""
manager = HostsManager()
hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
result = manager.execute_add_command(hosts_file, entry)
assert result.success is False
assert "Not in edit mode" in result.message
if __name__ == "__main__":
pytest.main([__file__])