Compare commits

..

No commits in common. "e6f3e9f3d4db4754d1870118237aff68fde176ba" and "77d4a2e95530a14774ba03d5b5fb7b00c3096ec5" have entirely different histories.

9 changed files with 64 additions and 1487 deletions

View file

@ -2,20 +2,25 @@
## Current Work Focus
**Phase 4 Advanced Edit Features Complete**: Successfully implemented all Phase 4 features including add/delete entries, inline editing, search functionality, and comprehensive undo/redo system. The application now has complete edit capabilities with modular TUI architecture, command pattern implementation, and professional user interface. Ready for Phase 5 advanced features.
**Phase 4 Advanced Edit Features Largely Complete**: Successfully implemented all major Phase 4 features including add/delete entries, inline editing, and search functionality. The application now has comprehensive edit capabilities with modular TUI architecture and advanced user interface. Ready for Phase 5 advanced features and Polish phase.
## Immediate Next Steps
### Priority 1: Phase 5 Advanced Features
### Priority 1: Phase 4 Completion (Minor Features)
1. **Test fixes**: Resolve 3 failing tests (footer/status bar and keybinding updates)
2. **Documentation updates**: Update keybinding documentation to reflect current implementation
### Priority 2: Phase 5 Advanced Features
1. **DNS resolution**: Resolve hostnames to IP addresses with comparison
2. **CNAME support**: Store DNS names alongside IP addresses
3. **Advanced filtering**: Filter by active/inactive status
4. **Import/Export**: Support for different file formats
### Priority 2: Phase 6 Polish
### Priority 3: Phase 6 Polish
1. **Bulk operations**: Select and modify multiple entries
2. **Performance optimization**: Testing with large hosts files
3. **Accessibility**: Screen reader support and keyboard accessibility
2. **Undo/Redo functionality**: Command pattern for operation history
3. **Performance optimization**: Testing with large hosts files
4. **Accessibility**: Screen reader support and keyboard accessibility
## Recent Changes
@ -46,27 +51,6 @@ Successfully implemented DataTable-based entry details with consistent field ord
- **Labeled rows**: Uses DataTable labeled rows feature for clean presentation
- **Professional appearance**: Table format matching main entries table
### Phase 4 Undo/Redo System ✅ COMPLETED
Successfully implemented comprehensive undo/redo functionality using the Command pattern:
**Command Pattern Implementation:**
- **Abstract Command class**: Base interface with execute/undo methods and operation descriptions
- **OperationResult dataclass**: Standardized result handling with success, message, and optional data
- **UndoRedoHistory manager**: Stack-based operation history with configurable limits (default 50 operations)
- **Concrete command classes**: Complete implementations for all edit operations:
- ToggleEntryCommand: Toggle active/inactive status with reversible operations
- MoveEntryCommand: Move entries up/down with position restoration
- AddEntryCommand: Add entries with removal capability for undo
- DeleteEntryCommand: Remove entries with restoration capability
- UpdateEntryCommand: Modify entry fields with original value restoration
**Integration and User Interface:**
- **HostsManager integration**: All edit operations now use command pattern with execute/undo methods
- **Keyboard shortcuts**: Ctrl+Z for undo, Ctrl+Y for redo operations
- **UI feedback**: Status bar shows undo/redo availability and operation descriptions
- **History management**: Operations cleared on edit mode exit, failed operations not stored
- **Comprehensive testing**: 43 test cases covering all command operations and edge cases
### Phase 3 Edit Mode Complete ✅ COMPLETE
- ✅ **Permission management**: Complete PermissionManager class with sudo request and validation
- ✅ **Edit mode toggle**: Safe transition between read-only and edit modes with 'e' key

View file

