Compare commits
2 commits
77d4a2e955
...
e6f3e9f3d4
Author | SHA1 | Date | |
---|---|---|---|
e6f3e9f3d4 | |||
bc0f8b99e8 |
9 changed files with 1487 additions and 64 deletions
|
@ -2,25 +2,20 @@
|
|||
|
||||
## Current Work Focus
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
### 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
|
||||
### Priority 1: 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 3: Phase 6 Polish
|
||||
### Priority 2: Phase 6 Polish
|
||||
1. **Bulk operations**: Select and modify multiple entries
|
||||
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
|
||||
2. **Performance optimization**: Testing with large hosts files
|
||||
3. **Accessibility**: Screen reader support and keyboard accessibility
|
||||
|
||||
## Recent Changes
|
||||
|
||||
|
@ -51,6 +46,27 @@ 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
|
||||
|
|
|
@ -72,13 +72,13 @@
|
|||
|
||||
## What's Left to Build
|
||||
|
||||
### Phase 4: Advanced Edit Features ✅ LARGELY COMPLETE
|
||||
### Phase 4: Advanced Edit Features ✅ 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
|
||||
- ❌ **Bulk operations**: Select and modify multiple entries (planned)
|
||||
- ❌ **Undo/Redo**: Command pattern implementation for operation history (planned)
|
||||
- ✅ **Undo/Redo**: Complete command pattern implementation with 43 comprehensive tests
|
||||
- ❌ ~~**Bulk operations**: Select and modify multiple entries (won't be implemented)~~
|
||||
|
||||
### Phase 5: Advanced Features
|
||||
- ❌ **DNS resolution**: Resolve hostnames to IP addresses
|
||||
|
@ -127,7 +127,8 @@
|
|||
- **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
|
||||
- **Total**: 149 tests with 100% pass rate and comprehensive edge case coverage
|
||||
- **Commands**: 43 tests for command pattern and undo/redo functionality
|
||||
- **Total**: 192 tests with 100% pass rate and comprehensive edge case coverage
|
||||
|
||||
### Code Quality Standards
|
||||
- **Linting**: All ruff checks passing with clean code
|
||||
|
@ -138,6 +139,25 @@
|
|||
|
||||
## 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:
|
||||
|
||||
|
|
550
src/hosts/core/commands.py
Normal file
550
src/hosts/core/commands.py
Normal file
|
@ -0,0 +1,550 @@
|
|||
"""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}"
|
|
@ -12,7 +12,15 @@ 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:
|
||||
"""
|
||||
|
@ -112,7 +120,6 @@ class PermissionManager:
|
|||
self.has_sudo = False
|
||||
self._sudo_validated = False
|
||||
|
||||
|
||||
class HostsManager:
|
||||
"""
|
||||
Main manager for hosts file edit operations.
|
||||
|
@ -126,6 +133,7 @@ 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]:
|
||||
"""
|
||||
|
@ -171,6 +179,7 @@ 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}"
|
||||
|
@ -412,6 +421,162 @@ 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.
|
||||
|
@ -521,19 +686,16 @@ 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."""
|
||||
|
||||
|
|
|
@ -237,7 +237,20 @@ 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())
|
||||
status = f"{entry_count} entries ({active_count} active) | {mode}"
|
||||
|
||||
# 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}"
|
||||
footer.set_status(status)
|
||||
except Exception:
|
||||
pass # Footer not ready yet
|
||||
|
@ -509,17 +522,22 @@ class HostsManagerApp(App):
|
|||
self.update_status("Entry creation cancelled")
|
||||
return
|
||||
|
||||
# Add the entry using the manager
|
||||
success, message = self.manager.add_entry(self.hosts_file, new_entry)
|
||||
if success:
|
||||
# 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"✅ {message}")
|
||||
self.update_status(f"✅ {result.message} - Changes saved automatically")
|
||||
else:
|
||||
self.update_status(f"❌ {message}")
|
||||
self.update_status(f"Entry added but save failed: {save_message}")
|
||||
else:
|
||||
self.update_status(f"❌ {result.message}")
|
||||
|
||||
self.push_screen(AddEntryModal(), handle_add_entry_result)
|
||||
|
||||
|
@ -549,11 +567,14 @@ class HostsManagerApp(App):
|
|||
self.update_status("Entry deletion cancelled")
|
||||
return
|
||||
|
||||
# Delete the entry using the manager
|
||||
success, message = self.manager.delete_entry(
|
||||
# Delete the entry using the command-based manager method
|
||||
result = self.manager.execute_delete_command(
|
||||
self.hosts_file, self.selected_entry_index
|
||||
)
|
||||
if success:
|
||||
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)
|
||||
|
@ -561,9 +582,11 @@ class HostsManagerApp(App):
|
|||
# Refresh the table
|
||||
self.table_handler.populate_entries_table()
|
||||
self.details_handler.update_entry_details()
|
||||
self.update_status(f"✅ {message}")
|
||||
self.update_status(f"✅ {result.message} - Changes saved automatically")
|
||||
else:
|
||||
self.update_status(f"❌ {message}")
|
||||
self.update_status(f"Entry deleted but save failed: {save_message}")
|
||||
else:
|
||||
self.update_status(f"❌ {result.message}")
|
||||
|
||||
self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation)
|
||||
|
||||
|
@ -571,6 +594,52 @@ 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."""
|
||||
|
|
|
@ -42,6 +42,8 @@ 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),
|
||||
|
|
|
@ -30,10 +30,11 @@ class NavigationHandler:
|
|||
# Remember current entry for cursor position restoration
|
||||
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||
|
||||
success, message = self.app.manager.toggle_entry(
|
||||
# Use command-based method for undo/redo support
|
||||
result = self.app.manager.execute_toggle_command(
|
||||
self.app.hosts_file, self.app.selected_entry_index
|
||||
)
|
||||
if success:
|
||||
if result.success:
|
||||
# Auto-save the changes immediately
|
||||
save_success, save_message = self.app.manager.save_hosts_file(
|
||||
self.app.hosts_file
|
||||
|
@ -48,11 +49,11 @@ class NavigationHandler:
|
|||
),
|
||||
)
|
||||
self.app.details_handler.update_entry_details()
|
||||
self.app.update_status(f"{message} - Changes saved automatically")
|
||||
self.app.update_status(f"{result.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: {message}")
|
||||
self.app.update_status(f"Error toggling entry: {result.message}")
|
||||
|
||||
def move_entry_up(self) -> None:
|
||||
"""Move the selected entry up in the list."""
|
||||
|
@ -66,10 +67,11 @@ class NavigationHandler:
|
|||
self.app.update_status("No entries to move")
|
||||
return
|
||||
|
||||
success, message = self.app.manager.move_entry_up(
|
||||
self.app.hosts_file, self.app.selected_entry_index
|
||||
# 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"
|
||||
)
|
||||
if success:
|
||||
if result.success:
|
||||
# Auto-save the changes immediately
|
||||
save_success, save_message = self.app.manager.save_hosts_file(
|
||||
self.app.hosts_file
|
||||
|
@ -87,11 +89,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"{message} - Changes saved automatically")
|
||||
self.app.update_status(f"{result.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: {message}")
|
||||
self.app.update_status(f"Error moving entry: {result.message}")
|
||||
|
||||
def move_entry_down(self) -> None:
|
||||
"""Move the selected entry down in the list."""
|
||||
|
@ -105,10 +107,11 @@ class NavigationHandler:
|
|||
self.app.update_status("No entries to move")
|
||||
return
|
||||
|
||||
success, message = self.app.manager.move_entry_down(
|
||||
self.app.hosts_file, self.app.selected_entry_index
|
||||
# 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"
|
||||
)
|
||||
if success:
|
||||
if result.success:
|
||||
# Auto-save the changes immediately
|
||||
save_success, save_message = self.app.manager.save_hosts_file(
|
||||
self.app.hosts_file
|
||||
|
@ -126,11 +129,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"{message} - Changes saved automatically")
|
||||
self.app.update_status(f"{result.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: {message}")
|
||||
self.app.update_status(f"Error moving entry: {result.message}")
|
||||
|
||||
def save_hosts_file(self) -> None:
|
||||
"""Save the hosts file to disk."""
|
||||
|
|
584
tests/test_commands.py
Normal file
584
tests/test_commands.py
Normal 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__])
|
|
@ -267,6 +267,10 @@ 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(
|
||||
|
@ -280,10 +284,12 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_status()
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
def test_update_status_custom_message(self):
|
||||
"""Test status bar update with custom message."""
|
||||
|
@ -299,9 +305,18 @@ class TestHostsManagerApp:
|
|||
# Mock set_timer and query_one to avoid event loop and UI issues
|
||||
app.set_timer = Mock()
|
||||
mock_status_bar = Mock()
|
||||
app.query_one = Mock(return_value=mock_status_bar)
|
||||
mock_footer = Mock()
|
||||
|
||||
# Add test hosts_file for subtitle generation
|
||||
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
|
||||
app.hosts_file = HostsFile()
|
||||
app.hosts_file.add_entry(
|
||||
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||
|
@ -317,9 +332,11 @@ 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 subtitle shows current status (not the custom message)
|
||||
assert "2 entries" in app.sub_title
|
||||
assert "Read-only mode" in app.sub_title
|
||||
# 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 timer was set for auto-clearing
|
||||
app.set_timer.assert_called_once()
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue