Migrate TUI to design guide

This commit is contained in:
Philip Henning 2026-09-04 18:30:53 +02:00
parent 3ce7ba8d5c
commit 563ec7c705
14 changed files with 741 additions and 442 deletions

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")
@ -89,6 +87,24 @@ async def test_filter_shortcut_does_not_stack_filter_modals():
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."""
@ -111,6 +127,8 @@ async def test_filter_modal_tabs_through_controls_in_visual_order():
"search-comments",
"search-ips",
"search-case-sensitive",
"cancel",
"reset",
"apply",
):
await pilot.press("tab")
@ -148,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."""