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:
xavierk
2026-09-18 14:17:02 +05:30
co-authored by CommandCodeBot
parent e12f4a574c
commit 5df8a12339
2 changed files with 766 additions and 10 deletions
+174 -10
View File
@@ -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,12 +1870,18 @@ 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()
local = query_current_local_day(conn, self._clock_now, tz_name) 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)
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
+592
View File
@@ -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