"""Tests for live activity graph (issue #91). Covers: - Live interval volume query from raw samples - LiveActivityGraph widget rendering, toggle, and inspection - Three-minute cadence constants - TUI integration with live graph """ from datetime import datetime, timedelta, timezone from pathlib import Path 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.tui import ( FenrisTuiApp, LiveActivityGraph, _query_live_graph_data, LIVE_WINDOW_H, _query_daily_graph_data, _RANGE_OPTIONS, ) from fenris.status import CADENCE_DEFAULT_S, FRESH_THRESHOLD_S, ACCURACY_SEC # --------------------------------------------------------------------------- # 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", degraded=False, mn="Samsung SSD 970 EVO Plus 1TB"): conn.execute( "INSERT INTO controller_segments " "(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1", "0x144d", "0x144d", "pcie"), ) conn.commit() def _insert_sample(conn, ts, bw=512000000000, br=256000000000, segment_id=None, pu=5, device="/dev/nvme0n1"): conn.execute( "INSERT INTO samples (ts, device, data_units_written, data_units_read, " "percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (ts, device, 1000000, 500000, pu, bw, br, 8765, segment_id), ) conn.commit() def _open_period(conn, start="2026-09-01T00:00:00+00:00"): ensure_period_open(conn, datetime.fromisoformat(start)) # --------------------------------------------------------------------------- # Cadence constants (issue #91 AC1) # --------------------------------------------------------------------------- class TestCadenceConstants: def test_cadence_default_is_3_minutes(self): """Default collection cadence is 3 minutes (180 s).""" assert CADENCE_DEFAULT_S == 180 def test_fresh_threshold_uses_3min_cadence(self): """Fresh threshold = 2 × 3min + AccuracySec + 60s = 450s.""" expected = 2 * 180 + ACCURACY_SEC + 60 assert FRESH_THRESHOLD_S == expected def test_live_window_is_3_hours(self): """Live graph window is 3 hours.""" assert LIVE_WINDOW_H == 3 # --------------------------------------------------------------------------- # Live graph query (issue #91 AC2-5) # --------------------------------------------------------------------------- class TestQueryLiveData: def test_empty_store_returns_empty(self, tmp_path): """No samples → empty list.""" conn = init_store(tmp_path / "test.db") now = _clock() result = _query_live_graph_data(conn, now) assert result == [] conn.close() def test_single_sample_returns_empty(self, tmp_path): """One sample (no pair) → empty list.""" conn = init_store(tmp_path / "test.db") now = _clock() _insert_sample(conn, now.isoformat()) result = _query_live_graph_data(conn, now) assert result == [] conn.close() def test_two_samples_in_window(self, tmp_path): """Two samples within 3h window produce one interval.""" conn = init_store(tmp_path / "test.db") now = _clock() t1 = (now - timedelta(minutes=3)).isoformat() t2 = (now - timedelta(minutes=0)).isoformat() _insert_sample(conn, t1, bw=1000, br=500) _insert_sample(conn, t2, bw=1500, br=700) result = _query_live_graph_data(conn, now) assert len(result) == 1 assert result[0]["bytes_written"] == 500 assert result[0]["bytes_read"] == 200 assert result[0]["elapsed_s"] == 180 assert result[0]["is_gap"] is False assert result[0]["is_zero"] is False conn.close() def test_samples_outside_window_ignored(self, tmp_path): """Samples older than 3h are excluded.""" conn = init_store(tmp_path / "test.db") now = _clock() old = (now - timedelta(hours=4)).isoformat() recent = (now - timedelta(minutes=1)).isoformat() _insert_sample(conn, old, bw=1000, br=500) _insert_sample(conn, recent, bw=1500, br=700) result = _query_live_graph_data(conn, now) assert len(result) == 0 conn.close() def test_gap_detection(self, tmp_path): """Interval > 3× cadence is flagged as a gap.""" conn = init_store(tmp_path / "test.db") now = _clock() t1 = (now - timedelta(minutes=13)).isoformat() t2 = (now - timedelta(minutes=3)).isoformat() _insert_sample(conn, t1, bw=1000, br=500) _insert_sample(conn, t2, bw=1500, br=700) result = _query_live_graph_data(conn, now) assert len(result) == 1 assert result[0]["is_gap"] is True conn.close() def test_zero_interval(self, tmp_path): """No byte delta → is_zero flag.""" conn = init_store(tmp_path / "test.db") now = _clock() t1 = (now - timedelta(minutes=3)).isoformat() t2 = (now - timedelta(minutes=0)).isoformat() _insert_sample(conn, t1, bw=1000, br=500) _insert_sample(conn, t2, bw=1000, br=500) result = _query_live_graph_data(conn, now) assert len(result) == 1 assert result[0]["bytes_written"] == 0 assert result[0]["bytes_read"] == 0 assert result[0]["is_zero"] is True conn.close() def test_segment_boundary_resets_counters(self, tmp_path): """Counter discontinuity across segments → zero delta.""" conn = init_store(tmp_path / "test.db") _insert_segment(conn, identity_key="key_a") _insert_segment(conn, opened_at="2026-09-30T11:00:00+00:00", identity_key="key_b") now = _clock() t1 = (now - timedelta(minutes=6)).isoformat() t2 = (now - timedelta(minutes=3)).isoformat() _insert_sample(conn, t1, bw=1000, br=500, segment_id=1) _insert_sample(conn, t2, bw=500, br=200, segment_id=2) result = _query_live_graph_data(conn, now) assert len(result) == 1 assert result[0]["bytes_written"] == 0 assert result[0]["bytes_read"] == 0 assert result[0]["is_segment_boundary"] is True conn.close() def test_actual_timestamps_used(self, tmp_path): """Points use actual sample timestamps, not 3-min spacing.""" conn = init_store(tmp_path / "test.db") now = _clock() t1 = (now - timedelta(minutes=5)).isoformat() t2 = (now - timedelta(minutes=1)).isoformat() _insert_sample(conn, t1, bw=1000, br=500) _insert_sample(conn, t2, bw=2000, br=800) result = _query_live_graph_data(conn, now) assert len(result) == 1 assert result[0]["start_ts"] == t1 assert result[0]["end_ts"] == t2 assert result[0]["elapsed_s"] == 240 conn.close() def test_multiple_intervals_ordered_oldest_first(self, tmp_path): """Multiple intervals are returned in chronological order.""" conn = init_store(tmp_path / "test.db") now = _clock() for i in range(5): ts = (now - timedelta(minutes=15 - i * 3)).isoformat() _insert_sample(conn, ts, bw=1000 * (i + 1), br=500 * (i + 1)) result = _query_live_graph_data(conn, now) assert len(result) == 4 for i in range(len(result) - 1): assert result[i]["end_ts"] <= result[i + 1]["start_ts"] conn.close() # --------------------------------------------------------------------------- # LiveActivityGraph widget (issue #91 AC3) # --------------------------------------------------------------------------- class TestLiveActivityGraph: @pytest.mark.asyncio async def test_toggle_measure(self, tmp_path): """Toggle switches between written and read.""" app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test() as pilot: await pilot.pause() graph = app.query_one("#live-activity") assert graph.measure == "written" graph.toggle_measure() assert graph.measure == "read" graph.toggle_measure() assert graph.measure == "written" @pytest.mark.asyncio async def test_empty_data_renders_awaiting(self, tmp_path): """Empty data shows awaiting message.""" app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test() as pilot: await pilot.pause() graph = app.query_one("#live-activity") graph.set_data([]) await pilot.pause() render = str(graph.query_one("#live-render").render()) assert "Awaiting intervals" in render @pytest.mark.asyncio async def test_data_renders_header(self, tmp_path): """Data updates the header with interval count.""" app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test() as pilot: await pilot.pause() graph = app.query_one("#live-activity") data = [ { "start_ts": "2026-09-30T11:57:00+00:00", "end_ts": "2026-09-30T12:00:00+00:00", "start_label": "11:57", "end_label": "12:00", "bytes_written": 500000000, "bytes_read": 200000000, "elapsed_s": 180, "is_gap": False, "is_zero": False, "is_segment_boundary": False, }, ] graph.set_data(data) await pilot.pause() header = str(graph.query_one("#live-header").render()) assert "1 intervals" in header assert "w Toggle W/R" in header # --------------------------------------------------------------------------- # TUI integration (issue #91 AC2, AC6-9) # --------------------------------------------------------------------------- class TestTUILiveIntegration: @pytest.mark.asyncio async def test_live_graph_exists_in_compose(self, tmp_path): """Live activity graph is composed in the TUI.""" app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test() as pilot: await pilot.pause() assert app.query_one("#live-activity") is not None assert isinstance(app.query_one("#live-activity"), LiveActivityGraph) @pytest.mark.asyncio async def test_live_graph_has_border_title(self, tmp_path): """Live activity graph has a border title.""" app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test() as pilot: await pilot.pause() live = app.query_one("#live-activity") assert live.border_title == "Live activity" @pytest.mark.asyncio async def test_today_action_resets_drill(self, tmp_path): """Action today returns from hourly drill to daily view.""" app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test() as pilot: await pilot.pause() graph = app.query_one("#usage-history") graph.view_mode = "hourly" graph.drill_day = "2026-09-30" app.action_today() await pilot.pause() assert graph.view_mode == "daily" @pytest.mark.asyncio async def test_toggle_measure_binding(self, tmp_path): """The w key toggles the live graph measure.""" app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test() as pilot: await pilot.pause() graph = app.query_one("#live-activity") assert graph.measure == "written" await pilot.press("w") await pilot.pause() assert graph.measure == "read" @pytest.mark.asyncio async def test_constrained_mode_hides_live_graph(self, tmp_path): """Constrained terminal hides the live graph.""" app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(70, 20)) as pilot: await pilot.pause() main_grid = app.query_one("#main-grid") assert main_grid.has_class("constrained")