hosts/tests/test_main.py
2026-09-04 18:31:25 +02:00

1660 lines
66 KiB
Python

"""
Tests for the main TUI application.
This module contains unit tests for the HostsManagerApp class,
validating application behavior, navigation, and user interactions.
"""
from datetime import datetime
from typing import Any, cast
from unittest.mock import AsyncMock, Mock, patch
import pytest
from hosts.tui.app import HostsManagerApp
from hosts.core.models import HostEntry, HostsFile
from hosts.core.parser import HostsParser
from hosts.core.config import Config
from hosts.core.dns import DNSResolution, DNSResolutionStatus
class TestHostsManagerApp:
"""Test cases for the HostsManagerApp class."""
@staticmethod
def fail_saves(app: HostsManagerApp) -> None:
"""Configure a TUI app with an isolated failed-persistence boundary."""
app.manager.save_hosts_file = Mock(return_value=(False, "Permission denied"))
app.table_handler.populate_entries_table = Mock()
app.table_handler.move_cursor_to_entry_index = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
def test_app_initialization(self):
"""Test application initialization."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
assert app.title == "/etc/hosts Manager"
assert app.sub_title == "" # Now set by update_status
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.tui.app.HostsParser"), patch("hosts.tui.app.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.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.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("Hosts file not found")
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.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 loading hosts file: 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.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.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 loading hosts file: 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.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.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)
mock_config.should_show_default_entries.return_value = True
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock the query_one method to return disabled input widgets
mock_details_display = Mock()
mock_edit_form = Mock()
mock_ip_input = Mock()
mock_hostname_input = Mock()
mock_comment_input = Mock()
mock_active_checkbox = Mock()
def mock_query_one(selector, widget_type=None):
if selector == "#entry-details-display":
return mock_details_display
elif selector == "#entry-edit-form":
return mock_edit_form
elif selector == "#details-ip-input":
return mock_ip_input
elif selector == "#details-hostname-input":
return mock_hostname_input
elif selector == "#details-comment-input":
return mock_comment_input
elif selector == "#details-active-checkbox":
return mock_active_checkbox
return Mock()
cast(Any, app).query_one = mock_query_one
# 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 read-only detail rows were updated with entry data.
mock_details_display.remove_class.assert_called_with("hidden")
mock_edit_form.add_class.assert_called_with("hidden")
mock_ip_input.update.assert_called_with("127.0.0.1")
mock_hostname_input.update.assert_called_with("localhost, local")
mock_comment_input.update.assert_called_with("Test comment")
mock_active_checkbox.update.assert_called_with(
"■ Default (protected) · ✓ Active"
)
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.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock the query_one method to return disabled input widgets
mock_details_display = Mock()
mock_edit_form = Mock()
mock_ip_input = Mock()
mock_hostname_input = Mock()
mock_comment_input = Mock()
mock_active_checkbox = Mock()
mock_empty_state = Mock()
def mock_query_one(selector, widget_type=None):
if selector == "#entry-details-display":
return mock_details_display
elif selector == "#entry-edit-form":
return mock_edit_form
elif selector == "#details-ip-input":
return mock_ip_input
elif selector == "#details-hostname-input":
return mock_hostname_input
elif selector == "#details-comment-input":
return mock_comment_input
elif selector == "#details-active-checkbox":
return mock_active_checkbox
elif selector == "#details-empty-state":
return mock_empty_state
return Mock()
cast(Any, app).query_one = mock_query_one
app.hosts_file = HostsFile()
app.update_entry_details()
# Verify the detail pane names the empty condition.
mock_details_display.remove_class.assert_called_with("hidden")
mock_edit_form.add_class.assert_called_with("hidden")
mock_empty_state.update.assert_called_with("No Host Entries are 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.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
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(
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 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."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock set_timer and query_one to avoid event loop and UI issues
app.set_timer = Mock()
mock_message_rail = Mock()
mock_footer = Mock()
def mock_query_one(selector, widget_type=None):
if selector == "#message-rail":
return mock_message_rail
elif selector == "#custom-footer":
return mock_footer
return Mock()
cast(Any, 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"])
)
app.hosts_file.add_entry(
HostEntry(
ip_address="192.168.1.1", hostnames=["router"], is_active=False
)
)
app.update_status("Custom status message")
# Verify the reserved message rail was updated with the message.
mock_message_rail.update.assert_called_with("Custom status message")
# 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()
def test_action_reload(self):
"""Test reload action."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.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.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.push_screen = Mock()
app.action_help()
# Help is a dedicated overlay, not a docked panel.
app.push_screen.assert_called_once()
def test_action_config(self):
"""Test config action opens modal."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.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_orders_display_without_changing_file_order(self):
"""Sorting by IP changes the display order without changing file order."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
mock_config.should_show_default_entries.return_value = True
# Keep default entries deliberately out of their fixed display 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="::1", hostnames=["localhost"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="10.0.0.1", hostnames=["test"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="255.255.255.255", hostnames=["broadcasthost"])
)
original_order = [entry.ip_address for entry in app.hosts_file.entries]
# Mock the table_handler methods to avoid UI queries
app.table_handler.populate_entries_table = Mock()
app.table_handler.restore_cursor_position = Mock()
app.update_status = Mock()
app.action_sort_by_ip()
assert [
entry.ip_address for entry in app.table_handler.get_visible_entries()
] == ["127.0.0.1", "255.255.255.255", "::1", "10.0.0.1", "192.168.1.1"]
assert [
entry.ip_address for entry in app.hosts_file.entries
] == original_order
assert app.sort_column == "ip"
assert app.sort_ascending is True
app.table_handler.populate_entries_table.assert_called_once()
app.action_sort_by_ip()
assert [
entry.ip_address for entry in app.table_handler.get_visible_entries()
] == ["127.0.0.1", "255.255.255.255", "::1", "192.168.1.1", "10.0.0.1"]
assert [
entry.ip_address for entry in app.hosts_file.entries
] == original_order
def test_action_sort_by_hostname_keeps_default_entries_first_in_both_directions(
self,
):
"""Sorting by hostname leaves Default Entries first when toggled."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
mock_config.should_show_default_entries.return_value = True
app.hosts_file = HostsFile()
app.hosts_file.add_entry(
HostEntry(ip_address="192.168.1.1", hostnames=["alpha"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="::1", hostnames=["localhost"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="10.0.0.1", hostnames=["zebra"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="255.255.255.255", hostnames=["broadcasthost"])
)
original_order = [entry.hostnames[0] for entry in app.hosts_file.entries]
# Mock the table_handler methods to avoid UI queries
app.table_handler.populate_entries_table = Mock()
app.table_handler.restore_cursor_position = Mock()
app.update_status = Mock()
app.action_sort_by_hostname()
assert [
entry.ip_address for entry in app.table_handler.get_visible_entries()
] == ["127.0.0.1", "255.255.255.255", "::1", "192.168.1.1", "10.0.0.1"]
assert app.sort_column == "hostname"
assert app.sort_ascending is True
app.table_handler.populate_entries_table.assert_called_once()
app.action_sort_by_hostname()
assert [
entry.ip_address for entry in app.table_handler.get_visible_entries()
] == ["127.0.0.1", "255.255.255.255", "::1", "10.0.0.1", "192.168.1.1"]
assert [
entry.hostnames[0] for entry in app.hosts_file.entries
] == original_order
def test_sorting_in_read_only_mode_preserves_file_order_when_mutation_autosaves(
self,
):
"""A privileged mutation saves file order after sorting in Read-only Mode."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
mock_config.should_show_default_entries.return_value = True
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.168.1.1", hostnames=["router"]),
HostEntry(ip_address="10.0.0.1", hostnames=["test"]),
]
)
app.table_handler.populate_entries_table = Mock()
app.table_handler.restore_cursor_position = Mock()
app.details_handler.update_entry_details = Mock()
app.set_timer = Mock()
app.update_status = Mock()
serialized_files = []
app.manager.enter_edit_mode = Mock(return_value=(True, "Edit mode enabled"))
def toggle_entry(hosts_file, index):
hosts_file.toggle_entry(index)
return Mock(success=True, message="Entry toggled")
def save_hosts_file(hosts_file):
serialized_files.append(HostsParser().serialize(hosts_file))
return True, "Hosts file saved successfully"
app.manager.execute_toggle_command = Mock(side_effect=toggle_entry)
app.manager.save_hosts_file = Mock(side_effect=save_hosts_file)
app.action_sort_by_ip()
assert app.edit_mode is False
app.action_toggle_edit_mode()
app.action_toggle_entry()
assert app.edit_mode is True
assert len(serialized_files) == 1
assert serialized_files[0].index("router") < serialized_files[0].index(
"test"
)
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.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock the details_handler and table_handler methods
app.details_handler.update_entry_details = Mock()
app.table_handler.display_index_to_actual_index = Mock(return_value=2)
# 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.details_handler.update_entry_details.assert_called_once()
app.table_handler.display_index_to_actual_index.assert_called_once_with(2)
def test_data_table_row_highlighted_with_no_visible_entries(self):
"""Handle Textual's negative cursor row when all entries are hidden."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
mock_config.should_show_default_entries.return_value = False
mock_config.is_default_entry.return_value = True
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])]
)
app.details_handler.update_entry_details = Mock()
mock_table = Mock()
mock_table.id = "entries-table"
event = Mock()
event.data_table = mock_table
event.cursor_row = -1
app.on_data_table_row_highlighted(event)
assert app.selected_entry_index == 0
app.details_handler.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.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.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.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.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.tui.app.HostsParser"), patch("hosts.tui.app.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 "question_mark" in binding_keys # Help binding (? key)
assert "i" in binding_keys
assert "n" in binding_keys
assert "c" in binding_keys
assert "ctrl+c" in binding_keys
def test_radio_set_event_handling_ip_entry(self):
"""Test radio set event handling for IP entry type."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.edit_handler.handle_entry_type_change = Mock()
# Create mock radio set event for IP entry
mock_radio_set = Mock()
mock_radio_set.id = "edit-entry-type-radio"
mock_pressed_radio = Mock()
mock_pressed_radio.id = "edit-ip-entry-radio"
event = Mock()
event.radio_set = mock_radio_set
event.pressed = mock_pressed_radio
app.on_radio_set_changed(event)
# Should handle IP entry type change
app.edit_handler.handle_entry_type_change.assert_called_once_with("ip")
def test_radio_set_event_handling_dns_entry(self):
"""Test radio set event handling for DNS entry type."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.edit_handler.handle_entry_type_change = Mock()
# Create mock radio set event for DNS entry
mock_radio_set = Mock()
mock_radio_set.id = "edit-entry-type-radio"
mock_pressed_radio = Mock()
mock_pressed_radio.id = "edit-dns-entry-radio"
event = Mock()
event.radio_set = mock_radio_set
event.pressed = mock_pressed_radio
app.on_radio_set_changed(event)
# Should handle DNS entry type change
app.edit_handler.handle_entry_type_change.assert_called_once_with("dns")
def test_entry_type_detection_ip_entry(self):
"""Test entry type detection for IP entries."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Add IP entry (no DNS name)
app.hosts_file = HostsFile()
ip_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
app.hosts_file.add_entry(ip_entry)
app.selected_entry_index = 0
entry_type = app.edit_handler.get_current_entry_type()
assert entry_type == "ip"
def test_entry_type_detection_dns_entry(self):
"""Test entry type detection for DNS entries."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Add DNS entry with DNS name
app.hosts_file = HostsFile()
dns_entry = HostEntry(ip_address="0.0.0.0", hostnames=["example"])
dns_entry.dns_name = "example.com"
app.hosts_file.add_entry(dns_entry)
app.selected_entry_index = 0
entry_type = app.edit_handler.get_current_entry_type()
assert entry_type == "dns"
def test_field_visibility_ip_type(self):
"""Test field visibility logic for IP entry type."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock the section elements
mock_ip_section = Mock()
mock_dns_section = Mock()
def mock_query_one(selector):
if selector == "#edit-ip-section":
return mock_ip_section
elif selector == "#edit-dns-section":
return mock_dns_section
return Mock()
cast(Any, app).query_one = mock_query_one
app.edit_handler.update_field_visibility(show_ip=True, show_dns=False)
# IP section should be visible, DNS section hidden
mock_ip_section.remove_class.assert_called_with("hidden")
mock_dns_section.add_class.assert_called_with("hidden")
def test_field_visibility_dns_type(self):
"""Test field visibility logic for DNS entry type."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock the section elements
mock_ip_section = Mock()
mock_dns_section = Mock()
def mock_query_one(selector):
if selector == "#edit-ip-section":
return mock_ip_section
elif selector == "#edit-dns-section":
return mock_dns_section
return Mock()
cast(Any, app).query_one = mock_query_one
app.edit_handler.update_field_visibility(show_ip=False, show_dns=True)
# DNS section should be visible, IP section hidden
mock_ip_section.add_class.assert_called_with("hidden")
mock_dns_section.remove_class.assert_called_with("hidden")
def test_populate_edit_form_with_ip_type_detection(self):
"""Test edit form population with IP type detection."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.entry_edit_mode = True
app.set_timer = Mock() # Mock set_timer to avoid event loop issues
# Add IP entry
app.hosts_file = HostsFile()
ip_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
app.hosts_file.add_entry(ip_entry)
app.selected_entry_index = 0
# Mock radio set and buttons
mock_radio_set = Mock()
mock_ip_radio = Mock()
mock_dns_radio = Mock()
def mock_query_one(selector):
if selector == "#edit-entry-type-radio":
return mock_radio_set
elif selector == "#edit-ip-entry-radio":
return mock_ip_radio
elif selector == "#edit-dns-entry-radio":
return mock_dns_radio
return Mock()
cast(Any, app).query_one = mock_query_one
app.edit_handler.handle_entry_type_change = Mock()
# Test that the method can be called without errors
try:
app.edit_handler.populate_edit_form_with_type_detection()
# Method executed successfully
assert True
except Exception as e:
# Method should not raise exceptions
assert False, f"Method raised unexpected exception: {e}"
def test_populate_edit_form_with_dns_type_detection(self):
"""Test edit form population with DNS type detection."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.entry_edit_mode = True
# Add DNS entry
app.hosts_file = HostsFile()
dns_entry = HostEntry(ip_address="0.0.0.0", hostnames=["example"])
dns_entry.dns_name = "example.com"
app.hosts_file.add_entry(dns_entry)
app.selected_entry_index = 0
# Mock radio set, buttons, and DNS input with proper value tracking
mock_radio_set = Mock()
mock_ip_radio = Mock()
mock_dns_radio = Mock()
# Use a simple object to track value assignment
class MockDNSInput:
def __init__(self):
self.value = ""
mock_dns_input = MockDNSInput()
def mock_query_one(selector, widget_type=None):
if selector == "#edit-entry-type-radio":
return mock_radio_set
elif selector == "#edit-ip-entry-radio":
return mock_ip_radio
elif selector == "#edit-dns-entry-radio":
return mock_dns_radio
elif selector == "#dns-name-input":
return mock_dns_input
return Mock()
cast(Any, app).query_one = mock_query_one
app.edit_handler.handle_entry_type_change = Mock()
# Mock the set_timer method to avoid event loop issues in tests
with patch.object(app, "set_timer") as mock_set_timer:
app.edit_handler.populate_edit_form_with_type_detection()
# Verify timer was set with the correct callback
mock_set_timer.assert_called_once_with(
0.1, app.edit_handler._delayed_radio_setup
)
# Manually call the delayed setup to test the actual logic
app.edit_handler._delayed_radio_setup()
# Verify that the DNS radio was set to True (which should be the pressed button)
assert mock_dns_radio.value
assert not mock_ip_radio.value
assert mock_dns_input.value == "example.com"
app.edit_handler.handle_entry_type_change.assert_called_with("dns")
def test_edit_form_initialization_calls_type_detection(self):
"""Test that edit form initialization calls type detection."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
# Mock form elements
mock_details_display = Mock()
mock_edit_form = Mock()
mock_ip_input = Mock()
mock_hostname_input = Mock()
mock_comment_input = Mock()
mock_active_checkbox = Mock()
def mock_query_one(selector, widget_type=None):
if selector == "#entry-details-display":
return mock_details_display
elif selector == "#entry-edit-form":
return mock_edit_form
elif selector == "#ip-input":
return mock_ip_input
elif selector == "#hostname-input":
return mock_hostname_input
elif selector == "#comment-input":
return mock_comment_input
elif selector == "#active-checkbox":
return mock_active_checkbox
return Mock()
cast(Any, app).query_one = mock_query_one
# Add test entry
app.hosts_file = HostsFile()
test_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
app.hosts_file.add_entry(test_entry)
app.selected_entry_index = 0
# Mock the type detection method
app.edit_handler.populate_edit_form_with_type_detection = Mock()
app.details_handler.update_edit_form()
# Should call type detection method
app.edit_handler.populate_edit_form_with_type_detection.assert_called_once()
def test_dns_resolution_restricted_to_edit_mode(self):
"""Test that DNS resolution is only allowed in edit mode."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.update_status = Mock()
# Add test DNS entry
app.hosts_file = HostsFile()
dns_entry = HostEntry(ip_address="0.0.0.0", hostnames=["example"])
dns_entry.dns_name = "example.com"
app.hosts_file.add_entry(dns_entry)
app.selected_entry_index = 0
# Test 1: DNS resolution blocked in read-only mode (default)
assert app.edit_mode is False
# Test action_refresh_dns in read-only mode
app.action_refresh_dns()
app.update_status.assert_called_with(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
)
# Reset mock
app.update_status.reset_mock()
# Test action_update_single_dns in read-only mode
app.action_update_single_dns()
app.update_status.assert_called_with(
"❌ Cannot resolve DNS names: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
)
# Test 2: DNS resolution allowed in edit mode
app.edit_mode = True
app.update_status.reset_mock()
# Mock DNS service and other dependencies
app.dns_service.resolve_entry_async = Mock()
app.manager.save_hosts_file = Mock(return_value=(True, "Success"))
app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock()
# Create a mock that properly handles and closes coroutines
def consume_coro(coro, **kwargs):
# If it's a coroutine, close it to prevent warnings
if hasattr(coro, "close"):
coro.close()
return None
app.run_worker = Mock(side_effect=consume_coro)
# Test action_refresh_dns in edit mode - should proceed
app.action_refresh_dns()
# Should not show error message about read-only mode
error_calls = [
call
for call in app.update_status.call_args_list
if "read-only mode" in str(call)
]
assert len(error_calls) == 0
# Should start DNS resolution
app.run_worker.assert_called()
# Reset mocks
app.update_status.reset_mock()
app.run_worker.reset_mock()
# Test action_update_single_dns in edit mode - should proceed
app.action_update_single_dns()
# Should not show error message about read-only mode
error_calls = [
call
for call in app.update_status.call_args_list
if "read-only mode" in str(call)
]
assert len(error_calls) == 0
# Should start DNS resolution
app.run_worker.assert_called()
def test_undo_saves_the_reverted_hosts_file(self):
"""Undo persists its result before refreshing the interface."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0)
app.manager.save_hosts_file = Mock(return_value=(True, "Hosts file saved"))
app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
app.action_undo()
assert entry.is_active is True
app.manager.save_hosts_file.assert_called_once_with(app.hosts_file)
app.table_handler.populate_entries_table.assert_called_once()
app.details_handler.update_entry_details.assert_called_once()
def test_undo_save_failure_restores_the_persisted_state(self):
"""Undo failure leaves the display and undo history at the saved state."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0)
self.fail_saves(app)
app.action_undo()
assert app.hosts_file.entries[0].is_active is False
assert app.manager.can_undo()
assert not app.manager.can_redo()
cast(Mock, app.table_handler.populate_entries_table).assert_called_once()
cast(
Mock, app.table_handler.move_cursor_to_entry_index
).assert_called_once_with(0)
cast(Mock, app.details_handler.update_entry_details).assert_called_once()
cast(Mock, app.update_status).assert_called_once_with(
"❌ Undo save failed; previous state restored: Permission denied"
)
def test_redo_saves_the_reapplied_hosts_file(self):
"""Redo persists its result before refreshing the interface."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0)
app.manager.undo_last_operation(app.hosts_file)
app.manager.save_hosts_file = Mock(return_value=(True, "Hosts file saved"))
app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
app.action_redo()
assert entry.is_active is False
app.manager.save_hosts_file.assert_called_once_with(app.hosts_file)
app.table_handler.populate_entries_table.assert_called_once()
app.details_handler.update_entry_details.assert_called_once()
def test_redo_save_failure_restores_the_persisted_state(self):
"""Redo failure leaves the display and redo history at the saved state."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0)
app.manager.undo_last_operation(app.hosts_file)
self.fail_saves(app)
app.action_redo()
assert app.hosts_file.entries[0].is_active is True
assert not app.manager.can_undo()
assert app.manager.can_redo()
cast(Mock, app.table_handler.populate_entries_table).assert_called_once()
cast(
Mock, app.table_handler.move_cursor_to_entry_index
).assert_called_once_with(0)
cast(Mock, app.details_handler.update_entry_details).assert_called_once()
cast(Mock, app.update_status).assert_called_once_with(
"❌ Redo save failed; previous state restored: Permission denied"
)
def test_toggle_save_failure_restores_model_selection_and_history(self):
"""A failed toggle restores all user-visible and undoable state."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.0.2.1", hostnames=["one.test"]),
HostEntry(ip_address="192.0.2.2", hostnames=["two.test"]),
]
)
app.selected_entry_index = 0
app.manager.execute_toggle_command(app.hosts_file, 1)
app.manager.undo_last_operation(app.hosts_file)
self.fail_saves(app)
app.action_toggle_entry()
assert app.hosts_file.entries[0].is_active is True
assert app.selected_entry_index == 0
assert not app.manager.can_undo()
assert app.manager.can_redo()
cast(Mock, app.update_status).assert_called_once_with(
"❌ Toggle save failed; previous state restored: Permission denied"
)
def test_move_save_failure_restores_entry_order_and_selection(self):
"""A failed move leaves the selected Host Entry in its original position."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.0.2.1", hostnames=["one.test"]),
HostEntry(ip_address="192.0.2.2", hostnames=["two.test"]),
]
)
app.selected_entry_index = 1
self.fail_saves(app)
app.action_move_entry_up()
assert [entry.hostnames[0] for entry in app.hosts_file.entries] == [
"one.test",
"two.test",
]
assert app.selected_entry_index == 1
assert not app.manager.can_undo()
cast(Mock, app.update_status).assert_called_once_with(
"❌ Move save failed; previous state restored: Permission denied"
)
def test_add_save_failure_removes_entry_and_restores_history(self):
"""A failed add restores the prior entries and history."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
existing = HostEntry(ip_address="192.0.2.1", hostnames=["one.test"])
added = HostEntry(ip_address="192.0.2.2", hostnames=["two.test"])
app.hosts_file = HostsFile(entries=[existing])
app.selected_entry_index = 0
self.fail_saves(app)
app.push_screen = Mock(
side_effect=lambda _screen, callback: callback(added)
)
app.action_add_entry()
assert [entry.hostnames[0] for entry in app.hosts_file.entries] == [
"one.test"
]
assert app.selected_entry_index == 0
assert not app.manager.can_undo()
cast(Mock, app.update_status).assert_called_once_with(
"❌ Add save failed; previous state restored: Permission denied"
)
def test_later_mutation_does_not_persist_failed_add(self):
"""A later successful save excludes a previously failed addition."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
existing = HostEntry(ip_address="192.0.2.1", hostnames=["one.test"])
added = HostEntry(ip_address="192.0.2.2", hostnames=["two.test"])
app.hosts_file = HostsFile(entries=[existing])
app.manager.save_hosts_file = Mock(
return_value=(False, "Permission denied")
)
app.table_handler.populate_entries_table = Mock()
app.table_handler.move_cursor_to_entry_index = Mock()
app.table_handler.restore_cursor_position = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
app.set_timer = Mock()
app.push_screen = Mock(
side_effect=lambda _screen, callback: callback(added)
)
app.action_add_entry()
persisted = []
def save_hosts_file(hosts_file):
persisted.append(HostsParser().serialize(hosts_file))
return True, "Hosts file saved"
app.manager.save_hosts_file = Mock(side_effect=save_hosts_file)
app.action_toggle_entry()
assert len(persisted) == 1
assert "one.test" in persisted[0]
assert "two.test" not in persisted[0]
def test_save_failure_restores_exact_duplicate_selection(self):
"""Rollback keeps the second of two identical Host Entries selected."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.0.2.1", hostnames=["same.test"]),
HostEntry(ip_address="192.0.2.1", hostnames=["same.test"]),
]
)
app.selected_entry_index = 1
app.manager.save_hosts_file = Mock(
return_value=(False, "Permission denied")
)
table = Mock(row_count=2)
app.query_one = Mock(return_value=table)
app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
app.action_toggle_entry()
assert app.selected_entry_index == 1
table.move_cursor.assert_called_once_with(row=1)
def test_delete_save_failure_restores_entry_and_selection(self):
"""A failed delete restores the removed Host Entry and selection."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.0.2.1", hostnames=["one.test"]),
HostEntry(ip_address="192.0.2.2", hostnames=["two.test"]),
]
)
app.selected_entry_index = 1
self.fail_saves(app)
app.push_screen = Mock(side_effect=lambda _screen, callback: callback(True))
app.action_delete_entry()
assert [entry.hostnames[0] for entry in app.hosts_file.entries] == [
"one.test",
"two.test",
]
assert app.selected_entry_index == 1
assert not app.manager.can_undo()
cast(Mock, app.update_status).assert_called_once_with(
"❌ Delete save failed; previous state restored: Permission denied"
)
def test_entry_editor_save_failure_restores_all_entry_fields(self):
"""A failed editor save restores ordinary fields and DNS metadata."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
resolved_at = datetime(2026, 9, 4, 10, 30)
app.hosts_file = HostsFile(
entries=[
HostEntry(
ip_address="192.0.2.1",
hostnames=["one.test", "alias.test"],
comment="original",
is_active=False,
dns_name="source.test",
resolved_ip="192.0.2.1",
last_resolved=resolved_at,
dns_resolution_status="resolved",
)
]
)
app.selected_entry_index = 0
app.entry_edit_mode = True
app.manager.save_hosts_file = Mock(
return_value=(False, "Permission denied")
)
app.table_handler.populate_entries_table = Mock()
app.table_handler.move_cursor_to_entry_index = Mock()
app.update_status = Mock()
ip_input = Mock(value="198.51.100.8")
hostname_input = Mock(value="changed.test")
comment_input = Mock(value="changed")
active_checkbox = Mock(value=True)
dns_input = Mock(value="")
ip_radio = Mock(id="edit-ip-entry-radio", value=True)
dns_radio = Mock(id="edit-dns-entry-radio", value=False)
radio_set = Mock(pressed_button=ip_radio)
widgets = {
"#entry-details-display": Mock(),
"#entry-edit-form": Mock(),
"#ip-input": ip_input,
"#hostname-input": hostname_input,
"#comment-input": comment_input,
"#active-checkbox": active_checkbox,
"#dns-name-input": dns_input,
"#edit-entry-type-radio": radio_set,
"#edit-ip-entry-radio": ip_radio,
"#edit-dns-entry-radio": dns_radio,
"#edit-ip-section": Mock(),
"#edit-dns-section": Mock(),
}
app.query_one = Mock(side_effect=lambda selector, *_args: widgets[selector])
app.set_timer = Mock(side_effect=lambda _delay, callback: callback())
saved = app.edit_handler.validate_and_save_entry_changes()
entry = app.hosts_file.entries[0]
assert saved is False
assert entry.ip_address == "192.0.2.1"
assert entry.hostnames == ["one.test", "alias.test"]
assert entry.comment == "original"
assert entry.is_active is False
assert entry.dns_name == "source.test"
assert entry.resolved_ip == "192.0.2.1"
assert entry.last_resolved == resolved_at
assert entry.dns_resolution_status == "resolved"
assert ip_input.value == "192.0.2.1"
assert hostname_input.value == "one.test, alias.test"
assert comment_input.value == "original"
assert active_checkbox.value is False
assert dns_input.value == "source.test"
app.update_status.assert_called_once_with(
"❌ Edit save failed; previous state restored: Permission denied"
)
@pytest.mark.asyncio
async def test_batch_dns_save_failure_restores_mapping_and_metadata(self):
"""A failed batch DNS refresh restores every changed DNS field."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
original_time = datetime(2026, 9, 3, 8, 0)
resolved_time = datetime(2026, 9, 4, 11, 0)
app.hosts_file = HostsFile(
entries=[
HostEntry(
ip_address="192.0.2.1",
hostnames=["one.test"],
dns_name="source.test",
resolved_ip="192.0.2.1",
last_resolved=original_time,
dns_resolution_status="match",
)
]
)
app.selected_entry_index = 0
app.dns_service.resolve_entry_async = AsyncMock(
return_value=DNSResolution(
hostname="source.test",
resolved_ip="198.51.100.9",
status=DNSResolutionStatus.RESOLVED,
resolved_at=resolved_time,
)
)
self.fail_saves(app)
workers = []
app.run_worker = Mock(
side_effect=lambda worker, **_kwargs: workers.append(worker)
)
app.action_refresh_dns()
await workers[0]
entry = app.hosts_file.entries[0]
assert entry.ip_address == "192.0.2.1"
assert entry.resolved_ip == "192.0.2.1"
assert entry.last_resolved == original_time
assert entry.dns_resolution_status == "match"
assert app.selected_entry_index == 0
cast(Mock, app.update_status).assert_any_call(
"❌ DNS refresh save failed; previous state restored: Permission denied"
)
@pytest.mark.asyncio
async def test_single_dns_save_failure_restores_mapping_and_metadata(self):
"""A failed selected-entry DNS refresh restores every changed field."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
original_time = datetime(2026, 9, 3, 8, 0)
app.hosts_file = HostsFile(
entries=[
HostEntry(
ip_address="192.0.2.1",
hostnames=["one.test"],
dns_name="source.test",
resolved_ip="192.0.2.1",
last_resolved=original_time,
dns_resolution_status="match",
)
]
)
app.dns_service.resolve_entry_async = AsyncMock(
return_value=DNSResolution(
hostname="source.test",
resolved_ip="198.51.100.9",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime(2026, 9, 4, 11, 0),
)
)
self.fail_saves(app)
workers = []
app.run_worker = Mock(
side_effect=lambda worker, **_kwargs: workers.append(worker)
)
app.action_update_single_dns()
await workers[0]
entry = app.hosts_file.entries[0]
assert entry.ip_address == "192.0.2.1"
assert entry.resolved_ip == "192.0.2.1"
assert entry.last_resolved == original_time
assert entry.dns_resolution_status == "match"
cast(Mock, app.update_status).assert_any_call(
"❌ DNS refresh save failed; previous state restored: Permission denied"
)
@pytest.mark.asyncio
async def test_single_dns_refresh_updates_selected_duplicate(self):
"""DNS refresh retains the selected entry identity across its await."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(
ip_address="192.0.2.1",
hostnames=["same.test"],
dns_name="source.test",
),
HostEntry(
ip_address="192.0.2.2",
hostnames=["same.test"],
dns_name="source.test",
),
]
)
app.selected_entry_index = 1
app.dns_service.resolve_entry_async = AsyncMock(
return_value=DNSResolution(
hostname="source.test",
resolved_ip="198.51.100.9",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime(2026, 9, 4, 11, 0),
)
)
app.manager.save_hosts_file = Mock(return_value=(True, "Saved"))
app.table_handler.populate_entries_table = Mock()
app.table_handler.restore_cursor_position = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
workers = []
app.run_worker = Mock(
side_effect=lambda worker, **_kwargs: workers.append(worker)
)
app.action_update_single_dns()
await workers[0]
assert app.hosts_file.entries[0].ip_address == "192.0.2.1"
assert app.hosts_file.entries[1].ip_address == "198.51.100.9"
@pytest.mark.asyncio
async def test_new_entry_dns_save_failure_keeps_saved_placeholder(self):
"""Failed post-add DNS persistence keeps the already-saved DNS placeholder."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile()
entry = HostEntry(
ip_address="0.0.0.0",
hostnames=["one.test"],
is_active=False,
dns_name="source.test",
)
app.manager.execute_add_command(app.hosts_file, entry)
app.dns_service.resolve_entry_async = AsyncMock(
return_value=DNSResolution(
hostname="source.test",
resolved_ip="198.51.100.9",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime(2026, 9, 4, 11, 0),
)
)
self.fail_saves(app)
workers = []
app.run_worker = Mock(
side_effect=lambda worker, **_kwargs: workers.append(worker)
)
app._resolve_new_dns_entry(entry)
await workers[0]
restored = app.hosts_file.entries[0]
assert restored.ip_address == "0.0.0.0"
assert restored.resolved_ip is None
assert restored.last_resolved is None
assert restored.dns_resolution_status is None
assert restored.is_active is False
assert app.manager.can_undo()
cast(Mock, app.update_status).assert_any_call(
"❌ DNS activation save failed; previous state restored: Permission denied"
)
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()