From 00519320146c795f1c320ce7e6c1898570a89bdc Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 29 Jul 2025 21:14:29 +0200 Subject: [PATCH] Add comprehensive tests for configuration management and main TUI components --- memory-bank/progress.md | 4 +- tests/test_config.py | 292 ++++++++++++++++++++++ tests/test_config_modal.py | 229 ++++++++++++++++++ tests/test_main.py | 484 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1008 insertions(+), 1 deletion(-) create mode 100644 tests/test_config.py create mode 100644 tests/test_config_modal.py create mode 100644 tests/test_main.py diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 2823609..28b367c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -19,7 +19,7 @@ - ✅ **Entry management**: List view with proper formatting and status indicators - ✅ **Detail view**: Comprehensive entry details in right pane - ✅ **Navigation**: Smooth keyboard navigation with cursor position restoration -- ✅ **Testing**: 42 comprehensive tests with 100% pass rate +- ✅ **Testing**: 97 comprehensive tests with 100% pass rate - ✅ **Code quality**: All ruff linting and formatting checks passing - ✅ **Error handling**: Graceful handling of file access and parsing errors - ✅ **Status feedback**: Informative status bar with file and entry information @@ -100,6 +100,8 @@ - ✅ **File integrity**: Perfect preservation of comments and formatting - ✅ **Test coverage**: Comprehensive test suite catching all edge cases - ✅ **Development workflow**: Smooth uv-based development experience +- ✅ **Complete test implementation**: Added comprehensive tests for config and main TUI components +- ✅ **Test-driven development**: All 97 tests passing with full coverage of application functionality ## Technical Implementation Details diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..bc4a86a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,292 @@ +""" +Tests for the configuration management module. + +This module contains unit tests for the Config class, +validating configuration loading, saving, and management functionality. +""" + +import pytest +import tempfile +import json +import os +from pathlib import Path +from unittest.mock import patch, mock_open + +from hosts.core.config import Config + + +class TestConfig: + """Test cases for the Config class.""" + + def test_config_initialization(self): + """Test basic config initialization with defaults.""" + with patch.object(Config, 'load'): + config = Config() + + # Check default settings + assert config.get("show_default_entries") is False + assert len(config.get("default_entries", [])) == 3 + assert config.get("window_settings", {}).get("last_sort_column") == "" + assert config.get("window_settings", {}).get("last_sort_ascending") is True + + def test_default_settings_structure(self): + """Test that default settings have the expected structure.""" + with patch.object(Config, 'load'): + config = Config() + + default_entries = config.get("default_entries", []) + assert len(default_entries) == 3 + + # Check localhost entries + localhost_entries = [e for e in default_entries if e["hostname"] == "localhost"] + assert len(localhost_entries) == 2 # IPv4 and IPv6 + + # Check broadcasthost entry + broadcast_entries = [e for e in default_entries if e["hostname"] == "broadcasthost"] + assert len(broadcast_entries) == 1 + assert broadcast_entries[0]["ip"] == "255.255.255.255" + + def test_config_paths(self): + """Test that config paths are set correctly.""" + with patch.object(Config, 'load'): + config = Config() + + expected_dir = Path.home() / ".config" / "hosts-manager" + expected_file = expected_dir / "config.json" + + assert config.config_dir == expected_dir + assert config.config_file == expected_file + + def test_get_existing_key(self): + """Test getting an existing configuration key.""" + with patch.object(Config, 'load'): + config = Config() + + result = config.get("show_default_entries") + assert result is False + + def test_get_nonexistent_key_with_default(self): + """Test getting a nonexistent key with default value.""" + with patch.object(Config, 'load'): + config = Config() + + result = config.get("nonexistent_key", "default_value") + assert result == "default_value" + + def test_get_nonexistent_key_without_default(self): + """Test getting a nonexistent key without default value.""" + with patch.object(Config, 'load'): + config = Config() + + result = config.get("nonexistent_key") + assert result is None + + def test_set_configuration_value(self): + """Test setting a configuration value.""" + with patch.object(Config, 'load'): + config = Config() + + config.set("test_key", "test_value") + assert config.get("test_key") == "test_value" + + def test_set_overwrites_existing_value(self): + """Test that setting overwrites existing values.""" + with patch.object(Config, 'load'): + config = Config() + + # Set initial value + config.set("show_default_entries", True) + assert config.get("show_default_entries") is True + + # Overwrite with new value + config.set("show_default_entries", False) + assert config.get("show_default_entries") is False + + def test_is_default_entry_true(self): + """Test identifying default entries correctly.""" + with patch.object(Config, 'load'): + config = Config() + + # Test localhost IPv4 + assert config.is_default_entry("127.0.0.1", "localhost") is True + + # Test localhost IPv6 + assert config.is_default_entry("::1", "localhost") is True + + # Test broadcasthost + assert config.is_default_entry("255.255.255.255", "broadcasthost") is True + + def test_is_default_entry_false(self): + """Test that non-default entries are not identified as default.""" + with patch.object(Config, 'load'): + config = Config() + + # Test custom entries + assert config.is_default_entry("192.168.1.1", "router") is False + assert config.is_default_entry("10.0.0.1", "test.local") is False + assert config.is_default_entry("127.0.0.1", "custom") is False + + def test_should_show_default_entries_default(self): + """Test default value for show_default_entries.""" + with patch.object(Config, 'load'): + config = Config() + + assert config.should_show_default_entries() is False + + def test_should_show_default_entries_configured(self): + """Test configured value for show_default_entries.""" + with patch.object(Config, 'load'): + config = Config() + config.set("show_default_entries", True) + + assert config.should_show_default_entries() is True + + def test_toggle_show_default_entries(self): + """Test toggling the show_default_entries setting.""" + with patch.object(Config, 'load'), patch.object(Config, 'save') as mock_save: + config = Config() + + # Initial state should be False + assert config.should_show_default_entries() is False + + # Toggle to True + config.toggle_show_default_entries() + assert config.should_show_default_entries() is True + mock_save.assert_called_once() + + # Toggle back to False + mock_save.reset_mock() + config.toggle_show_default_entries() + assert config.should_show_default_entries() is False + mock_save.assert_called_once() + + def test_load_nonexistent_file(self): + """Test loading config when file doesn't exist.""" + with patch('pathlib.Path.exists', return_value=False): + config = Config() + + # Should use defaults when file doesn't exist + assert config.get("show_default_entries") is False + + def test_load_existing_file(self): + """Test loading config from existing file.""" + test_config = { + "show_default_entries": True, + "custom_setting": "custom_value" + } + + with patch('pathlib.Path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data=json.dumps(test_config))): + config = Config() + + # Should load values from file + assert config.get("show_default_entries") is True + assert config.get("custom_setting") == "custom_value" + + # Should still have defaults for missing keys + assert len(config.get("default_entries", [])) == 3 + + def test_load_invalid_json(self): + """Test loading config with invalid JSON falls back to defaults.""" + with patch('pathlib.Path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data="invalid json")): + config = Config() + + # Should use defaults when JSON is invalid + assert config.get("show_default_entries") is False + + def test_load_file_io_error(self): + """Test loading config with file I/O error falls back to defaults.""" + with patch('pathlib.Path.exists', return_value=True), \ + patch('builtins.open', side_effect=IOError("File error")): + config = Config() + + # Should use defaults when file can't be read + assert config.get("show_default_entries") is False + + def test_save_creates_directory(self): + """Test that save creates config directory if it doesn't exist.""" + with patch.object(Config, 'load'), \ + patch('pathlib.Path.mkdir') as mock_mkdir, \ + patch('builtins.open', mock_open()) as mock_file: + config = Config() + config.save() + + # Should create directory with parents=True, exist_ok=True + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + mock_file.assert_called_once() + + def test_save_writes_json(self): + """Test that save writes configuration as JSON.""" + with patch.object(Config, 'load'), \ + patch('pathlib.Path.mkdir'), \ + patch('builtins.open', mock_open()) as mock_file: + config = Config() + config.set("test_key", "test_value") + config.save() + + # Check that file was opened for writing + mock_file.assert_called_once_with(config.config_file, 'w') + + # Check that JSON was written + handle = mock_file() + written_data = ''.join(call.args[0] for call in handle.write.call_args_list) + + # Should be valid JSON containing our test data + parsed_data = json.loads(written_data) + assert parsed_data["test_key"] == "test_value" + + def test_save_io_error_silent_fail(self): + """Test that save silently fails on I/O error.""" + with patch.object(Config, 'load'), \ + patch('pathlib.Path.mkdir'), \ + patch('builtins.open', side_effect=IOError("Write error")): + config = Config() + + # Should not raise exception + config.save() + + def test_save_directory_creation_error_silent_fail(self): + """Test that save silently fails on directory creation error.""" + with patch.object(Config, 'load'), \ + patch('pathlib.Path.mkdir', side_effect=OSError("Permission denied")): + config = Config() + + # Should not raise exception + config.save() + + def test_integration_load_save_roundtrip(self): + """Test complete load/save cycle with temporary file.""" + with tempfile.TemporaryDirectory() as temp_dir: + config_dir = Path(temp_dir) / "hosts-manager" + config_file = config_dir / "config.json" + + with patch.object(Config, '__init__', lambda self: None): + config = Config() + config.config_dir = config_dir + config.config_file = config_file + config._settings = config._load_default_settings() + + # Modify some settings + config.set("show_default_entries", True) + config.set("custom_setting", "test_value") + + # Save configuration + config.save() + + # Verify file was created + assert config_file.exists() + + # Create new config instance and load + config2 = Config() + config2.config_dir = config_dir + config2.config_file = config_file + config2._settings = config2._load_default_settings() + config2.load() + + # Verify settings were loaded correctly + assert config2.get("show_default_entries") is True + assert config2.get("custom_setting") == "test_value" + + # Verify defaults are still present + assert len(config2.get("default_entries", [])) == 3 diff --git a/tests/test_config_modal.py b/tests/test_config_modal.py new file mode 100644 index 0000000..7d2bbbf --- /dev/null +++ b/tests/test_config_modal.py @@ -0,0 +1,229 @@ +""" +Tests for the configuration modal TUI component. + +This module contains unit tests for the ConfigModal class, +validating modal behavior and configuration interaction. +""" + +import pytest +from unittest.mock import Mock, patch + +from textual.widgets import Checkbox, Button + +from hosts.core.config import Config +from hosts.tui.config_modal import ConfigModal + + +class TestConfigModal: + """Test cases for the ConfigModal class.""" + + def test_modal_initialization(self): + """Test modal initialization with config.""" + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = False + + modal = ConfigModal(mock_config) + assert modal.config == mock_config + + def test_modal_compose_method_exists(self): + """Test that modal has compose method.""" + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = True + + modal = ConfigModal(mock_config) + + # Test that compose method exists and is callable + assert hasattr(modal, 'compose') + assert callable(modal.compose) + + def test_action_save_updates_config(self): + """Test that save action updates configuration.""" + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = False + + modal = ConfigModal(mock_config) + modal.dismiss = Mock() + + # Mock the checkbox query + mock_checkbox = Mock() + mock_checkbox.value = True + modal.query_one = Mock(return_value=mock_checkbox) + + # Trigger save action + modal.action_save() + + # Verify config was updated + mock_config.set.assert_called_once_with("show_default_entries", True) + mock_config.save.assert_called_once() + modal.dismiss.assert_called_once_with(True) + + def test_action_save_preserves_false_state(self): + """Test that save action preserves False checkbox state.""" + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = True + + modal = ConfigModal(mock_config) + modal.dismiss = Mock() + + # Mock the checkbox query with False value + mock_checkbox = Mock() + mock_checkbox.value = False + modal.query_one = Mock(return_value=mock_checkbox) + + # Trigger save action + modal.action_save() + + # Verify the False value was saved + mock_config.set.assert_called_once_with("show_default_entries", False) + mock_config.save.assert_called_once() + modal.dismiss.assert_called_once_with(True) + + def test_action_cancel_no_config_changes(self): + """Test that cancel action doesn't modify configuration.""" + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = False + + modal = ConfigModal(mock_config) + modal.dismiss = Mock() + + # Trigger cancel action + modal.action_cancel() + + # Verify config was NOT updated + mock_config.set.assert_not_called() + mock_config.save.assert_not_called() + modal.dismiss.assert_called_once_with(False) + + def test_save_button_pressed_event(self): + """Test save button pressed event handling.""" + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = False + + modal = ConfigModal(mock_config) + modal.action_save = Mock() + + # Create mock save button + save_button = Mock() + save_button.id = "save-button" + event = Button.Pressed(save_button) + + modal.on_button_pressed(event) + + modal.action_save.assert_called_once() + + def test_cancel_button_pressed_event(self): + """Test cancel button pressed event handling.""" + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = False + + modal = ConfigModal(mock_config) + modal.action_cancel = Mock() + + # Create mock cancel button + cancel_button = Mock() + cancel_button.id = "cancel-button" + event = Button.Pressed(cancel_button) + + modal.on_button_pressed(event) + + modal.action_cancel.assert_called_once() + + def test_unknown_button_pressed_ignored(self): + """Test that unknown button presses are ignored.""" + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = False + + modal = ConfigModal(mock_config) + modal.action_save = Mock() + modal.action_cancel = Mock() + + # Create a mock button with unknown ID + unknown_button = Mock() + unknown_button.id = "unknown-button" + event = Button.Pressed(unknown_button) + + # Should not raise exception + modal.on_button_pressed(event) + + # Should not trigger any actions + modal.action_save.assert_not_called() + modal.action_cancel.assert_not_called() + + def test_modal_bindings_defined(self): + """Test that modal has expected key bindings.""" + mock_config = Mock(spec=Config) + modal = ConfigModal(mock_config) + + # Check that bindings are defined + assert len(modal.BINDINGS) == 2 + + # Check specific bindings + binding_keys = [binding.key for binding in modal.BINDINGS] + assert "escape" in binding_keys + assert "enter" in binding_keys + + binding_actions = [binding.action for binding in modal.BINDINGS] + assert "cancel" in binding_actions + assert "save" in binding_actions + + def test_modal_css_defined(self): + """Test that modal has CSS styling defined.""" + mock_config = Mock(spec=Config) + modal = ConfigModal(mock_config) + + # Check that CSS is defined + assert hasattr(modal, 'CSS') + assert isinstance(modal.CSS, str) + assert len(modal.CSS) > 0 + + # Check for key CSS classes + assert "config-container" in modal.CSS + assert "config-title" in modal.CSS + assert "button-row" in modal.CSS + + def test_config_method_called_during_initialization(self): + """Test that config method is called during modal setup.""" + mock_config = Mock(spec=Config) + + # Test with True + mock_config.should_show_default_entries.return_value = True + modal = ConfigModal(mock_config) + + # Verify the config object is stored + assert modal.config == mock_config + + # Test with False + mock_config.should_show_default_entries.return_value = False + modal = ConfigModal(mock_config) + + # Verify the config object is stored + assert modal.config == mock_config + + def test_compose_method_signature(self): + """Test that compose method has the expected signature.""" + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = False + + modal = ConfigModal(mock_config) + + # Test that compose method exists and has correct signature + import inspect + sig = inspect.signature(modal.compose) + assert len(sig.parameters) == 0 # No parameters except self + + # Test return type annotation if present + if sig.return_annotation != inspect.Signature.empty: + from textual.app import ComposeResult + assert sig.return_annotation == ComposeResult + + def test_modal_inheritance(self): + """Test that ConfigModal properly inherits from ModalScreen.""" + mock_config = Mock(spec=Config) + modal = ConfigModal(mock_config) + + from textual.screen import ModalScreen + assert isinstance(modal, ModalScreen) + + # Should have the config attribute + assert hasattr(modal, 'config') + assert modal.config == mock_config diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..6c51846 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,484 @@ +""" +Tests for the main TUI application. + +This module contains unit tests for the HostsManagerApp class, +validating application behavior, navigation, and user interactions. +""" + +import pytest +import tempfile +from unittest.mock import Mock, patch, MagicMock +from pathlib import Path + +from textual.widgets import DataTable, Static + +from hosts.main import HostsManagerApp +from hosts.core.models import HostEntry, HostsFile +from hosts.core.parser import HostsParser +from hosts.core.config import Config + + +class TestHostsManagerApp: + """Test cases for the HostsManagerApp class.""" + + def test_app_initialization(self): + """Test application initialization.""" + with patch('hosts.main.HostsParser'), patch('hosts.main.Config'): + app = HostsManagerApp() + + assert app.title == "Hosts Manager" + assert app.sub_title == "Read-only mode" + assert app.edit_mode is False + assert app.selected_entry_index == 0 + assert app.sort_column == "" + assert app.sort_ascending is True + + def test_app_compose_method_exists(self): + """Test that app has compose method.""" + with patch('hosts.main.HostsParser'), patch('hosts.main.Config'): + app = HostsManagerApp() + + # Test that compose method exists and is callable + assert hasattr(app, 'compose') + assert callable(app.compose) + + def test_load_hosts_file_success(self): + """Test successful hosts file loading.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + # Create test hosts file + test_hosts = HostsFile() + test_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) + test_hosts.add_entry(test_entry) + + mock_parser.parse.return_value = test_hosts + mock_parser.get_file_info.return_value = { + 'path': '/etc/hosts', + 'exists': True, + 'size': 100 + } + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + app.populate_entries_table = Mock() + app.update_entry_details = Mock() + app.set_timer = Mock() + + app.load_hosts_file() + + # Verify hosts file was loaded + assert len(app.hosts_file.entries) == 1 + assert app.hosts_file.entries[0].ip_address == "127.0.0.1" + mock_parser.parse.assert_called_once() + + def test_load_hosts_file_not_found(self): + """Test handling of missing hosts file.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + mock_parser.parse.side_effect = FileNotFoundError("File not found") + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + app.update_status = Mock() + + app.load_hosts_file() + + # Should handle error gracefully + app.update_status.assert_called_with("Error: Hosts file not found") + + def test_load_hosts_file_permission_error(self): + """Test handling of permission denied error.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + mock_parser.parse.side_effect = PermissionError("Permission denied") + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + app.update_status = Mock() + + app.load_hosts_file() + + # Should handle error gracefully + app.update_status.assert_called_with("Error: Permission denied") + + def test_populate_entries_table_logic(self): + """Test populating DataTable logic without UI dependencies.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + mock_config.should_show_default_entries.return_value = True + mock_config.is_default_entry.return_value = False + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + + # Mock the query_one method to return a mock table + mock_table = Mock() + app.query_one = Mock(return_value=mock_table) + + # Add test entries + app.hosts_file = HostsFile() + active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) + inactive_entry = HostEntry( + ip_address="192.168.1.1", + hostnames=["router"], + is_active=False + ) + app.hosts_file.add_entry(active_entry) + app.hosts_file.add_entry(inactive_entry) + + app.populate_entries_table() + + # Verify table methods were called + mock_table.clear.assert_called_once_with(columns=True) + mock_table.add_columns.assert_called_once() + assert mock_table.add_row.call_count == 2 # Two entries added + + def test_update_entry_details_with_entry(self): + """Test updating entry details pane.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + + # Mock the query_one method + mock_details = Mock() + app.query_one = Mock(return_value=mock_details) + + # Add test entry + app.hosts_file = HostsFile() + test_entry = HostEntry( + ip_address="127.0.0.1", + hostnames=["localhost", "local"], + comment="Test comment" + ) + app.hosts_file.add_entry(test_entry) + app.selected_entry_index = 0 + + app.update_entry_details() + + # Verify update was called with content containing entry details + mock_details.update.assert_called_once() + call_args = mock_details.update.call_args[0][0] + assert "127.0.0.1" in call_args + assert "localhost, local" in call_args + assert "Test comment" in call_args + + def test_update_entry_details_no_entries(self): + """Test updating entry details with no entries.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + + # Mock the query_one method + mock_details = Mock() + app.query_one = Mock(return_value=mock_details) + + app.hosts_file = HostsFile() + + app.update_entry_details() + + # Verify update was called with "No entries loaded" + mock_details.update.assert_called_once_with("No entries loaded") + + def test_update_status_default(self): + """Test status bar update with default information.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + mock_parser.get_file_info.return_value = { + 'path': '/etc/hosts', + 'exists': True, + 'size': 100 + } + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + + # Mock the query_one method + mock_status = Mock() + app.query_one = Mock(return_value=mock_status) + + # Add test entries + app.hosts_file = HostsFile() + app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])) + app.hosts_file.add_entry(HostEntry( + ip_address="192.168.1.1", + hostnames=["router"], + is_active=False + )) + + app.update_status() + + # Verify status was updated + mock_status.update.assert_called_once() + call_args = mock_status.update.call_args[0][0] + assert "Read-only mode" in call_args + assert "2 entries" in call_args + assert "1 active" in call_args + + def test_update_status_custom_message(self): + """Test status bar update with custom message.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + + # Mock the query_one method + mock_status = Mock() + app.query_one = Mock(return_value=mock_status) + + app.update_status("Custom status message") + + # Verify status was updated with custom message + mock_status.update.assert_called_once_with("Custom status message") + + def test_action_reload(self): + """Test reload action.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + app.load_hosts_file = Mock() + app.update_status = Mock() + + app.action_reload() + + app.load_hosts_file.assert_called_once() + app.update_status.assert_called_with("Hosts file reloaded") + + def test_action_help(self): + """Test help action.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + app.update_status = Mock() + + app.action_help() + + # Should update status with help message + app.update_status.assert_called_once() + call_args = app.update_status.call_args[0][0] + assert "Help:" in call_args + + def test_action_config(self): + """Test config action opens modal.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + app.push_screen = Mock() + + app.action_config() + + # Should push config modal screen + app.push_screen.assert_called_once() + args = app.push_screen.call_args[0] + assert len(args) >= 1 # ConfigModal instance + + def test_action_sort_by_ip_ascending(self): + """Test sorting by IP address in ascending order.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + + # Add test entries in reverse order + app.hosts_file = HostsFile() + app.hosts_file.add_entry(HostEntry(ip_address="192.168.1.1", hostnames=["router"])) + app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])) + app.hosts_file.add_entry(HostEntry(ip_address="10.0.0.1", hostnames=["test"])) + + app.populate_entries_table = Mock() + app.update_status = Mock() + + app.action_sort_by_ip() + + # Check that entries are sorted + assert app.hosts_file.entries[0].ip_address == "10.0.0.1" + assert app.hosts_file.entries[1].ip_address == "127.0.0.1" + assert app.hosts_file.entries[2].ip_address == "192.168.1.1" + + assert app.sort_column == "ip" + assert app.sort_ascending is True + app.populate_entries_table.assert_called_once() + + def test_action_sort_by_hostname_ascending(self): + """Test sorting by hostname in ascending order.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + + # Add test entries in reverse alphabetical order + app.hosts_file = HostsFile() + app.hosts_file.add_entry(HostEntry(ip_address="127.0.0.1", hostnames=["zebra"])) + app.hosts_file.add_entry(HostEntry(ip_address="192.168.1.1", hostnames=["alpha"])) + app.hosts_file.add_entry(HostEntry(ip_address="10.0.0.1", hostnames=["beta"])) + + app.populate_entries_table = Mock() + app.update_status = Mock() + + app.action_sort_by_hostname() + + # Check that entries are sorted alphabetically + assert app.hosts_file.entries[0].hostnames[0] == "alpha" + assert app.hosts_file.entries[1].hostnames[0] == "beta" + assert app.hosts_file.entries[2].hostnames[0] == "zebra" + + assert app.sort_column == "hostname" + assert app.sort_ascending is True + app.populate_entries_table.assert_called_once() + + def test_data_table_row_highlighted_event(self): + """Test DataTable row highlighting event handling.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + app.update_entry_details = Mock() + + # Create mock event with required parameters + mock_table = Mock() + mock_table.id = "entries-table" + event = Mock() + event.data_table = mock_table + event.cursor_row = 2 + + app.on_data_table_row_highlighted(event) + + # Should update selected index and details + assert app.selected_entry_index == 2 + app.update_entry_details.assert_called_once() + + def test_data_table_header_selected_ip_column(self): + """Test DataTable header selection for IP column.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + app.action_sort_by_ip = Mock() + + # Create mock event for IP column + mock_table = Mock() + mock_table.id = "entries-table" + event = Mock() + event.data_table = mock_table + event.column_key = "IP Address" + + app.on_data_table_header_selected(event) + + app.action_sort_by_ip.assert_called_once() + + def test_restore_cursor_position_logic(self): + """Test cursor position restoration logic.""" + mock_parser = Mock(spec=HostsParser) + mock_config = Mock(spec=Config) + + with patch('hosts.main.HostsParser', return_value=mock_parser), \ + patch('hosts.main.Config', return_value=mock_config): + + app = HostsManagerApp() + + # Mock the query_one method to avoid UI dependencies + mock_table = Mock() + app.query_one = Mock(return_value=mock_table) + app.update_entry_details = Mock() + + # Add test entries + app.hosts_file = HostsFile() + entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) + entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"]) + app.hosts_file.add_entry(entry1) + app.hosts_file.add_entry(entry2) + + # Test the logic without UI dependencies + # Find the index of entry2 + target_index = None + for i, entry in enumerate(app.hosts_file.entries): + if entry.ip_address == entry2.ip_address and entry.hostnames == entry2.hostnames: + target_index = i + break + + # Should find the matching entry at index 1 + assert target_index == 1 + + def test_app_bindings_defined(self): + """Test that application has expected key bindings.""" + with patch('hosts.main.HostsParser'), patch('hosts.main.Config'): + app = HostsManagerApp() + + # Check that bindings are defined + assert len(app.BINDINGS) >= 6 + + # Check specific bindings exist (handle both Binding objects and tuples) + binding_keys = [] + for binding in app.BINDINGS: + if hasattr(binding, 'key'): + # Binding object + binding_keys.append(binding.key) + elif isinstance(binding, tuple) and len(binding) >= 1: + # Tuple format (key, action, description) + binding_keys.append(binding[0]) + + assert "q" in binding_keys + assert "r" in binding_keys + assert "h" in binding_keys + assert "i" in binding_keys + assert "n" in binding_keys + assert "c" in binding_keys + assert "ctrl+c" in binding_keys + + def test_main_function(self): + """Test main entry point function.""" + with patch('hosts.main.HostsManagerApp') as mock_app_class: + mock_app = Mock() + mock_app_class.return_value = mock_app + + from hosts.main import main + main() + + # Should create and run app + mock_app_class.assert_called_once() + mock_app.run.assert_called_once()