@ -72,13 +72,13 @@
## What's Left to Build
### Phase 4: Advanced Edit Features ✅ COMPLETE
### Phase 4: Advanced Edit Features ✅ LARGELY COMPLETE
- ✅ **Add new entries**: Complete AddEntryModal with validation
- ✅ **Delete entries**: Complete DeleteConfirmationModal with safety checks
- ✅ **Entry editing**: Complete inline editing for IP, hostnames, comments, and active status
- ✅ **Search functionality**: Complete search/filter by hostname, IP address, or comment
- **Undo/Redo**: Complete command pattern implementation with 43 comprehensive tests
- ❌ ~~**Bulk operations**: Select and modify multiple entries (won't be implemented)~~
- **Bulk operations**: Select and modify multiple entries (planned)
- ❌ **Undo/Redo**: Command pattern implementation for operation history (planned)
### Phase 5: Advanced Features
- ❌ **DNS resolution**: Resolve hostnames to IP addresses
@ -127,8 +127,7 @@
- **UI Components**: 28 tests for TUI application and modal dialogs
- **Save Confirmation**: 13 tests for save confirmation modal functionality
- **Config Modal**: 6 tests for configuration modal interface
- **Commands**: 43 tests for command pattern and undo/redo functionality
- **Total**: 192 tests with 100% pass rate and comprehensive edge case coverage
- **Total**: 149 tests with 100% pass rate and comprehensive edge case coverage
### Code Quality Standards
- **Linting**: All ruff checks passing with clean code
@ -139,25 +138,6 @@
## Phase Completion Summaries
### Phase 4: Undo/Redo System ✅ EXCEPTIONAL SUCCESS
Phase 4 undo/redo implementation exceeded all objectives with comprehensive command pattern:
1. ✅ **Command Pattern Foundation**: Abstract Command class with execute/undo methods and operation descriptions
2. ✅ **OperationResult System**: Standardized result handling with success, message, and optional data fields
3. ✅ **UndoRedoHistory Manager**: Stack-based operation history with configurable limits (default 50 operations)
4. ✅ **Complete Command Set**: All edit operations implemented as reversible commands:
- ToggleEntryCommand: Toggle active/inactive status with state restoration
- MoveEntryCommand: Move entries up/down with position restoration
- AddEntryCommand: Add entries with removal capability for undo
- DeleteEntryCommand: Remove entries with full restoration capability
- UpdateEntryCommand: Modify entry fields with original value restoration
5. ✅ **HostsManager Integration**: All edit operations now use command pattern with execute/undo methods
6. ✅ **User Interface**: Ctrl+Z/Ctrl+Y keyboard shortcuts with status bar feedback
7. ✅ **History Management**: Operations cleared on edit mode exit, failed operations not stored
8. ✅ **Comprehensive Testing**: 43 test cases covering all command operations, edge cases, and integration
9. ✅ **API Consistency**: Systematic resolution of all integration API mismatches
10. ✅ **Production Ready**: Complete undo/redo functionality integrated into existing workflow
### Phase 3: Edit Mode Foundation ✅ EXCEPTIONAL SUCCESS
Phase 3 exceeded all objectives with comprehensive edit mode implementation:

View file

@ -1,550 +0,0 @@
"""Command pattern implementation for undo/redo functionality in hosts management.
This module provides the command pattern infrastructure for tracking and reversing
edit operations on hosts files, enabling comprehensive undo/redo functionality.
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, TYPE_CHECKING
from dataclasses import dataclass
if TYPE_CHECKING:
from .models import HostsFile, HostEntry
from .manager import HostsManager
@dataclass
class OperationResult:
"""Result of executing or undoing a command.
Attributes:
success: Whether the operation succeeded
message: Human-readable description of the result
data: Optional additional data about the operation
"""
success: bool
message: str
data: Optional[Dict[str, Any]] = None
class Command(ABC):
"""Abstract base class for all edit commands.
All edit operations (toggle, move, add, delete, update) implement this interface
to provide consistent execute/undo capabilities for the undo/redo system.
"""
@abstractmethod
def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""Execute the command and return the result.
Args:
hosts_file: The hosts file to operate on
Returns:
OperationResult indicating success/failure and details
"""
pass
@abstractmethod
def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Undo the command and return the result.
Args:
hosts_file: The hosts file to operate on
Returns:
OperationResult indicating success/failure and details
"""
pass
@abstractmethod
def get_description(self) -> str:
"""Get a human-readable description of the command.
Returns:
String description of what this command does
"""
pass
class UndoRedoHistory:
"""Manages undo/redo history with configurable limits.
This class maintains separate stacks for undo and redo operations,
executes commands while managing history, and provides methods to
check availability of undo/redo operations.
"""
def __init__(self, max_history: int = 50):
"""Initialize the history manager.
Args:
max_history: Maximum number of commands to keep in history
"""
self.max_history = max_history
self.undo_stack: list[Command] = []
self.redo_stack: list[Command] = []
def execute_command(self, command: Command, hosts_file: "HostsFile") -> OperationResult:
"""Execute a command and add it to the undo stack.
Args:
command: Command to execute
hosts_file: The hosts file to operate on
Returns:
OperationResult from command execution
"""
result = command.execute(hosts_file)
if result.success:
# Add to undo stack and clear redo stack
self.undo_stack.append(command)
self.redo_stack.clear()
# Enforce history limit
if len(self.undo_stack) > self.max_history:
self.undo_stack.pop(0)
return result
def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Undo the last command.
Args:
hosts_file: The hosts file to operate on
Returns:
OperationResult from undo operation
"""
if not self.can_undo():
return OperationResult(
success=False,
message="No operations to undo"
)
command = self.undo_stack.pop()
result = command.undo(hosts_file)
if result.success:
# Move command to redo stack
self.redo_stack.append(command)
# Enforce history limit on redo stack too
if len(self.redo_stack) > self.max_history:
self.redo_stack.pop(0)
else:
# If undo failed, put command back on undo stack
self.undo_stack.append(command)
return result
def redo(self, hosts_file: "HostsFile") -> OperationResult:
"""Redo the last undone command.
Args:
hosts_file: The hosts file to operate on
Returns:
OperationResult from redo operation
"""
if not self.can_redo():
return OperationResult(
success=False,
message="No operations to redo"
)
command = self.redo_stack.pop()
result = command.execute(hosts_file)
if result.success:
# Move command back to undo stack
self.undo_stack.append(command)
else:
# If redo failed, put command back on redo stack
self.redo_stack.append(command)
return result
def can_undo(self) -> bool:
"""Check if undo is possible.
Returns:
True if there are commands that can be undone
"""
return len(self.undo_stack) > 0
def can_redo(self) -> bool:
"""Check if redo is possible.
Returns:
True if there are commands that can be redone
"""
return len(self.redo_stack) > 0
def clear_history(self) -> None:
"""Clear both undo and redo stacks."""
self.undo_stack.clear()
self.redo_stack.clear()
def get_undo_description(self) -> Optional[str]:
"""Get description of the next command that would be undone.
Returns:
Description string or None if no undo available
"""
if self.can_undo():
return self.undo_stack[-1].get_description()
return None
def get_redo_description(self) -> Optional[str]:
"""Get description of the next command that would be redone.
Returns:
Description string or None if no redo available
"""
if self.can_redo():
return self.redo_stack[-1].get_description()
return None
class ToggleEntryCommand(Command):
"""Command to toggle an entry's active state."""
def __init__(self, index: int):
"""Initialize the toggle command.
Args:
index: Index of the entry to toggle
"""
self.index = index
self.original_state: Optional[bool] = None
def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""Toggle the entry's active state."""
if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult(
success=False,
message=f"Invalid entry index: {self.index}"
)
entry = hosts_file.entries[self.index]
self.original_state = entry.is_active
entry.is_active = not entry.is_active
action = "activated" if entry.is_active else "deactivated"
return OperationResult(
success=True,
message=f"Entry {action}: {entry.ip_address} {' '.join(entry.hostnames)}",
data={"index": self.index, "new_state": entry.is_active}
)
def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Restore the entry's original active state."""
if self.original_state is None:
return OperationResult(
success=False,
message="Cannot undo: original state not saved"
)
if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult(
success=False,
message=f"Cannot undo: invalid entry index: {self.index}"
)
entry = hosts_file.entries[self.index]
entry.is_active = self.original_state
action = "activated" if entry.is_active else "deactivated"
return OperationResult(
success=True,
message=f"Undid toggle: entry {action}",
data={"index": self.index, "restored_state": entry.is_active}
)
def get_description(self) -> str:
"""Get description of this command."""
return f"Toggle entry at index {self.index}"
class MoveEntryCommand(Command):
"""Command to move an entry up or down."""
def __init__(self, from_index: int, to_index: int):
"""Initialize the move command.
Args:
from_index: Original position of the entry
to_index: Target position for the entry
"""
self.from_index = from_index
self.to_index = to_index
def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""Move the entry from one position to another."""
if (self.from_index < 0 or self.from_index >= len(hosts_file.entries) or
self.to_index < 0 or self.to_index >= len(hosts_file.entries)):
return OperationResult(
success=False,
message=f"Invalid move: from {self.from_index} to {self.to_index}"
)
if self.from_index == self.to_index:
return OperationResult(
success=True,
message="No movement needed",
data={"from_index": self.from_index, "to_index": self.to_index}
)
# Move the entry
entry = hosts_file.entries.pop(self.from_index)
hosts_file.entries.insert(self.to_index, entry)
direction = "up" if self.to_index < self.from_index else "down"
return OperationResult(
success=True,
message=f"Moved entry {direction}: {entry.ip_address} {' '.join(entry.hostnames)}",
data={"from_index": self.from_index, "to_index": self.to_index, "direction": direction}
)
def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Move the entry back to its original position."""
if (self.to_index < 0 or self.to_index >= len(hosts_file.entries) or
self.from_index < 0 or self.from_index >= len(hosts_file.entries)):
return OperationResult(
success=False,
message=f"Cannot undo move: invalid indices"
)
# Move back: from to_index back to from_index
entry = hosts_file.entries.pop(self.to_index)
hosts_file.entries.insert(self.from_index, entry)
direction = "down" if self.to_index < self.from_index else "up"
return OperationResult(
success=True,
message=f"Undid move: moved entry {direction}",
data={"restored_index": self.from_index}
)
def get_description(self) -> str:
"""Get description of this command."""
direction = "up" if self.to_index < self.from_index else "down"
return f"Move entry {direction} (from {self.from_index} to {self.to_index})"
class AddEntryCommand(Command):
"""Command to add a new entry."""
def __init__(self, entry: "HostEntry", index: Optional[int] = None):
"""Initialize the add command.
Args:
entry: The entry to add
index: Position to insert at (None for end)
"""
self.entry = entry
self.index = index
self.actual_index: Optional[int] = None
def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""Add the entry to the hosts file."""
if self.index is None:
# Add at the end
hosts_file.entries.append(self.entry)
self.actual_index = len(hosts_file.entries) - 1
else:
# Insert at specific position
if self.index < 0 or self.index > len(hosts_file.entries):
return OperationResult(
success=False,
message=f"Invalid insertion index: {self.index}"
)
hosts_file.entries.insert(self.index, self.entry)
self.actual_index = self.index
return OperationResult(
success=True,
message=f"Added entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}",
data={"index": self.actual_index, "entry": self.entry}
)
def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Remove the added entry."""
if self.actual_index is None:
return OperationResult(
success=False,
message="Cannot undo: entry index not recorded"
)
if self.actual_index < 0 or self.actual_index >= len(hosts_file.entries):
return OperationResult(
success=False,
message=f"Cannot undo: invalid entry index: {self.actual_index}"
)
# Verify we're removing the right entry
entry_to_remove = hosts_file.entries[self.actual_index]
if (entry_to_remove.ip_address != self.entry.ip_address or
entry_to_remove.hostnames != self.entry.hostnames):
return OperationResult(
success=False,
message="Cannot undo: entry at index doesn't match added entry"
)
hosts_file.entries.pop(self.actual_index)
return OperationResult(
success=True,
message=f"Undid add: removed entry {self.entry.ip_address}",
data={"removed_index": self.actual_index}
)
def get_description(self) -> str:
"""Get description of this command."""
return f"Add entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}"
class DeleteEntryCommand(Command):
"""Command to delete an entry."""
def __init__(self, index: int):
"""Initialize the delete command.
Args:
index: Index of the entry to delete
"""
self.index = index
self.deleted_entry: Optional["HostEntry"] = None
def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""Delete the entry from the hosts file."""
if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult(
success=False,
message=f"Invalid entry index: {self.index}"
)
self.deleted_entry = hosts_file.entries.pop(self.index)
return OperationResult(
success=True,
message=f"Deleted entry: {self.deleted_entry.ip_address} {' '.join(self.deleted_entry.hostnames)}",
data={"index": self.index, "deleted_entry": self.deleted_entry}
)
def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Restore the deleted entry."""
if self.deleted_entry is None:
return OperationResult(
success=False,
message="Cannot undo: deleted entry not saved"
)
if self.index < 0 or self.index > len(hosts_file.entries):
return OperationResult(
success=False,
message=f"Cannot undo: invalid restoration index: {self.index}"
)
hosts_file.entries.insert(self.index, self.deleted_entry)
return OperationResult(
success=True,
message=f"Undid delete: restored entry {self.deleted_entry.ip_address}",
data={"restored_index": self.index, "restored_entry": self.deleted_entry}
)
def get_description(self) -> str:
"""Get description of this command."""
if self.deleted_entry:
return f"Delete entry: {self.deleted_entry.ip_address} {' '.join(self.deleted_entry.hostnames)}"
return f"Delete entry at index {self.index}"
class UpdateEntryCommand(Command):
"""Command to update an entry."""
def __init__(self, index: int, new_ip: str, new_hostnames: list[str],
new_comment: Optional[str], new_active: bool):
"""Initialize the update command.
Args:
index: Index of the entry to update
new_ip: New IP address
new_hostnames: New list of hostnames
new_comment: New comment (optional)
new_active: New active state
"""
self.index = index
self.new_ip = new_ip
self.new_hostnames = new_hostnames.copy() # Make a copy to avoid mutation
self.new_comment = new_comment
self.new_active = new_active
self.original_entry: Optional["HostEntry"] = None
def execute(self, hosts_file: "HostsFile") -> OperationResult:
"""Update the entry with new values."""
if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult(
success=False,
message=f"Invalid entry index: {self.index}"
)
# Save original entry for undo
from .models import HostEntry
original = hosts_file.entries[self.index]
self.original_entry = HostEntry(
ip_address=original.ip_address,
hostnames=original.hostnames.copy(),
comment=original.comment,
is_active=original.is_active
)
# Update the entry
entry = hosts_file.entries[self.index]
entry.ip_address = self.new_ip
entry.hostnames = self.new_hostnames.copy()
entry.comment = self.new_comment
entry.is_active = self.new_active
return OperationResult(
success=True,
message=f"Updated entry: {entry.ip_address} {' '.join(entry.hostnames)}",
data={"index": self.index, "updated_entry": entry}
)
def undo(self, hosts_file: "HostsFile") -> OperationResult:
"""Restore the entry's original values."""
if self.original_entry is None:
return OperationResult(
success=False,
message="Cannot undo: original entry not saved"
)
if self.index < 0 or self.index >= len(hosts_file.entries):
return OperationResult(
success=False,
message=f"Cannot undo: invalid entry index: {self.index}"
)
# Restore original values
entry = hosts_file.entries[self.index]
entry.ip_address = self.original_entry.ip_address
entry.hostnames = self.original_entry.hostnames.copy()
entry.comment = self.original_entry.comment
entry.is_active = self.original_entry.is_active
return OperationResult(
success=True,
message=f"Undid update: restored entry {entry.ip_address}",
data={"index": self.index, "restored_entry": entry}
)
def get_description(self) -> str:
"""Get description of this command."""
if self.original_entry:
return f"Update entry: {self.original_entry.ip_address}{self.new_ip}"
return f"Update entry at index {self.index}"

View file

@ -12,15 +12,7 @@ from pathlib import Path
from typing import Optional, Tuple
from .models import HostEntry, HostsFile
from .parser import HostsParser
from .commands import (
UndoRedoHistory,
ToggleEntryCommand,
MoveEntryCommand,
AddEntryCommand,
DeleteEntryCommand,
UpdateEntryCommand,
OperationResult,
)
class PermissionManager:
"""
@ -120,6 +112,7 @@ class PermissionManager:
self.has_sudo = False
self._sudo_validated = False
class HostsManager:
"""
Main manager for hosts file edit operations.
@ -133,7 +126,6 @@ class HostsManager:
self.permission_manager = PermissionManager()
self.edit_mode = False
self._backup_path: Optional[Path] = None
self.undo_redo_history = UndoRedoHistory()
def enter_edit_mode(self, password: str = None) -> Tuple[bool, str]:
"""
@ -179,7 +171,6 @@ class HostsManager:
self.permission_manager.release_sudo()
self.edit_mode = False
self._backup_path = None
self.undo_redo_history.clear_history() # Clear undo/redo history when exiting edit mode
return True, "Edit mode disabled"
except Exception as e:
return False, f"Error exiting edit mode: {e}"
@ -421,162 +412,6 @@ class HostsManager:
except Exception as e:
return False, f"Error updating entry: {e}"
# Command-based methods for undo/redo functionality
def execute_toggle_command(self, hosts_file: HostsFile, index: int) -> OperationResult:
"""
Execute a toggle command with undo/redo support.
Args:
hosts_file: The hosts file to modify
index: Index of the entry to toggle
Returns:
OperationResult with success status and message
"""
if not self.edit_mode:
return OperationResult(False, "Not in edit mode")
command = ToggleEntryCommand(index)
result = self.undo_redo_history.execute_command(command, hosts_file)
return result
def execute_move_command(self, hosts_file: HostsFile, index: int, direction: str) -> OperationResult:
"""
Execute a move command with undo/redo support.
Args:
hosts_file: The hosts file to modify
index: Index of the entry to move
direction: "up" or "down"
Returns:
OperationResult with success status and message
"""
if not self.edit_mode:
return OperationResult(False, "Not in edit mode")
# Convert direction to target index
if direction == "up":
if index <= 0:
return OperationResult(False, "Cannot move first entry up")
to_index = index - 1
elif direction == "down":
if index >= len(hosts_file.entries) - 1:
return OperationResult(False, "Cannot move last entry down")
to_index = index + 1
else:
return OperationResult(False, f"Invalid direction: {direction}")
command = MoveEntryCommand(index, to_index)
result = self.undo_redo_history.execute_command(command, hosts_file)
return result
def execute_add_command(self, hosts_file: HostsFile, entry: HostEntry, save_callback=None) -> OperationResult:
"""
Execute an add command with undo/redo support.
Args:
hosts_file: The hosts file to modify
entry: The new entry to add
save_callback: Optional callback to save the file after adding
Returns:
OperationResult with success status and message
"""
if not self.edit_mode:
return OperationResult(False, "Not in edit mode")
command = AddEntryCommand(entry)
result = self.undo_redo_history.execute_command(command, hosts_file)
return result
def execute_delete_command(self, hosts_file: HostsFile, index: int, save_callback=None) -> OperationResult:
"""
Execute a delete command with undo/redo support.
Args:
hosts_file: The hosts file to modify
index: Index of the entry to delete
save_callback: Optional callback to save the file after deletion
Returns:
OperationResult with success status and message
"""
if not self.edit_mode:
return OperationResult(False, "Not in edit mode")
command = DeleteEntryCommand(index)
result = self.undo_redo_history.execute_command(command, hosts_file)
return result
def execute_update_command(self, hosts_file: HostsFile, index: int, ip_address: str, hostnames: list[str], comment: Optional[str], is_active: bool) -> OperationResult:
"""
Execute an update command with undo/redo support.
Args:
hosts_file: The hosts file to modify
index: Index of the entry to update
ip_address: New IP address
hostnames: New list of hostnames
comment: New comment (optional)
is_active: New active state
Returns:
OperationResult with success status and message
"""
if not self.edit_mode:
return OperationResult(False, "Not in edit mode")
command = UpdateEntryCommand(index, ip_address, hostnames, comment, is_active)
result = self.undo_redo_history.execute_command(command, hosts_file)
return result
def undo_last_operation(self, hosts_file: HostsFile) -> OperationResult:
"""
Undo the last operation.
Args:
hosts_file: The hosts file to operate on
Returns:
OperationResult with success status and message
"""
if not self.edit_mode:
return OperationResult(False, "Not in edit mode")
return self.undo_redo_history.undo(hosts_file)
def redo_last_operation(self, hosts_file: HostsFile) -> OperationResult:
"""
Redo the last undone operation.
Args:
hosts_file: The hosts file to operate on
Returns:
OperationResult with success status and message
"""
if not self.edit_mode:
return OperationResult(False, "Not in edit mode")
return self.undo_redo_history.redo(hosts_file)
def can_undo(self) -> bool:
"""Check if undo is available."""
return self.undo_redo_history.can_undo()
def can_redo(self) -> bool:
"""Check if redo is available."""
return self.undo_redo_history.can_redo()
def get_undo_description(self) -> Optional[str]:
"""Get description of the operation that would be undone."""
return self.undo_redo_history.get_undo_description()
def get_redo_description(self) -> Optional[str]:
"""Get description of the operation that would be redone."""
return self.undo_redo_history.get_redo_description()
def save_hosts_file(self, hosts_file: HostsFile) -> Tuple[bool, str]:
"""
Save the hosts file to disk with sudo permissions.
@ -686,16 +521,19 @@ class HostsManager:
["sudo", "chmod", "644", str(self._backup_path)], capture_output=True
)
class EditModeError(Exception):
"""Base exception for edit mode errors."""
pass
class PermissionError(EditModeError):
"""Raised when there are permission issues."""
pass
class ValidationError(EditModeError):
"""Raised when validation fails."""

View file

@ -237,20 +237,7 @@ class HostsManagerApp(App):
mode = "Edit" if self.edit_mode else "Read-only"
entry_count = len(self.hosts_file.entries)
active_count = len(self.hosts_file.get_active_entries())
# Add undo/redo status in edit mode
undo_redo_status = ""
if self.edit_mode:
can_undo = self.manager.can_undo()
can_redo = self.manager.can_redo()
if can_undo or can_redo:
undo_status = "Undo available" if can_undo else ""
redo_status = "Redo available" if can_redo else ""
statuses = [s for s in [undo_status, redo_status] if s]
if statuses:
undo_redo_status = f" | {', '.join(statuses)}"
status = f"{entry_count} entries ({active_count} active) | {mode}{undo_redo_status}"
status = f"{entry_count} entries ({active_count} active) | {mode}"
footer.set_status(status)
except Exception:
pass # Footer not ready yet
@ -522,22 +509,17 @@ class HostsManagerApp(App):
self.update_status("Entry creation cancelled")
return
# Add the entry using the command-based manager method
result = self.manager.execute_add_command(self.hosts_file, new_entry)
if result.success:
# Save the changes
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
if save_success:
# Refresh the table
self.table_handler.populate_entries_table()
# Move cursor to the newly added entry (last entry)
self.selected_entry_index = len(self.hosts_file.entries) - 1
self.table_handler.restore_cursor_position(new_entry)
self.update_status(f"{result.message} - Changes saved automatically")
else:
self.update_status(f"Entry added but save failed: {save_message}")
# Add the entry using the manager
success, message = self.manager.add_entry(self.hosts_file, new_entry)
if success:
# Refresh the table
self.table_handler.populate_entries_table()
# Move cursor to the newly added entry (last entry)
self.selected_entry_index = len(self.hosts_file.entries) - 1
self.table_handler.restore_cursor_position(new_entry)
self.update_status(f"{message}")
else:
self.update_status(f"{result.message}")
self.update_status(f"{message}")
self.push_screen(AddEntryModal(), handle_add_entry_result)
@ -567,26 +549,21 @@ class HostsManagerApp(App):
self.update_status("Entry deletion cancelled")
return
# Delete the entry using the command-based manager method
result = self.manager.execute_delete_command(
# Delete the entry using the manager
success, message = self.manager.delete_entry(
self.hosts_file, self.selected_entry_index
)
if result.success:
# Save the changes
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
if save_success:
# Adjust selected index if needed
if self.selected_entry_index >= len(self.hosts_file.entries):
self.selected_entry_index = max(0, len(self.hosts_file.entries) - 1)
if success:
# Adjust selected index if needed
if self.selected_entry_index >= len(self.hosts_file.entries):
self.selected_entry_index = max(0, len(self.hosts_file.entries) - 1)
# Refresh the table
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
self.update_status(f"{result.message} - Changes saved automatically")
else:
self.update_status(f"Entry deleted but save failed: {save_message}")
# Refresh the table
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
self.update_status(f"{message}")
else:
self.update_status(f"{result.message}")
self.update_status(f"{message}")
self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation)
@ -594,52 +571,6 @@ class HostsManagerApp(App):
"""Quit the application."""
self.navigation_handler.quit_application()
def action_undo(self) -> None:
"""Undo the last operation."""
if not self.edit_mode:
self.update_status("❌ Cannot undo: Application is in read-only mode")
return
if not self.manager.can_undo():
self.update_status("Nothing to undo")
return
# Get description before undoing
description = self.manager.get_undo_description()
# Perform undo
result = self.manager.undo_last_operation(self.hosts_file)
if result.success:
# Refresh the table and update UI
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
self.update_status(f"✅ Undone: {description or 'operation'}")
else:
self.update_status(f"❌ Undo failed: {result.message}")
def action_redo(self) -> None:
"""Redo the last undone operation."""
if not self.edit_mode:
self.update_status("❌ Cannot redo: Application is in read-only mode")
return
if not self.manager.can_redo():
self.update_status("Nothing to redo")
return
# Get description before redoing
description = self.manager.get_redo_description()
# Perform redo
result = self.manager.redo_last_operation(self.hosts_file)
if result.success:
# Refresh the table and update UI
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
self.update_status(f"✅ Redone: {description or 'operation'}")
else:
self.update_status(f"❌ Redo failed: {result.message}")
# Delegated methods for backward compatibility with tests
def has_entry_changes(self) -> bool:
"""Check if the current entry has been modified from its original values."""

