From 5df8a12339760bec65bdbd96f071b7d56cfed200 Mon Sep 17 00:00:00 2001 From: xavierk Date: Fri, 18 Sep 2026 14:17:02 +0530 Subject: [PATCH] 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 --- src/fenris/tui.py | 184 ++++++++++++- tests/test_issue_92.py | 592 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 766 insertions(+), 10 deletions(-) create mode 100644 tests/test_issue_92.py diff --git a/src/fenris/tui.py b/src/fenris/tui.py index 75b2717..af3e883 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -26,7 +26,7 @@ from textual.binding import Binding from textual.containers import Container, Horizontal, VerticalScroll from textual.screen import ModalScreen from textual.widget import Widget -from textual.widgets import Static +from textual.widgets import Input, Static from .projection import ( 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" _ACTION_LEGEND = ( "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: @@ -191,10 +191,11 @@ class DailyBarGraph(Widget): self, day_data: List[Dict[str, Any]], on_drill: Any = None, + selected_date: Optional[str] = None, ) -> None: """Update graph with day data. on_drill(day) called on drill entry.""" - selected_day = None - if 0 <= self.selected_index < len(self._day_data): + selected_day = selected_date + if selected_day is None and 0 <= self.selected_index < len(self._day_data): selected_day = self._day_data[self.selected_index].get("day") self._all_day_data = day_data self._on_drill = on_drill @@ -699,6 +700,42 @@ class DailyBarGraph(Widget): self._refresh_hourly() 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: render = self.query_one("#bar-render") offset_x = event.x - render.region.x @@ -1333,6 +1370,7 @@ class HelpScreen(ModalScreen[None]): + _ACTION_LEGEND + "\n" "q Quit the dashboard — monitoring continues\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" "w Toggle W/R on live graph\n" "Theme changed via preferences file\n\n" @@ -1344,6 +1382,68 @@ class HelpScreen(ModalScreen[None]): 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 # --------------------------------------------------------------------------- @@ -1451,6 +1551,9 @@ class FenrisTuiApp(App): Binding("c", "collect", "Collect now", show=False), Binding("d", "disclose", "Disclosures", 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("w", "toggle_measure", "W/R", show=False), Binding("?", "help", "Help", show=False), @@ -1485,6 +1588,9 @@ class FenrisTuiApp(App): # Track constrained state for resize handling (issue #81) 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: with VerticalScroll(id="dashboard-scroll"): with Container(id="main-grid"): @@ -1675,7 +1781,10 @@ class FenrisTuiApp(App): hour_data = _query_hourly_graph_data( 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.drill_day = saved_drill_day graph.set_hour_data(hour_data) @@ -1687,7 +1796,10 @@ class FenrisTuiApp(App): ) graph._refresh_hourly() 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) if self._is_constrained_mode: @@ -1758,12 +1870,18 @@ class FenrisTuiApp(App): main_grid.refresh(layout=True) 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: 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() - 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: local = None @@ -1870,12 +1988,58 @@ class FenrisTuiApp(App): def action_today(self) -> None: """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") if graph.view_mode == "hourly": 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() + 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: """Toggle reduced motion preference (issue #80).""" self._reduced_motion = not self._reduced_motion diff --git a/tests/test_issue_92.py b/tests/test_issue_92.py new file mode 100644 index 0000000..4922a22 --- /dev/null +++ b/tests/test_issue_92.py @@ -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