Reviewed-on: #15 Co-authored-by: phg <mail@philip-henning.com> Co-committed-by: phg <mail@philip-henning.com>
This commit is contained in:
parent
9f8e9c3415
commit
0b7b52521f
19 changed files with 1507 additions and 723 deletions
|
|
@ -193,15 +193,15 @@ class TestAddEntryModalRadioButtonLogic:
|
|||
# Mock the query_one method for sections and inputs
|
||||
mock_ip_section = Mock()
|
||||
mock_dns_section = Mock()
|
||||
mock_ip_input = Mock(spec=Input)
|
||||
mock_hostname_input = Mock(spec=Input)
|
||||
|
||||
def mock_query_one(selector, widget_type=None):
|
||||
if selector == "#ip-section":
|
||||
return mock_ip_section
|
||||
elif selector == "#dns-section":
|
||||
return mock_dns_section
|
||||
elif selector == "#ip-address-input":
|
||||
return mock_ip_input
|
||||
elif selector == "#hostnames-input":
|
||||
return mock_hostname_input
|
||||
return Mock()
|
||||
|
||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||
|
|
@ -225,22 +225,22 @@ class TestAddEntryModalRadioButtonLogic:
|
|||
# Verify IP section is shown and DNS section is hidden
|
||||
mock_ip_section.remove_class.assert_called_with("hidden")
|
||||
mock_dns_section.add_class.assert_called_with("hidden")
|
||||
mock_ip_input.focus.assert_called_once()
|
||||
mock_hostname_input.focus.assert_called_once()
|
||||
|
||||
def test_radio_button_change_to_dns_entry(self):
|
||||
"""Test radio button change to DNS entry mode."""
|
||||
# Mock the query_one method for sections and inputs
|
||||
mock_ip_section = Mock()
|
||||
mock_dns_section = Mock()
|
||||
mock_dns_input = Mock(spec=Input)
|
||||
mock_hostname_input = Mock(spec=Input)
|
||||
|
||||
def mock_query_one(selector, widget_type=None):
|
||||
if selector == "#ip-section":
|
||||
return mock_ip_section
|
||||
elif selector == "#dns-section":
|
||||
return mock_dns_section
|
||||
elif selector == "#dns-name-input":
|
||||
return mock_dns_input
|
||||
elif selector == "#hostnames-input":
|
||||
return mock_hostname_input
|
||||
return Mock()
|
||||
|
||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||
|
|
@ -264,7 +264,7 @@ class TestAddEntryModalRadioButtonLogic:
|
|||
# Verify DNS section is shown and IP section is hidden
|
||||
mock_ip_section.add_class.assert_called_with("hidden")
|
||||
mock_dns_section.remove_class.assert_called_with("hidden")
|
||||
mock_dns_input.focus.assert_called_once()
|
||||
mock_hostname_input.focus.assert_called_once()
|
||||
|
||||
|
||||
class TestAddEntryModalSaveLogic:
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ from unittest.mock import Mock, patch
|
|||
|
||||
import pytest
|
||||
from textual.app import SuspendNotSupported
|
||||
from textual.widgets import HelpPanel, RadioButton, Static
|
||||
from textual.widgets import Input, RadioButton, Static
|
||||
|
||||
from src.hosts.core.filters import FilterOptions
|
||||
from src.hosts.core.models import HostEntry, HostsFile
|
||||
from src.hosts.tui.app import HostsManagerApp
|
||||
from src.hosts.tui.custom_footer import CustomFooter
|
||||
from src.hosts.tui.filter_modal import FilterModal
|
||||
from src.hosts.tui.help_modal import HelpModal
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -45,16 +46,13 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter():
|
|||
async with app.run_test() as pilot:
|
||||
footer = app.query_one("#custom-footer", CustomFooter)
|
||||
footer_text = footer.query_one("#footer-right", Static).render()
|
||||
assert "ctrl+f Filter entries" in str(footer_text)
|
||||
assert "ctrl+f Filters" in str(footer_text)
|
||||
|
||||
app.action_help()
|
||||
await pilot.pause()
|
||||
assert isinstance(app.query_one(HelpPanel), HelpPanel)
|
||||
assert any(
|
||||
binding.action == "show_filters" and binding.description == "Filter entries"
|
||||
for _, binding, _, _ in app.screen.active_bindings.values()
|
||||
)
|
||||
app.action_help()
|
||||
assert isinstance(app.screen, HelpModal)
|
||||
assert app.screen.query_one("#help-close") is not None
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("ctrl+f")
|
||||
|
|
@ -72,6 +70,71 @@ async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter():
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_shortcut_does_not_stack_filter_modals():
|
||||
"""Repeated filter shortcuts keep the existing filter modal in focus."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("ctrl+f")
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("ctrl+f")
|
||||
await pilot.press("ctrl+f")
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, FilterModal)
|
||||
assert sum(isinstance(screen, FilterModal) for screen in app.screen_stack) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_overlay_closes_to_the_control_that_opened_it():
|
||||
"""Help is an overlay and returns keyboard focus to its opener."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
search = app.query_one("#search-input", Input)
|
||||
search.focus()
|
||||
app.action_help()
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, HelpModal)
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.focused is search
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_modal_tabs_through_controls_in_visual_order():
|
||||
"""The modal starts at Presets and tabs through the visible controls in order."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("ctrl+f")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.focused is app.screen.query_one("#preset-select")
|
||||
for expected_id in (
|
||||
"load-preset",
|
||||
"save-preset",
|
||||
"delete-preset",
|
||||
"status-filter-type",
|
||||
"type-filter-type",
|
||||
"resolution-filter-type",
|
||||
"search-term",
|
||||
"search-hostnames",
|
||||
"search-comments",
|
||||
"search-ips",
|
||||
"search-case-sensitive",
|
||||
"cancel",
|
||||
"reset",
|
||||
"apply",
|
||||
):
|
||||
await pilot.press("tab")
|
||||
assert app.focused is app.screen.query_one(f"#{expected_id}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters():
|
||||
"""Reset clears the form on Apply, while Cancel leaves the applied filter alone."""
|
||||
|
|
@ -103,6 +166,79 @@ async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters(
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_uses_read_only_detail_rows_and_hides_irrelevant_dns_fields():
|
||||
"""An IP Host Entry has compact text details instead of disabled controls."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
app.table_handler.populate_entries_table()
|
||||
app.details_handler.update_entry_details()
|
||||
await pilot.pause()
|
||||
|
||||
details = app.query_one("#entry-details-display")
|
||||
assert not list(details.query(Input))
|
||||
assert "192.0.2.1" in str(app.query_one("#details-ip-input", Static).render())
|
||||
assert "Active" in str(
|
||||
app.query_one("#details-active-checkbox", Static).render()
|
||||
)
|
||||
assert app.query_one("#details-dns-rows").has_class("hidden")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_exposes_dns_and_protection_details_and_durable_state():
|
||||
"""DNS and Default Entry states remain explicit independently of colour."""
|
||||
app = app_with_filterable_entries()
|
||||
app.hosts_file.entries[0].dns_name = "active.example"
|
||||
app.hosts_file.entries[0].dns_resolution_status = "resolved"
|
||||
app.hosts_file.entries[0].resolved_ip = "192.0.2.1"
|
||||
app.current_filter_options.active_only = True
|
||||
app.current_filter_options.show_inactive = False
|
||||
|
||||
async with app.run_test(size=(100, 30)) as pilot:
|
||||
app.table_handler.populate_entries_table()
|
||||
app.details_handler.update_entry_details()
|
||||
app._update_footer_status()
|
||||
await pilot.pause()
|
||||
|
||||
assert not app.query_one("#details-dns-rows").has_class("hidden")
|
||||
assert "Resolved" in str(
|
||||
app.query_one("#details-dns-status-input", Static).render()
|
||||
)
|
||||
footer = app.query_one("#custom-footer", CustomFooter)
|
||||
assert "Filters: 1" in footer._status_text
|
||||
assert "READ-ONLY" in footer._status_text
|
||||
assert app.has_class("compact-layout")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_viewport_replaces_workspace_with_an_explicit_safe_message():
|
||||
"""A viewport below the contract cannot expose clipped mutating controls."""
|
||||
app = app_with_filterable_entries()
|
||||
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
assert not app.query_one("#workspace").display
|
||||
minimum_size = app.query_one("#minimum-size", Static)
|
||||
assert "requires at least 100 columns × 30 rows" in str(minimum_size.render())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_viewport_blocks_hidden_mutation_shortcuts():
|
||||
"""A minimum-size presentation cannot open a hidden editor or privilege flow."""
|
||||
app = app_with_filterable_entries()
|
||||
app.push_screen = Mock()
|
||||
app.manager = Mock()
|
||||
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("n", "ctrl+e")
|
||||
await pilot.pause()
|
||||
|
||||
app.push_screen.assert_not_called()
|
||||
app.manager.enter_edit_mode.assert_not_called()
|
||||
|
||||
|
||||
class TestPrivilegedModeAuthorization:
|
||||
"""Test the user-visible privileged-mode authorization flow."""
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ class TestHostsManagerApp:
|
|||
|
||||
# Should handle error gracefully
|
||||
app.update_status.assert_called_with(
|
||||
"❌ Error loading hosts file: Hosts file not found"
|
||||
"Error loading hosts file: Hosts file not found"
|
||||
)
|
||||
|
||||
def test_load_hosts_file_permission_error(self):
|
||||
|
|
@ -122,7 +122,7 @@ class TestHostsManagerApp:
|
|||
|
||||
# Should handle error gracefully
|
||||
app.update_status.assert_called_with(
|
||||
"❌ Error loading hosts file: Permission denied"
|
||||
"Error loading hosts file: Permission denied"
|
||||
)
|
||||
|
||||
def test_populate_entries_table_logic(self):
|
||||
|
|
@ -207,13 +207,15 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_entry_details()
|
||||
|
||||
# Verify input widgets were updated with entry data
|
||||
# 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")
|
||||
assert mock_ip_input.value == "127.0.0.1"
|
||||
assert mock_hostname_input.value == "localhost, local"
|
||||
assert mock_comment_input.value == "Test comment"
|
||||
assert mock_active_checkbox.value
|
||||
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."""
|
||||
|
|
@ -233,6 +235,7 @@ class TestHostsManagerApp:
|
|||
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":
|
||||
|
|
@ -247,6 +250,8 @@ class TestHostsManagerApp:
|
|||
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
|
||||
|
|
@ -254,16 +259,10 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_entry_details()
|
||||
|
||||
# Verify widgets show empty state placeholders
|
||||
# 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")
|
||||
assert mock_ip_input.value == ""
|
||||
assert mock_ip_input.placeholder == "No entries loaded"
|
||||
assert mock_hostname_input.value == ""
|
||||
assert mock_hostname_input.placeholder == "No entries loaded"
|
||||
assert mock_comment_input.value == ""
|
||||
assert mock_comment_input.placeholder == "No entries loaded"
|
||||
assert not mock_active_checkbox.value
|
||||
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."""
|
||||
|
|
@ -301,7 +300,7 @@ class TestHostsManagerApp:
|
|||
# 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 "READ-ONLY" in status_call
|
||||
assert "2 entries" in status_call
|
||||
assert "1 active" in status_call
|
||||
|
||||
|
|
@ -318,12 +317,12 @@ class TestHostsManagerApp:
|
|||
|
||||
# Mock set_timer and query_one to avoid event loop and UI issues
|
||||
app.set_timer = Mock()
|
||||
mock_status_bar = Mock()
|
||||
mock_message_rail = Mock()
|
||||
mock_footer = Mock()
|
||||
|
||||
def mock_query_one(selector, widget_type=None):
|
||||
if selector == "#status-bar":
|
||||
return mock_status_bar
|
||||
if selector == "#message-rail":
|
||||
return mock_message_rail
|
||||
elif selector == "#custom-footer":
|
||||
return mock_footer
|
||||
return Mock()
|
||||
|
|
@ -343,14 +342,13 @@ class TestHostsManagerApp:
|
|||
|
||||
app.update_status("Custom status message")
|
||||
|
||||
# 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 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
|
||||
assert "READ-ONLY" in footer_status
|
||||
# Verify timer was set for auto-clearing
|
||||
app.set_timer.assert_called_once()
|
||||
|
||||
|
|
@ -382,12 +380,12 @@ class TestHostsManagerApp:
|
|||
patch("hosts.tui.app.Config", return_value=mock_config),
|
||||
):
|
||||
app = HostsManagerApp()
|
||||
app.action_show_help_panel = Mock()
|
||||
app.push_screen = Mock()
|
||||
|
||||
app.action_help()
|
||||
|
||||
# Should call the built-in help action
|
||||
app.action_show_help_panel.assert_called_once()
|
||||
# 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."""
|
||||
|
|
|
|||
|
|
@ -57,15 +57,15 @@ class TestSaveConfirmationModal:
|
|||
|
||||
@patch.object(SaveConfirmationModal, "query_one")
|
||||
def test_on_mount_sets_focus(self, mock_query_one):
|
||||
"""Test that on_mount sets focus to the save button."""
|
||||
"""Test that on_mount sets focus to the safe cancel button."""
|
||||
modal = SaveConfirmationModal()
|
||||
mock_save_button = Mock()
|
||||
mock_query_one.return_value = mock_save_button
|
||||
mock_cancel_button = Mock()
|
||||
mock_query_one.return_value = mock_cancel_button
|
||||
|
||||
modal.on_mount()
|
||||
|
||||
mock_query_one.assert_called_once_with("#save-button", Button)
|
||||
mock_save_button.focus.assert_called_once()
|
||||
mock_query_one.assert_called_once_with("#cancel-button", Button)
|
||||
mock_cancel_button.focus.assert_called_once()
|
||||
|
||||
|
||||
class TestSaveConfirmationIntegration:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue