Declare every dashboard key as a Binding on the widget that owns it, and generate the footer, activity tool chips and help screen from those bindings so they cannot drift. The footer collapses to "? help · q quit" under 80x24. Add Home/End and PgUp/PgDn (one week) navigation, mouse-wheel selection on the graphs, click-to-focus on every panel, and clickable footer chips. Tab and Shift+Tab now visit only the dashboard panels.
457 lines
17 KiB
Python
457 lines
17 KiB
Python
"""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"}
|