fix: follow live activity until inspection (#102)
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""Activity-selection transitions independent from Textual widgets."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.activity_selection import ActivitySelection, IntervalIdentity
|
||||
|
||||
|
||||
def _interval(n):
|
||||
return IntervalIdentity(f"start-{n}", f"end-{n}")
|
||||
|
||||
|
||||
def test_live_selection_starts_and_stays_on_newest_interval():
|
||||
selection = ActivitySelection()
|
||||
first = [_interval(1), _interval(2)]
|
||||
|
||||
selection.update_live(first)
|
||||
assert selection.following_live
|
||||
assert selection.selected_live_interval == first[-1]
|
||||
|
||||
second = first + [_interval(3)]
|
||||
selection.update_live(second)
|
||||
assert selection.selected_live_interval == second[-1]
|
||||
|
||||
|
||||
def test_keyboard_and_mouse_inspection_pin_interval_identity():
|
||||
intervals = [_interval(1), _interval(2), _interval(3)]
|
||||
keyboard = ActivitySelection()
|
||||
mouse = ActivitySelection()
|
||||
|
||||
keyboard.update_live(intervals)
|
||||
keyboard.move_live(intervals, -1)
|
||||
mouse.update_live(intervals)
|
||||
mouse.inspect_live(intervals, intervals[1])
|
||||
|
||||
assert keyboard.selected_live_interval == mouse.selected_live_interval == intervals[1]
|
||||
assert not keyboard.following_live
|
||||
assert not mouse.following_live
|
||||
|
||||
refreshed = [_interval(2), _interval(3), _interval(4)]
|
||||
keyboard.update_live(refreshed)
|
||||
mouse.update_live(refreshed)
|
||||
assert keyboard.selected_live_interval == mouse.selected_live_interval == intervals[1]
|
||||
|
||||
|
||||
def test_expired_pin_explains_expiration_and_resumes_following():
|
||||
selection = ActivitySelection()
|
||||
intervals = [_interval(1), _interval(2), _interval(3)]
|
||||
selection.update_live(intervals)
|
||||
selection.inspect_live(intervals, intervals[0])
|
||||
|
||||
newest = _interval(4)
|
||||
selection.update_live([_interval(2), _interval(3), newest])
|
||||
|
||||
assert selection.following_live
|
||||
assert selection.selected_live_interval == newest
|
||||
assert selection.live_interval_expired
|
||||
|
||||
|
||||
def test_today_resets_expired_pin_and_read_write_toggle_keeps_identity():
|
||||
selection = ActivitySelection()
|
||||
intervals = [_interval(1), _interval(2)]
|
||||
selection.update_live(intervals)
|
||||
selection.inspect_live(intervals, intervals[0])
|
||||
selection.update_live([intervals[1]])
|
||||
newest = _interval(3)
|
||||
selection.update_live([intervals[1], newest])
|
||||
assert selection.live_interval_expired
|
||||
|
||||
selection.toggle_measure()
|
||||
assert selection.measure == "read"
|
||||
assert selection.selected_live_interval == newest
|
||||
|
||||
selection.select_history_date("2026-09-27")
|
||||
selection.set_view("history")
|
||||
selection.set_view("live")
|
||||
assert selection.browse_date is None
|
||||
assert selection.following_live
|
||||
assert not selection.live_interval_expired
|
||||
|
||||
|
||||
def test_live_identity_uses_both_interval_boundaries():
|
||||
first = IntervalIdentity("2026-09-28T10:00:00Z", "2026-09-28T10:03:00Z")
|
||||
changed_start = IntervalIdentity("2026-09-28T09:57:00Z", "2026-09-28T10:03:00Z")
|
||||
selection = ActivitySelection()
|
||||
selection.update_live([first])
|
||||
selection.inspect_live([first], first)
|
||||
|
||||
selection.update_live([changed_start])
|
||||
|
||||
assert selection.following_live
|
||||
assert selection.selected_live_interval == changed_start
|
||||
assert selection.live_interval_expired
|
||||
+108
-7
@@ -6,26 +6,24 @@ Covers:
|
||||
- Three-minute cadence constants
|
||||
- TUI integration with live graph
|
||||
"""
|
||||
import sys
|
||||
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.activity_selection import IntervalIdentity
|
||||
from fenris.monitoring_periods import ensure_period_open
|
||||
from fenris.status import ACCURACY_SEC, CADENCE_DEFAULT_S, FRESH_THRESHOLD_S
|
||||
from fenris.store import init_store
|
||||
from fenris.tui import (
|
||||
LIVE_WINDOW_H,
|
||||
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
|
||||
@@ -63,6 +61,27 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||
|
||||
|
||||
def _live_intervals(count=4):
|
||||
first = datetime(2026, 9, 28, 12, 0, tzinfo=timezone.utc)
|
||||
intervals = []
|
||||
for index in range(count):
|
||||
start = first + timedelta(minutes=3 * index)
|
||||
end = start + timedelta(minutes=3)
|
||||
intervals.append({
|
||||
"start_ts": start.isoformat(),
|
||||
"end_ts": end.isoformat(),
|
||||
"start_label": start.strftime("%H:%M"),
|
||||
"end_label": end.strftime("%H:%M"),
|
||||
"bytes_written": (index + 1) * 1000,
|
||||
"bytes_read": (index + 1) * 500,
|
||||
"elapsed_s": 180,
|
||||
"is_gap": False,
|
||||
"is_zero": False,
|
||||
"is_segment_boundary": False,
|
||||
})
|
||||
return intervals
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cadence constants (issue #91 AC1)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -322,3 +341,85 @@ class TestTUILiveIntegration:
|
||||
await pilot.pause()
|
||||
main_grid = app.query_one("#main-grid")
|
||||
assert main_grid.has_class("constrained")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("size", [(100, 40), (70, 20)])
|
||||
async def test_follow_pin_expiry_and_today_use_visible_readout(self, tmp_path, size):
|
||||
"""Live selection follows, pins, expires, and resets at both layout sizes."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
intervals = _live_intervals()
|
||||
async with app.run_test(size=size) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#live-activity")
|
||||
graph.set_data(intervals[:3])
|
||||
|
||||
readout = str(app.query_one("#live-readout").render())
|
||||
assert "12:09 UTC" in readout
|
||||
assert graph.selection.following_live
|
||||
assert graph.selection.selected_live_interval == IntervalIdentity(
|
||||
intervals[2]["start_ts"], intervals[2]["end_ts"]
|
||||
)
|
||||
|
||||
# Refresh while following selects the newly measured newest point.
|
||||
graph.set_data(intervals)
|
||||
assert "12:12 UTC" in str(app.query_one("#live-readout").render())
|
||||
|
||||
# Live graph receives focus at launch; first arrow pins immediately.
|
||||
await pilot.press("left")
|
||||
pinned = graph.selection.selected_live_interval
|
||||
readout = str(app.query_one("#live-readout").render())
|
||||
assert "12:09 UTC" in readout
|
||||
assert not graph.selection.following_live
|
||||
|
||||
graph.set_data(intervals)
|
||||
readout = str(app.query_one("#live-readout").render())
|
||||
assert "12:09 UTC" in readout
|
||||
assert graph.selection.selected_live_interval == pinned
|
||||
|
||||
# Read/write changes presentation only; pinned point stays stable.
|
||||
await pilot.press("w")
|
||||
graph.set_data(intervals)
|
||||
assert graph.measure == "read"
|
||||
assert graph.selection.selected_live_interval == pinned
|
||||
assert "12:09 UTC" in str(app.query_one("#live-readout").render())
|
||||
|
||||
# Remove pinned interval from rolling window. Explain expiration.
|
||||
graph.set_data(intervals[3:])
|
||||
readout = str(app.query_one("#live-readout").render())
|
||||
assert "Inspected interval expired; following live" in readout
|
||||
assert "12:12 UTC" in readout
|
||||
assert graph.selection.following_live
|
||||
|
||||
# Today action clears expiration and resumes newest-point following.
|
||||
await pilot.press("t")
|
||||
assert graph.selection.following_live
|
||||
assert not graph.selection.live_interval_expired
|
||||
graph.set_data(intervals)
|
||||
readout = str(app.query_one("#live-readout").render())
|
||||
assert "12:12 UTC" in readout
|
||||
assert "Inspected interval expired" not in readout
|
||||
assert graph.selection.following_live
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mouse_inspection_pins_same_interval_identity(self, tmp_path):
|
||||
"""Mouse inspection pins the interval at its visible graph location."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
intervals = _live_intervals()
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#live-activity")
|
||||
graph.set_data(intervals[:3])
|
||||
await pilot.pause()
|
||||
render = app.query_one("#live-render")
|
||||
await pilot.click(
|
||||
"#live-render",
|
||||
offset=(8, max(0, render.content_size.height // 2)),
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
expected = IntervalIdentity(
|
||||
intervals[0]["start_ts"], intervals[0]["end_ts"]
|
||||
)
|
||||
assert graph.selection.selected_live_interval == expected
|
||||
assert not graph.selection.following_live
|
||||
assert "12:03 UTC" in str(app.query_one("#live-readout").render())
|
||||
|
||||
Reference in New Issue
Block a user