View file

@ -42,8 +42,6 @@ HOSTS_MANAGER_BINDINGS = [
Binding("ctrl+s", "save_file", "Save hosts file", show=False),
Binding("shift+up", "move_entry_up", "Move entry up", show=False),
Binding("shift+down", "move_entry_down", "Move entry down", show=False),
Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"),
Binding("ctrl+y", "redo", "Redo", show=False, id="left:redo"),
Binding("escape", "exit_edit_entry", "Exit edit mode", show=False),
Binding("tab", "next_field", "Next field", show=False),
Binding("shift+tab", "prev_field", "Previous field", show=False),

View file

@ -30,11 +30,10 @@ class NavigationHandler:
# Remember current entry for cursor position restoration
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Use command-based method for undo/redo support
result = self.app.manager.execute_toggle_command(
success, message = self.app.manager.toggle_entry(
self.app.hosts_file, self.app.selected_entry_index
)
if result.success:
if success:
# Auto-save the changes immediately
save_success, save_message = self.app.manager.save_hosts_file(
self.app.hosts_file
@ -49,11 +48,11 @@ class NavigationHandler:
),
)
self.app.details_handler.update_entry_details()
self.app.update_status(f"{result.message} - Changes saved automatically")
self.app.update_status(f"{message} - Changes saved automatically")
else:
self.app.update_status(f"Entry toggled but save failed: {save_message}")
else:
self.app.update_status(f"Error toggling entry: {result.message}")
self.app.update_status(f"Error toggling entry: {message}")
def move_entry_up(self) -> None:
"""Move the selected entry up in the list."""
@ -67,11 +66,10 @@ class NavigationHandler:
self.app.update_status("No entries to move")
return
# Use command-based method for undo/redo support
result = self.app.manager.execute_move_command(
self.app.hosts_file, self.app.selected_entry_index, "up"
success, message = self.app.manager.move_entry_up(
self.app.hosts_file, self.app.selected_entry_index
)
if result.success:
if success:
# Auto-save the changes immediately
save_success, save_message = self.app.manager.save_hosts_file(
self.app.hosts_file
@ -89,11 +87,11 @@ class NavigationHandler:
if table.row_count > 0 and display_index < table.row_count:
table.move_cursor(row=display_index)
self.app.details_handler.update_entry_details()
self.app.update_status(f"{result.message} - Changes saved automatically")
self.app.update_status(f"{message} - Changes saved automatically")
else:
self.app.update_status(f"Entry moved but save failed: {save_message}")
else:
self.app.update_status(f"Error moving entry: {result.message}")
self.app.update_status(f"Error moving entry: {message}")
def move_entry_down(self) -> None:
"""Move the selected entry down in the list."""
@ -107,11 +105,10 @@ class NavigationHandler:
self.app.update_status("No entries to move")
return
# Use command-based method for undo/redo support
result = self.app.manager.execute_move_command(
self.app.hosts_file, self.app.selected_entry_index, "down"
success, message = self.app.manager.move_entry_down(
self.app.hosts_file, self.app.selected_entry_index
)
if result.success:
if success:
# Auto-save the changes immediately
save_success, save_message = self.app.manager.save_hosts_file(
self.app.hosts_file
@ -129,11 +126,11 @@ class NavigationHandler:
if table.row_count > 0 and display_index < table.row_count:
table.move_cursor(row=display_index)
self.app.details_handler.update_entry_details()
self.app.update_status(f"{result.message} - Changes saved automatically")
self.app.update_status(f"{message} - Changes saved automatically")
else:
self.app.update_status(f"Entry moved but save failed: {save_message}")
else:
self.app.update_status(f"Error moving entry: {result.message}")
self.app.update_status(f"Error moving entry: {message}")
def save_hosts_file(self) -> None:
"""Save the hosts file to disk."""

View file

@ -1,584 +0,0 @@
"""
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__])

View file

@ -267,10 +267,6 @@ class TestHostsManagerApp:
):
app = HostsManagerApp()
# Mock the query_one method to avoid UI dependencies
mock_footer = Mock()
app.query_one = Mock(return_value=mock_footer)
# Add test entries
app.hosts_file = HostsFile()
app.hosts_file.add_entry(
@ -284,12 +280,10 @@ class TestHostsManagerApp:
app.update_status()
# Verify footer status was updated
mock_footer.set_status.assert_called_once()
status_call = mock_footer.set_status.call_args[0][0]
assert "Read-only" in status_call
assert "2 entries" in status_call
assert "1 active" in status_call
# Verify sub_title was set correctly
assert "Read-only mode" in app.sub_title
assert "2 entries" in app.sub_title
assert "1 active" in app.sub_title
def test_update_status_custom_message(self):
"""Test status bar update with custom message."""
@ -305,18 +299,9 @@ class TestHostsManagerApp:
# Mock set_timer and query_one to avoid event loop and UI issues
app.set_timer = Mock()
mock_status_bar = Mock()
mock_footer = Mock()
app.query_one = Mock(return_value=mock_status_bar)
def mock_query_one(selector, widget_type=None):
if selector == "#status-bar":
return mock_status_bar
elif selector == "#custom-footer":
return mock_footer
return Mock()
app.query_one = mock_query_one
# Add test hosts_file for footer status generation
# Add test hosts_file for subtitle generation
app.hosts_file = HostsFile()
app.hosts_file.add_entry(
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
@ -332,11 +317,9 @@ class TestHostsManagerApp:
# Verify status bar was updated with custom message
mock_status_bar.update.assert_called_with("Custom status message")
mock_status_bar.remove_class.assert_called_with("hidden")
# Verify footer status was updated with current status (not the custom message)
mock_footer.set_status.assert_called_once()
footer_status = mock_footer.set_status.call_args[0][0]
assert "2 entries" in footer_status
assert "Read-only" in footer_status
# Verify subtitle shows current status (not the custom message)
assert "2 entries" in app.sub_title
assert "Read-only mode" in app.sub_title
# Verify timer was set for auto-clearing
app.set_timer.assert_called_once()