feat #14: Implement design guide (#15)

Reviewed-on: #15
Co-authored-by: phg <mail@philip-henning.com>
Co-committed-by: phg <mail@philip-henning.com>
This commit is contained in:
Philip Henning 2026-09-04 17:15:39 +00:00 committed by Philip Henning
parent 9f8e9c3415
commit 0b7b52521f
19 changed files with 1507 additions and 723 deletions

View file

@ -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:

View file

@ -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."""

View file

@ -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."""

View file

@ -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: