"""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