Browse activity dates from the keyboard (#92)
Add keyboard-driven date navigation to the TUI: - [ and ] keys select previous/next day, adjusting the visible range - g opens a date entry modal for direct date navigation - t returns to today's live view from any historical browsing - Background refresh preserves the browsed selection - Local-day widget shows data for the selected date - Updated help screen and action legend with new bindings - DatePickerScreen modal with input validation - 24 headless tests covering navigation, date entry, browse stability, local-day evidence, and constrained widths Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
co-authored by
CommandCodeBot
parent
e12f4a574c
commit
5df8a12339
+173
-9
@@ -26,7 +26,7 @@ from textual.binding import Binding
|
|||||||
from textual.containers import Container, Horizontal, VerticalScroll
|
from textual.containers import Container, Horizontal, VerticalScroll
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
from textual.widgets import Static
|
from textual.widgets import Input, Static
|
||||||
|
|
||||||
from .projection import (
|
from .projection import (
|
||||||
ConfidenceState,
|
ConfidenceState,
|
||||||
@@ -63,7 +63,7 @@ from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
|||||||
_RESUME_HINT = "r Resume — enable monitoring and future boots"
|
_RESUME_HINT = "r Resume — enable monitoring and future boots"
|
||||||
_ACTION_LEGEND = (
|
_ACTION_LEGEND = (
|
||||||
"p Pause · " + _RESUME_HINT + "\n"
|
"p Pause · " + _RESUME_HINT + "\n"
|
||||||
"c Collect now · t Today · m Motion · d Disclosures · ? Help"
|
"c Collect now · t Today · [ ] Day · g Date · m Motion · d Disclosures · ? Help"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _format_remaining(seconds: float) -> str:
|
def _format_remaining(seconds: float) -> str:
|
||||||
@@ -191,10 +191,11 @@ class DailyBarGraph(Widget):
|
|||||||
self,
|
self,
|
||||||
day_data: List[Dict[str, Any]],
|
day_data: List[Dict[str, Any]],
|
||||||
on_drill: Any = None,
|
on_drill: Any = None,
|
||||||
|
selected_date: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update graph with day data. on_drill(day) called on drill entry."""
|
"""Update graph with day data. on_drill(day) called on drill entry."""
|
||||||
selected_day = None
|
selected_day = selected_date
|
||||||
if 0 <= self.selected_index < len(self._day_data):
|
if selected_day is None and 0 <= self.selected_index < len(self._day_data):
|
||||||
selected_day = self._day_data[self.selected_index].get("day")
|
selected_day = self._day_data[self.selected_index].get("day")
|
||||||
self._all_day_data = day_data
|
self._all_day_data = day_data
|
||||||
self._on_drill = on_drill
|
self._on_drill = on_drill
|
||||||
@@ -699,6 +700,42 @@ class DailyBarGraph(Widget):
|
|||||||
self._refresh_hourly()
|
self._refresh_hourly()
|
||||||
event.stop()
|
event.stop()
|
||||||
|
|
||||||
|
def navigate_to_date(self, date_str: str) -> bool:
|
||||||
|
"""Navigate to a specific date, adjusting range if needed.
|
||||||
|
|
||||||
|
Returns True if the selection changed.
|
||||||
|
"""
|
||||||
|
idx = next(
|
||||||
|
(i for i, d in enumerate(self._all_day_data)
|
||||||
|
if d.get("day") == date_str),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if idx is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
old_day = None
|
||||||
|
if 0 <= self.selected_index < len(self._day_data):
|
||||||
|
old_day = self._day_data[self.selected_index].get("day")
|
||||||
|
|
||||||
|
# Count actual data days (not gaps) after the target
|
||||||
|
n_data_after = sum(
|
||||||
|
1 for d in self._all_day_data[idx:]
|
||||||
|
if d.get("sample_count", 0) > 0 or d.get("total_bytes", 0) > 0
|
||||||
|
)
|
||||||
|
if n_data_after > self.range_days:
|
||||||
|
self.range_days = max(n_data_after, min(_RANGE_OPTIONS))
|
||||||
|
|
||||||
|
self._trim_to_range()
|
||||||
|
if not self._day_data:
|
||||||
|
return False
|
||||||
|
self.selected_index = next(
|
||||||
|
(i for i, d in enumerate(self._day_data)
|
||||||
|
if d.get("day") == date_str),
|
||||||
|
idx,
|
||||||
|
)
|
||||||
|
new_day = self._day_data[self.selected_index].get("day")
|
||||||
|
return new_day != old_day
|
||||||
|
|
||||||
def on_click(self, event: Any) -> None:
|
def on_click(self, event: Any) -> None:
|
||||||
render = self.query_one("#bar-render")
|
render = self.query_one("#bar-render")
|
||||||
offset_x = event.x - render.region.x
|
offset_x = event.x - render.region.x
|
||||||
@@ -1333,6 +1370,7 @@ class HelpScreen(ModalScreen[None]):
|
|||||||
+ _ACTION_LEGEND + "\n"
|
+ _ACTION_LEGEND + "\n"
|
||||||
"q Quit the dashboard — monitoring continues\n"
|
"q Quit the dashboard — monitoring continues\n"
|
||||||
"Tab Focus the graph · ← → Select · 1-4 Change range\n"
|
"Tab Focus the graph · ← → Select · 1-4 Change range\n"
|
||||||
|
"[ Previous day · ] Next day · g Go to date\n"
|
||||||
"Enter Show hours · Esc Return to days\n"
|
"Enter Show hours · Esc Return to days\n"
|
||||||
"w Toggle W/R on live graph\n"
|
"w Toggle W/R on live graph\n"
|
||||||
"Theme changed via preferences file\n\n"
|
"Theme changed via preferences file\n\n"
|
||||||
@@ -1344,6 +1382,68 @@ class HelpScreen(ModalScreen[None]):
|
|||||||
self.dismiss()
|
self.dismiss()
|
||||||
|
|
||||||
|
|
||||||
|
class DatePickerScreen(ModalScreen[str | None]):
|
||||||
|
"""Modal screen for entering a date to browse."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "cancel", "Cancel", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
DatePickerScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
#date-picker-content {
|
||||||
|
width: 40;
|
||||||
|
height: auto;
|
||||||
|
border: heavy $accent;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
#date-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#date-error {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
color: $error;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Container(
|
||||||
|
Static("[bold]Go to date[/bold]\nEnter a local date (YYYY-MM-DD):", id="date-picker-header"),
|
||||||
|
Input(placeholder="YYYY-MM-DD", id="date-input"),
|
||||||
|
Static("", id="date-error"),
|
||||||
|
Static("[dim]Enter Select · Esc Cancel[/dim]", id="date-picker-footer"),
|
||||||
|
id="date-picker-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one("#date-input").focus()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Any) -> None:
|
||||||
|
value = event.value.strip()
|
||||||
|
if not value:
|
||||||
|
self.query_one("#date-error").update("[red]Please enter a date[/red]")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(value, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
self.query_one("#date-error").update(
|
||||||
|
"[red]Invalid date format. Use YYYY-MM-DD.[/red]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if parsed > datetime.now().date():
|
||||||
|
self.query_one("#date-error").update(
|
||||||
|
"[red]Cannot browse future dates[/red]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.dismiss(value)
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main TUI App
|
# Main TUI App
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1451,6 +1551,9 @@ class FenrisTuiApp(App):
|
|||||||
Binding("c", "collect", "Collect now", show=False),
|
Binding("c", "collect", "Collect now", show=False),
|
||||||
Binding("d", "disclose", "Disclosures", show=False),
|
Binding("d", "disclose", "Disclosures", show=False),
|
||||||
Binding("t", "today", "Today", show=False),
|
Binding("t", "today", "Today", show=False),
|
||||||
|
Binding("left_square_bracket", "prev_day", "Previous day", show=False),
|
||||||
|
Binding("right_square_bracket", "next_day", "Next day", show=False),
|
||||||
|
Binding("g", "go_to_date", "Go to date", show=False),
|
||||||
Binding("m", "toggle_motion", "Motion", show=False),
|
Binding("m", "toggle_motion", "Motion", show=False),
|
||||||
Binding("w", "toggle_measure", "W/R", show=False),
|
Binding("w", "toggle_measure", "W/R", show=False),
|
||||||
Binding("?", "help", "Help", show=False),
|
Binding("?", "help", "Help", show=False),
|
||||||
@@ -1485,6 +1588,9 @@ class FenrisTuiApp(App):
|
|||||||
# Track constrained state for resize handling (issue #81)
|
# Track constrained state for resize handling (issue #81)
|
||||||
self._is_constrained_mode = False
|
self._is_constrained_mode = False
|
||||||
|
|
||||||
|
# Track the date the user is browsing (None = live/today view)
|
||||||
|
self._browse_date: Optional[str] = None
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with VerticalScroll(id="dashboard-scroll"):
|
with VerticalScroll(id="dashboard-scroll"):
|
||||||
with Container(id="main-grid"):
|
with Container(id="main-grid"):
|
||||||
@@ -1675,7 +1781,10 @@ class FenrisTuiApp(App):
|
|||||||
hour_data = _query_hourly_graph_data(
|
hour_data = _query_hourly_graph_data(
|
||||||
conn, saved_drill_day, self._clock_now,
|
conn, saved_drill_day, self._clock_now,
|
||||||
)
|
)
|
||||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
graph.set_data(
|
||||||
|
day_data, on_drill=self._on_graph_drill,
|
||||||
|
selected_date=self._browse_date,
|
||||||
|
)
|
||||||
graph.view_mode = "hourly"
|
graph.view_mode = "hourly"
|
||||||
graph.drill_day = saved_drill_day
|
graph.drill_day = saved_drill_day
|
||||||
graph.set_hour_data(hour_data)
|
graph.set_hour_data(hour_data)
|
||||||
@@ -1687,7 +1796,10 @@ class FenrisTuiApp(App):
|
|||||||
)
|
)
|
||||||
graph._refresh_hourly()
|
graph._refresh_hourly()
|
||||||
else:
|
else:
|
||||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
graph.set_data(
|
||||||
|
day_data, on_drill=self._on_graph_drill,
|
||||||
|
selected_date=self._browse_date,
|
||||||
|
)
|
||||||
|
|
||||||
# Update constrained summary if in constrained mode (issue #81)
|
# Update constrained summary if in constrained mode (issue #81)
|
||||||
if self._is_constrained_mode:
|
if self._is_constrained_mode:
|
||||||
@@ -1758,11 +1870,17 @@ class FenrisTuiApp(App):
|
|||||||
main_grid.refresh(layout=True)
|
main_grid.refresh(layout=True)
|
||||||
|
|
||||||
def _render_local_day(self, conn: sqlite3.Connection) -> None:
|
def _render_local_day(self, conn: sqlite3.Connection) -> None:
|
||||||
"""Render the local-day activity totals widget (issue #90)."""
|
"""Render the local-day activity totals widget (issue #90, #92)."""
|
||||||
try:
|
try:
|
||||||
from .tz_util import detect_system_tz
|
from .tz_util import detect_system_tz
|
||||||
from .local_day import query_current_local_day
|
from .local_day import (
|
||||||
|
query_current_local_day,
|
||||||
|
query_local_day_summary,
|
||||||
|
)
|
||||||
tz_name = detect_system_tz()
|
tz_name = detect_system_tz()
|
||||||
|
if self._browse_date is not None:
|
||||||
|
local = query_local_day_summary(conn, self._browse_date)
|
||||||
|
else:
|
||||||
local = query_current_local_day(conn, self._clock_now, tz_name)
|
local = query_current_local_day(conn, self._clock_now, tz_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
local = None
|
local = None
|
||||||
@@ -1870,12 +1988,58 @@ class FenrisTuiApp(App):
|
|||||||
|
|
||||||
def action_today(self) -> None:
|
def action_today(self) -> None:
|
||||||
"""Return to today's live view from any historical browsing."""
|
"""Return to today's live view from any historical browsing."""
|
||||||
# Reset the daily graph to today's data
|
self._browse_date = None
|
||||||
graph = self.query_one("#usage-history")
|
graph = self.query_one("#usage-history")
|
||||||
if graph.view_mode == "hourly":
|
if graph.view_mode == "hourly":
|
||||||
graph._exit_drill()
|
graph._exit_drill()
|
||||||
|
# Reset selection to today (last day in the data)
|
||||||
|
if graph._day_data:
|
||||||
|
graph.selected_index = len(graph._day_data) - 1
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
|
def _browse_day(self, offset: int) -> None:
|
||||||
|
"""Move selection by *offset* days from the current position."""
|
||||||
|
graph = self.query_one("#usage-history")
|
||||||
|
if not graph._day_data:
|
||||||
|
return
|
||||||
|
# Find the currently selected day's date string
|
||||||
|
current_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
if current_day is None:
|
||||||
|
return
|
||||||
|
# Compute target date
|
||||||
|
current_date = datetime.strptime(current_day, "%Y-%m-%d").date()
|
||||||
|
target_date = current_date + timedelta(days=offset)
|
||||||
|
target_str = target_date.strftime("%Y-%m-%d")
|
||||||
|
# Navigate to target, adjusting range if needed
|
||||||
|
if graph.navigate_to_date(target_str):
|
||||||
|
self._browse_date = target_str
|
||||||
|
if graph.view_mode == "hourly":
|
||||||
|
graph._exit_drill()
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def action_prev_day(self) -> None:
|
||||||
|
"""Select the previous (older) day."""
|
||||||
|
self._browse_day(-1)
|
||||||
|
|
||||||
|
def action_next_day(self) -> None:
|
||||||
|
"""Select the next (newer) day."""
|
||||||
|
self._browse_day(1)
|
||||||
|
|
||||||
|
def _go_to_date_callback(self, date_str: str | None) -> None:
|
||||||
|
"""Handle date picker result."""
|
||||||
|
if date_str is None:
|
||||||
|
return
|
||||||
|
graph = self.query_one("#usage-history")
|
||||||
|
if graph.view_mode == "hourly":
|
||||||
|
graph._exit_drill()
|
||||||
|
self._browse_date = date_str
|
||||||
|
graph.navigate_to_date(date_str)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def action_go_to_date(self) -> None:
|
||||||
|
"""Open the date entry screen."""
|
||||||
|
self.push_screen(DatePickerScreen(), callback=self._go_to_date_callback)
|
||||||
|
|
||||||
def action_toggle_motion(self) -> None:
|
def action_toggle_motion(self) -> None:
|
||||||
"""Toggle reduced motion preference (issue #80)."""
|
"""Toggle reduced motion preference (issue #80)."""
|
||||||
self._reduced_motion = not self._reduced_motion
|
self._reduced_motion = not self._reduced_motion
|
||||||
|
|||||||
@@ -0,0 +1,592 @@
|
|||||||
|
"""Tests for issue #92: Browse activity dates from the keyboard.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- AC92-1: [ and ] select previous/next day, g opens date entry, t returns to today/live
|
||||||
|
- AC92-2: Date entry accepts valid dates, reports errors, returns on cancel
|
||||||
|
- AC92-3: Selecting a date obtains correct local-day evidence
|
||||||
|
- AC92-4: Historical date, measurement, and context remain stable across refresh
|
||||||
|
- AC92-5: t resolves to today/live, themes through discoverable control
|
||||||
|
- AC92-6: Drill-down shows available data with distinguishable states
|
||||||
|
- AC92-7: Keyboard actions verified from normal launch with headless driver
|
||||||
|
- AC92-8: Equivalent clickable actions at 80x24 and constrained widths
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
from fenris.local_day import (
|
||||||
|
LocalDaySummary,
|
||||||
|
persist_local_day,
|
||||||
|
query_local_day_summary,
|
||||||
|
)
|
||||||
|
from fenris.tui import (
|
||||||
|
FenrisTuiApp,
|
||||||
|
DailyBarGraph,
|
||||||
|
_RANGE_OPTIONS,
|
||||||
|
_RANGE_DEFAULT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clock(year=2026, month=9, day=30, hour=12):
|
||||||
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||||||
|
identity_key="nqn.test"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments "
|
||||||
|
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(opened_at, identity_key, False, "nqn.test", "SN123",
|
||||||
|
"Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
|
||||||
|
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||||
|
bw=300, br=130, complete=True):
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date=local_date,
|
||||||
|
tz_name=tz_name,
|
||||||
|
tz_offset=tz_offset,
|
||||||
|
utc_start=f"{local_date}T00:00:00+00:00",
|
||||||
|
utc_end=f"{(datetime.fromisoformat(local_date) + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+00:00",
|
||||||
|
bytes_written=bw,
|
||||||
|
bytes_read=br,
|
||||||
|
coverage=0.95,
|
||||||
|
sample_count=24,
|
||||||
|
complete=complete,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, summary)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_store(tmp_path, n_days=30, today=None):
|
||||||
|
"""Create a store with n_days of data and return (conn, clock)."""
|
||||||
|
if today is None:
|
||||||
|
today = _clock()
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(n_days):
|
||||||
|
d = (today - timedelta(days=n_days - 1 - i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d)
|
||||||
|
return conn, today
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(tmp_path, clock=None):
|
||||||
|
"""Create a FenrisTuiApp with the clock mocked to *clock*.
|
||||||
|
|
||||||
|
Returns (app, patcher) - caller must keep patcher alive during the test.
|
||||||
|
"""
|
||||||
|
if clock is None:
|
||||||
|
clock = _clock()
|
||||||
|
from unittest.mock import patch
|
||||||
|
import fenris.tui as tui_mod
|
||||||
|
from datetime import datetime as _real_datetime
|
||||||
|
|
||||||
|
mock_dt = type("MockDatetime", (), {
|
||||||
|
"now": staticmethod(lambda tz=None: clock),
|
||||||
|
"strptime": staticmethod(lambda *a, **kw: _real_datetime.strptime(*a, **kw)),
|
||||||
|
"date": staticmethod(lambda: clock.date()),
|
||||||
|
})()
|
||||||
|
|
||||||
|
patcher = patch.object(tui_mod, "datetime", mock_dt)
|
||||||
|
patcher.start()
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
return app, patcher
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-1: Keyboard bindings exist
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBindingExistence:
|
||||||
|
def test_open_square_bracket_binding(self, tmp_path):
|
||||||
|
"""The [ binding exists for previous day navigation."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "left_square_bracket" in binding_keys
|
||||||
|
|
||||||
|
def test_close_square_bracket_binding(self, tmp_path):
|
||||||
|
"""The ] binding exists for next day navigation."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "right_square_bracket" in binding_keys
|
||||||
|
|
||||||
|
def test_g_binding_exists(self, tmp_path):
|
||||||
|
"""The g binding exists for date entry."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "g" in binding_keys
|
||||||
|
|
||||||
|
def test_t_binding_is_today(self, tmp_path):
|
||||||
|
"""The t binding maps to action_today, not a theme action."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
t_binding = next(b for b in app.BINDINGS if b.key == "t")
|
||||||
|
assert "today" in t_binding.action.lower()
|
||||||
|
|
||||||
|
def test_pause_resume_quit_bindings_exist(self, tmp_path):
|
||||||
|
"""Existing bindings are preserved."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "p" in binding_keys # pause
|
||||||
|
assert "r" in binding_keys # resume
|
||||||
|
assert "q" in binding_keys # quit
|
||||||
|
assert "d" in binding_keys # disclosures
|
||||||
|
assert "m" in binding_keys # motion toggle
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-1: Bracket navigation from normal launch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBracketNavigation:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_right_bracket_advances_day(self, tmp_path):
|
||||||
|
"""The ] key moves to the next (newer) day when not at the end."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
# Move to an older day first so ] can advance
|
||||||
|
for _ in range(3):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
day_before = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
await pilot.press("right_square_bracket")
|
||||||
|
day_after = graph._day_data[graph.selected_index].get("day")
|
||||||
|
# ] should move to a newer day (closer to today)
|
||||||
|
assert day_after > day_before
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_left_bracket_goes_to_previous_day(self, tmp_path):
|
||||||
|
"""The [ key moves to the previous (older) day."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
day_before = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
day_after = graph._day_data[graph.selected_index].get("day")
|
||||||
|
# [ should move to an older day (further from today)
|
||||||
|
assert day_after < day_before
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bracket_navigates_outside_visible_range(self, tmp_path):
|
||||||
|
"""[ beyond the current range shifts the visible window."""
|
||||||
|
app_clock = _clock()
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
assert graph.range_days == _RANGE_DEFAULT
|
||||||
|
|
||||||
|
# Navigate to oldest day by pressing [ many times
|
||||||
|
for _ in range(29):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
|
||||||
|
# Graph should now show a range that includes the oldest day
|
||||||
|
assert graph.selected_index >= 0
|
||||||
|
selected_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
oldest_day = (app_clock - timedelta(days=29)).strftime("%Y-%m-%d")
|
||||||
|
assert selected_day == oldest_day
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bracket_at_boundary_does_not_move(self, tmp_path):
|
||||||
|
"""] at the last day does not move the selection."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
initial_idx = graph.selected_index
|
||||||
|
|
||||||
|
await pilot.press("right_square_bracket")
|
||||||
|
assert graph.selected_index == initial_idx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-1: Arrow keys inspect the selected view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestArrowInspection:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_arrow_keys_still_work_in_daily_view(self, tmp_path):
|
||||||
|
"""Arrow keys move the selection within the visible daily view."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
# Focus the graph widget explicitly
|
||||||
|
graph.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
day_before = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
await pilot.press("left")
|
||||||
|
day_after = graph._day_data[graph.selected_index].get("day")
|
||||||
|
assert day_after != day_before
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_arrow_keys_inspect_hours_in_drill(self, tmp_path):
|
||||||
|
"""Arrow keys navigate hours when in hourly drill-down."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
|
# Enter drill-down
|
||||||
|
await pilot.press("enter")
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
|
||||||
|
# Arrow keys navigate hours
|
||||||
|
await pilot.press("left")
|
||||||
|
assert graph._hourly_selected >= 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-1: t returns to today/live
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTodayBinding:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_t_returns_to_today_after_browsing(self, tmp_path):
|
||||||
|
"""The t key returns to today's live view after browsing."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
|
||||||
|
# Browse to an older day using [
|
||||||
|
for _ in range(5):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
browsed_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
today = clock.strftime("%Y-%m-%d")
|
||||||
|
assert browsed_day != today
|
||||||
|
|
||||||
|
# Press t to return to today
|
||||||
|
await pilot.press("t")
|
||||||
|
assert graph.selected_index == len(graph._day_data) - 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_t_exits_hourly_drill(self, tmp_path):
|
||||||
|
"""The t key exits hourly drill-down back to daily view."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
|
# Enter drill
|
||||||
|
await pilot.press("enter")
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
|
||||||
|
# t returns to daily
|
||||||
|
await pilot.press("t")
|
||||||
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-2: Date entry screen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDateEntry:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_g_opens_date_entry(self, tmp_path):
|
||||||
|
"""The g key opens the date entry screen."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.pause()
|
||||||
|
# Date picker should be pushed as a screen
|
||||||
|
assert len(app.screen_stack) > 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_valid_date_navigates(self, tmp_path):
|
||||||
|
"""Entering a valid date navigates to that day."""
|
||||||
|
app_clock = _clock()
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
target_date = (app_clock - timedelta(days=5)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Simulate the date picker callback directly
|
||||||
|
app._go_to_date_callback(target_date)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
selected_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
assert selected_day == target_date
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_date_shows_error(self, tmp_path):
|
||||||
|
"""Entering an invalid date shows an error message."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("n", "o", "t", " ", "a", " ", "d", "a", "t", "e")
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
# Should remain on date picker (error shown, not dismissed)
|
||||||
|
assert len(app.screen_stack) > 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_returns_to_prior(self, tmp_path):
|
||||||
|
"""Pressing escape in date entry returns to the prior view."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
prior_idx = graph.selected_index
|
||||||
|
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should be back to the graph with the same selection
|
||||||
|
assert graph.selected_index == prior_idx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-3: Selecting a date obtains correct local-day evidence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLocalDayEvidence:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bracket_shows_local_day_for_selected_date(self, tmp_path):
|
||||||
|
"""Navigating with brackets shows the local-day summary for that date."""
|
||||||
|
app_clock = _clock()
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14, today=app_clock)
|
||||||
|
# Insert local-day data for a day within the 14-day range
|
||||||
|
target = (app_clock - timedelta(days=4)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(conn, target, bw=500, br=200)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
|
||||||
|
# Navigate to the target day
|
||||||
|
for _ in range(4):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
local_day = app.query_one("#local-day")
|
||||||
|
text = str(local_day.render())
|
||||||
|
assert target in text
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_date_picker_shows_local_day(self, tmp_path):
|
||||||
|
"""Picking a date via the date picker shows its local-day summary."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
target = (clock - timedelta(days=10)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(conn, target, bw=100, br=50)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
# Simulate the date picker callback directly
|
||||||
|
app._go_to_date_callback(target)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
local_day = app.query_one("#local-day")
|
||||||
|
text = str(local_day.render())
|
||||||
|
assert target in text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-4: Browsing stability across background refresh
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBrowsingStability:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selection_stable_across_refresh(self, tmp_path):
|
||||||
|
"""Browsing selection is preserved when background refresh fires."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
|
||||||
|
# Browse to a specific day
|
||||||
|
for _ in range(3):
|
||||||
|
await pilot.press("right_square_bracket")
|
||||||
|
browsed_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
# Wait for at least one refresh tick
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Selection should be preserved
|
||||||
|
assert graph.selected_index >= 0
|
||||||
|
assert graph._day_data[graph.selected_index].get("day") == browsed_day
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_navigate_away_does_not_jump_on_refresh(self, tmp_path):
|
||||||
|
"""After navigating away from today, refresh does not jump back."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
|
||||||
|
# Browse to older day using [
|
||||||
|
for _ in range(5):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
|
||||||
|
browsed_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
today = clock.strftime("%Y-%m-%d")
|
||||||
|
assert browsed_day != today
|
||||||
|
|
||||||
|
# Wait for refresh
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should NOT jump back to today
|
||||||
|
selected_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
assert selected_day != today
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-6: Drill-down shows available data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDrillDownData:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drill_down_shows_loading_then_data(self, tmp_path):
|
||||||
|
"""Drill-down transitions from loading to actual data."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
|
# Enter drill-down
|
||||||
|
await pilot.press("enter")
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
|
||||||
|
# After drill callback, data should be populated
|
||||||
|
# (empty since we have no hour_observations)
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-8: Constrained width behavior
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConstrainedWidth:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bracket_navigation_works_at_80x24(self, tmp_path):
|
||||||
|
"""Date navigation works at minimum 80x24 terminal size."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
day_before = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
day_after = graph._day_data[graph.selected_index].get("day")
|
||||||
|
assert day_after < day_before
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_g_opens_date_entry_at_80x24(self, tmp_path):
|
||||||
|
"""Date entry opens at minimum terminal size."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.pause()
|
||||||
|
assert len(app.screen_stack) > 1
|
||||||
Reference in New Issue
Block a user