"""Unified keyboard and mouse navigation (issue #110). Every action has one declaration: a Binding. The footer, the help screen and the clickable chips are all generated from those bindings, and the keyboard, mouse and wheel paths reach the same actions. Pilot drives each path. """ import re from datetime import date, timedelta from unittest.mock import patch import pytest from test_dashboard_design import dashboard # noqa: F401 (pytest fixture) from textual import events from textual.geometry import Region from fenris.tui import ( ConfirmPause, DatePickerScreen, BaselineScreen, DisclosuresScreen, FenrisTuiApp, HelpScreen, HistoryGraph, LiveActivityGraph, ) BINDING_SOURCES = (FenrisTuiApp, LiveActivityGraph, HistoryGraph) # -- helpers --------------------------------------------------------------- async def _settle(app, pilot): app.on_refresh_tick() await pilot.pause() def _chip(app, label): """Offset of a footer or tool chip label inside its Static.""" for selector in ("#action-rail", "#activity-tools"): widget = app.query_one(selector) region = Region(0, 0, widget.size.width, widget.size.height) for y, strip in enumerate(widget.render_lines(region)): x = strip.text.find(label) if x >= 0: return selector, (x + 1, y) raise AssertionError("no chip %r in footer" % label) async def _click_chip(app, pilot, label): selector, offset = _chip(app, label) await pilot.click(selector, offset=offset) await pilot.pause() def _scroll(widget, direction): event = (events.MouseScrollUp if direction < 0 else events.MouseScrollDown)( widget, 3, 1, 0, direction, 0, False, False, False, ) widget.post_message(event) def _help_rows(app): text = str(app.screen.query_one("#help-text").render()) section = text.split("Keyboard controls\n", 1)[1].split("\n\n", 1)[0] rows = {} for line in section.splitlines(): keys, description = re.match(r"^(\S.*?)\s{2,}(\S.*)$", line).groups() rows[description] = set(keys.split(" / ")) return rows def _binding_rows(app): rows = {} for source in BINDING_SOURCES: for binding in source.BINDINGS: rows.setdefault(binding.description, set()).add(app.get_key_display(binding)) return rows # -- one source of truth --------------------------------------------------- def test_graphs_declare_bindings_instead_of_key_handlers(): for graph in (HistoryGraph, LiveActivityGraph): assert "on_key" not in vars(graph) assert graph.BINDINGS @pytest.mark.asyncio async def test_help_content_equals_binding_set(dashboard): # noqa: F811 async with dashboard.run_test(size=(100, 36)) as pilot: await pilot.press("?") assert isinstance(dashboard.screen, HelpScreen) assert _help_rows(dashboard) == _binding_rows(dashboard) assert "The dashboard does not need sudo" in str( dashboard.screen.query_one("#help-text").render()) @pytest.mark.asyncio async def test_footer_chips_are_generated_from_visible_bindings(dashboard): # noqa: F811 async with dashboard.run_test(size=(120, 36)) as pilot: await pilot.pause() footer = str(dashboard.query_one("#action-rail").render()) shown = [b for b in FenrisTuiApp.BINDINGS if b.show] assert shown for binding in shown: assert "%s %s" % (dashboard.get_key_display(binding), binding.description) in footer for binding in FenrisTuiApp.BINDINGS: if not binding.show: label = "%s %s" % (dashboard.get_key_display(binding), binding.description) assert label not in footer @pytest.mark.asyncio @pytest.mark.parametrize("size", [(79, 24), (80, 23), (60, 18)]) async def test_footer_collapses_under_80x24(dashboard, size): # noqa: F811 async with dashboard.run_test(size=size) as pilot: await pilot.pause() assert str(dashboard.query_one("#action-rail").render()) == "? help ยท q quit" await _click_chip(dashboard, pilot, "? help") assert isinstance(dashboard.screen, HelpScreen) await pilot.press("escape") await _click_chip(dashboard, pilot, "q quit") assert not dashboard.is_running @pytest.mark.asyncio async def test_footer_is_full_at_80x24(dashboard): # noqa: F811 async with dashboard.run_test(size=(80, 24)) as pilot: await pilot.pause() footer = str(dashboard.query_one("#action-rail").render()) assert "p Pause" in footer and "q Quit TUI" in footer # -- keyboard: live graph -------------------------------------------------- @pytest.mark.asyncio async def test_live_home_end_and_arrows(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: await _settle(app, pilot) graph = app.query_one("#live-activity") last = len(graph._data) - 1 assert last > 3 await pilot.press("home") assert graph._selected_index == 0 await pilot.press("right") assert graph._selected_index == 1 await pilot.press("end") assert graph._selected_index == last await pilot.press("left") assert graph._selected_index == last - 1 # -- keyboard: history graph ----------------------------------------------- async def _open_history(app, pilot): await pilot.click("#view-history") await _settle(app, pilot) graph = app.query_one("#usage-history") assert app.focused is graph return graph @pytest.mark.asyncio async def test_history_home_end_and_week_jumps(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: graph = await _open_history(app, pilot) last = len(graph._day_data) - 1 assert last >= 13 await pilot.press("home") assert graph.selected_index == 0 await pilot.press("end") assert graph.selected_index == last assert app._browse_date == graph._day_data[last]["day"] await pilot.press("pageup") assert graph.selected_index == last - 7 assert app._browse_date == graph._day_data[last - 7]["day"] await pilot.press("pagedown") assert graph.selected_index == last await pilot.press("pagedown") assert graph.selected_index == last await pilot.press("home", "pageup") assert graph.selected_index == 0 @pytest.mark.asyncio async def test_history_range_keys_and_drill_bindings(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: graph = await _open_history(app, pilot) await pilot.press("1") assert graph.range_days == 7 await pilot.press("3") assert graph.range_days == 30 await pilot.press("left", "enter") assert app._activity_view == "day" and graph.view_mode == "hourly" await pilot.press("1") assert graph.range_days == 30 # range keys only apply to the day graph await pilot.press("escape") assert graph.view_mode == "daily" and app._activity_view == "history" await pilot.press("enter") await pilot.press("backspace") assert graph.view_mode == "daily" @pytest.mark.asyncio async def test_hour_graph_home_end_arrows_and_week_jump(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: graph = await _open_history(app, pilot) await pilot.press("enter") day = app._browse_date await pilot.press("home") assert graph._hourly_selected == 0 await pilot.press("right") assert graph._hourly_selected == 1 await pilot.press("end") assert graph._hourly_selected == len(graph._hour_data) - 1 await pilot.press("left") assert graph._hourly_selected == len(graph._hour_data) - 2 await pilot.press("pageup") assert app._activity_view == "day" assert app._browse_date == (date.fromisoformat(day) - timedelta(days=7)).isoformat() @pytest.mark.asyncio async def test_escape_in_zoomed_hour_view_restores_zoom_first(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: graph = await _open_history(app, pilot) await pilot.press("enter", "z") assert app._zoomed_panel == "activity-panel" await pilot.press("escape") assert app._zoomed_panel is None and graph.view_mode == "hourly" await pilot.press("escape") assert graph.view_mode == "daily" # -- keyboard: focus ring -------------------------------------------------- @pytest.mark.asyncio async def test_tab_cycles_panels_with_visible_focus_ring(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() order = [app.focused.id] for _ in range(3): await pilot.press("tab") order.append(app.focused.id) assert order == ["live-activity", "drive-panel", "endurance-panel", "live-activity"] await pilot.press("shift+tab") assert app.focused.id == "endurance-panel" await pilot.press("shift+tab") assert app.focused.id == "drive-panel" def ring(pane_id): return app.query_one("#" + pane_id).styles.border_top[1] assert ring("drive-panel") != ring("endurance-panel") await pilot.press("tab") assert app.focused.id == "endurance-panel" assert ring("endurance-panel") != ring("drive-panel") await pilot.press("tab") assert ring("activity-panel") != ring("drive-panel") # -- mouse ----------------------------------------------------------------- @pytest.mark.asyncio async def test_click_focuses_each_panel(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() await pilot.click("#drive-health") assert app.focused.id == "drive-panel" await pilot.click("#headline-band") assert app.focused.id == "endurance-panel" await pilot.click("#local-day") assert app.focused.id == "live-activity" await pilot.click("#view-history") await pilot.click("#drive-health") await pilot.click("#local-day") assert app.focused.id == "usage-history" @pytest.mark.asyncio async def test_view_tabs_are_clickable(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: for view in ("history", "day", "live"): await pilot.click("#view-" + view) assert app._activity_view == view assert app.query_one("#activity-tabs").active == "view-" + view @pytest.mark.asyncio async def test_action_chips_fire_their_bindings(dashboard): # noqa: F811 app = dashboard calls = [] async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() with patch.object(app, "_run_helper", side_effect=lambda *a: calls.append(a) or True): await _click_chip(app, pilot, "p Pause") assert isinstance(app.screen, ConfirmPause) await pilot.press("n") await _click_chip(app, pilot, "r Resume") assert calls == [("enable", ["--now"])] await _click_chip(app, pilot, "c Collect") assert calls[-1] == ("collect",) await _click_chip(app, pilot, "b Rated TBW") assert isinstance(app.screen, BaselineScreen) await pilot.press("escape") await _click_chip(app, pilot, "s Theme") assert app.theme == "fenris-amber" await _click_chip(app, pilot, "d Disclosures") assert isinstance(app.screen, DisclosuresScreen) await pilot.press("escape") await _click_chip(app, pilot, "z Zoom") assert app._zoomed_panel is not None await _click_chip(app, pilot, "z Zoom") assert app._zoomed_panel is None await _click_chip(app, pilot, "v View") assert app._activity_view == "day" await _click_chip(app, pilot, "m Motion") assert app._reduced_motion is True @pytest.mark.asyncio async def test_activity_tool_chips_are_generated_from_bindings(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(120, 40)) as pilot: await pilot.pause() tools = str(app.query_one("#activity-tools").render()) for action in ("prev_day", "next_day", "go_to_date", "today", "toggle_measure"): binding = next(b for b in FenrisTuiApp.BINDINGS if b.action == action) assert "%s %s" % (app.get_key_display(binding), binding.description) in tools await _click_chip(app, pilot, "w Toggle read/write") assert app.query_one("#live-activity").measure == "read" await _click_chip(app, pilot, "g Go to date") assert isinstance(app.screen, DatePickerScreen) await pilot.press("escape") await _click_chip(app, pilot, "Prev day") assert app._activity_view == "day" await _click_chip(app, pilot, "t Today") assert app._activity_view == "live" @pytest.mark.asyncio async def test_wheel_moves_live_selection(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: await _settle(app, pilot) graph = app.query_one("#live-activity") last = len(graph._data) - 1 _scroll(graph, -1) await pilot.pause() assert graph._selected_index == last - 1 _scroll(graph, -1) await pilot.pause() assert graph._selected_index == last - 2 _scroll(graph, 1) await pilot.pause() assert graph._selected_index == last - 1 @pytest.mark.asyncio async def test_wheel_moves_history_day_and_hour_selection(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: graph = await _open_history(app, pilot) last = len(graph._day_data) - 1 _scroll(graph, -1) await pilot.pause() assert graph.selected_index == last - 1 _scroll(graph, 1) await pilot.pause() assert graph.selected_index == last await pilot.press("enter", "end") end = graph._hourly_selected _scroll(graph, -1) await pilot.pause() assert graph._hourly_selected == end - 1 @pytest.mark.asyncio async def test_click_to_inspect_stays(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: await pilot.pause() graph = app.query_one("#live-activity") await pilot.click("#live-render", offset=(graph._point_columns[0], 1)) assert graph._selected_index == 0 # -- modal keys ------------------------------------------------------------ @pytest.mark.asyncio async def test_confirm_pause_y_n_and_escape(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: with patch.object(app, "_run_helper") as helper: await pilot.press("p", "n") assert not isinstance(app.screen, ConfirmPause) await pilot.press("p", "escape") assert not isinstance(app.screen, ConfirmPause) helper.assert_not_called() await pilot.press("p", "y") assert not isinstance(app.screen, ConfirmPause) helper.assert_called_once_with("disable", ["--now"]) @pytest.mark.asyncio async def test_date_picker_keys(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: await _settle(app, pilot) await pilot.press("g", "escape") assert not isinstance(app.screen, DatePickerScreen) await pilot.press("g", "enter") assert isinstance(app.screen, DatePickerScreen) assert "Please enter a date" in str(app.screen.query_one("#date-error").render()) await pilot.press(*"2026-13-45", "enter") assert "Invalid date format" in str(app.screen.query_one("#date-error").render()) app.screen.query_one("#date-input").value = "" await pilot.press(*"2999-01-01", "enter") assert "future" in str(app.screen.query_one("#date-error").render()) app.screen.query_one("#date-input").value = "" await pilot.press(*"2026-09-17", "enter") assert not isinstance(app.screen, DatePickerScreen) assert app._browse_date == "2026-09-17" assert app._activity_view == "day" @pytest.mark.asyncio async def test_disclosures_and_help_toggle_keys(dashboard): # noqa: F811 app = dashboard async with app.run_test(size=(100, 36)) as pilot: await pilot.press("d") assert isinstance(app.screen, DisclosuresScreen) await pilot.press("d") assert not isinstance(app.screen, DisclosuresScreen) await pilot.press("d", "escape") assert not isinstance(app.screen, DisclosuresScreen) await pilot.press("?") assert isinstance(app.screen, HelpScreen) await pilot.press("?") assert not isinstance(app.screen, HelpScreen) await pilot.press("?", "escape") assert not isinstance(app.screen, HelpScreen) @pytest.mark.asyncio async def test_single_dashboard_has_no_page_navigation_bindings(dashboard): # noqa: F811 keys = {b.key for b in FenrisTuiApp.BINDINGS} assert not keys & {"left", "right", "1", "2", "3", "4", "home", "end", "pageup", "pagedown"}