fix: follow live activity until inspection (#102)

This commit is contained in:
xavierk
2026-09-28 16:32:13 +05:30
parent 3e1ecc90e4
commit f780212a02
4 changed files with 442 additions and 60 deletions
+108
View File
@@ -0,0 +1,108 @@
"""User choices for live and historical activity views."""
from collections.abc import Sequence
from dataclasses import dataclass
HISTORY_RANGE_DEFAULT = 14
@dataclass(frozen=True)
class IntervalIdentity:
"""Stable identity for one measured interval."""
start_ts: str
end_ts: str
class ActivitySelection:
"""Keep activity navigation state separate from its Textual rendering."""
def __init__(self) -> None:
self.view = "live"
self.browse_date: str | None = None
self.selected_history_hour: str | None = None
self.history_range_days = HISTORY_RANGE_DEFAULT
self.measure = "written"
self.following_live = True
self.selected_live_interval: IntervalIdentity | None = None
self.live_interval_expired = False
def set_view(self, view: str) -> None:
"""Select live, day, or history view."""
if view not in ("live", "day", "history"):
raise ValueError(f"unknown activity view: {view}")
self.view = view
if view == "live":
self.follow_live()
def select_history_date(self, date: str | None) -> None:
"""Keep the chosen historical local date across refresh."""
if date != self.browse_date:
self.selected_history_hour = None
self.browse_date = date
def select_history_hour(self, hour: str | None) -> None:
"""Keep the chosen historical hour across refresh."""
self.selected_history_hour = hour
def toggle_measure(self) -> str:
"""Switch read/write presentation without changing point selection."""
self.measure = "read" if self.measure == "written" else "written"
return self.measure
def follow_live(self) -> None:
"""Resume following the newest interval and clear stale-pin notices."""
self.view = "live"
self.browse_date = None
self.selected_history_hour = None
self.following_live = True
self.selected_live_interval = None
self.live_interval_expired = False
def update_live(self, intervals: Sequence[IntervalIdentity]) -> None:
"""Reconcile the selected interval with the rolling live window."""
if self.following_live:
self.selected_live_interval = intervals[-1] if intervals else None
return
if self.selected_live_interval in intervals:
return
self.following_live = True
self.selected_live_interval = intervals[-1] if intervals else None
self.live_interval_expired = True
def move_live(
self, intervals: Sequence[IntervalIdentity], offset: int,
) -> IntervalIdentity | None:
"""Inspect an adjacent interval by stable identity."""
if not intervals:
return None
if self.selected_live_interval in intervals:
current = intervals.index(self.selected_live_interval)
else:
current = len(intervals) - 1
selected = intervals[max(0, min(len(intervals) - 1, current + offset))]
self._pin_live_interval(selected)
return selected
def inspect_live(
self,
intervals: Sequence[IntervalIdentity],
interval: IntervalIdentity,
) -> None:
"""Pin the interval chosen by mouse inspection."""
if interval in intervals:
self._pin_live_interval(interval)
def selected_live_index(self, intervals: Sequence[IntervalIdentity]) -> int:
"""Return the selected point's current render index, or -1."""
if self.selected_live_interval is None:
return -1
try:
return intervals.index(self.selected_live_interval)
except ValueError:
return -1
def _pin_live_interval(self, interval: IntervalIdentity) -> None:
self.selected_live_interval = interval
self.following_live = False
self.live_interval_expired = False
+132 -53
View File
@@ -26,6 +26,11 @@ from textual.message import Message
from textual.widget import Widget
from textual.widgets import Input, Static, Tab, Tabs
from .activity_selection import (
HISTORY_RANGE_DEFAULT,
ActivitySelection,
IntervalIdentity,
)
from .activity_plot import VolumePoint, volume_plot
from .projection import (
@@ -109,7 +114,7 @@ def _can_render_wolf(width: int) -> bool:
# ---------------------------------------------------------------------------
_RANGE_OPTIONS = (7, 14, 30, 90)
_RANGE_DEFAULT = 14
_RANGE_DEFAULT = HISTORY_RANGE_DEFAULT
# Terminal size thresholds for constrained layout (issue #81)
_MIN_WIDTH = 80
@@ -157,9 +162,11 @@ class HistoryGraph(Widget):
}
"""
def __init__(self, **kwargs: Any) -> None:
def __init__(
self, selection: ActivitySelection | None = None, **kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.range_days: int = _RANGE_DEFAULT
self.selection = selection or ActivitySelection()
self.selected_index: int = -1
self.view_mode: str = "daily"
self.drill_day: Optional[str] = None
@@ -170,10 +177,27 @@ class HistoryGraph(Widget):
self._hourly_selected: int = -1
self._drill_unallocated_bytes: int = 0
self._on_drill: Optional[Callable[[str], None]] = None
self.measure = "written"
self._point_columns: list[int] = []
self._empty_message = "Awaiting first sample"
@property
def range_days(self) -> int:
return self.selection.history_range_days
@range_days.setter
def range_days(self, value: int) -> None:
self.selection.history_range_days = value
@property
def measure(self) -> str:
return self.selection.measure
@measure.setter
def measure(self, value: str) -> None:
if value not in ("written", "read"):
raise ValueError("unknown activity measurement: %s" % value)
self.selection.measure = value
def compose(self) -> ComposeResult:
yield Static("", id="bar-range")
yield Static("", id="bar-render")
@@ -189,7 +213,7 @@ class HistoryGraph(Widget):
) -> None:
"""Update graph with day data. on_drill(day) called on drill entry."""
self._empty_message = empty_message
selected_day = selected_date
selected_day = selected_date or self.selection.browse_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
@@ -206,15 +230,18 @@ class HistoryGraph(Widget):
None,
)
self.selected_index = matching if matching is not None else len(self._day_data) - 1
if self.selection.view != "live":
self.selection.select_history_date(
self._day_data[self.selected_index].get("day")
)
else:
self.selected_index = -1
self._refresh()
def set_hour_data(self, hour_data: List[Dict[str, Any]]) -> None:
"""Set hourly data for drill-down view."""
selected_hour = None
if 0 <= self._hourly_selected < len(self._hour_data):
selected_hour = self._hour_data[self._hourly_selected].get("hour")
selected_hour = self.selection.selected_history_hour
selected_hour = selected_hour or self._selected_history_hour()
self._hour_data = hour_data
matching = next(
(i for i, item in enumerate(hour_data) if item.get("hour") == selected_hour),
@@ -225,6 +252,7 @@ class HistoryGraph(Widget):
else:
selectable = [i for i, item in enumerate(hour_data) if not item.get("is_future")]
self._hourly_selected = selectable[-1] if selectable else -1
self.selection.select_history_hour(self._selected_history_hour())
self._refresh_hourly()
def _trim_to_range(self) -> None:
@@ -517,13 +545,17 @@ class HistoryGraph(Widget):
def _notify_selection(self) -> None:
if 0 <= self.selected_index < len(self._day_data):
self.post_message(self.DaySelected(self._day_data[self.selected_index]["day"]))
selected_day = self._day_data[self.selected_index]["day"]
self.selection.select_history_date(selected_day)
self.post_message(self.DaySelected(selected_day))
def _enter_drill(self) -> None:
if self.selected_index < 0 or self.selected_index >= len(self._day_data):
return
day = self._day_data[self.selected_index]
self.drill_day = day.get("day")
self.selection.select_history_date(self.drill_day)
self.selection.select_history_hour(None)
self._drill_unallocated_bytes = day.get("unallocated_bytes", 0)
self.view_mode = "hourly"
self._hourly_selected = -1
@@ -536,6 +568,7 @@ class HistoryGraph(Widget):
self.drill_day = None
self._hour_data = []
self._hourly_selected = -1
self.selection.select_history_hour(None)
self._refresh()
def _handle_hourly_key(self, event: Any) -> None:
@@ -551,6 +584,7 @@ class HistoryGraph(Widget):
self._hourly_selected = len(self._hour_data) - 1
else:
self._hourly_selected = max(0, self._hourly_selected - 1)
self._select_history_hour()
self._refresh_hourly()
event.stop()
elif event.key == "right":
@@ -560,9 +594,18 @@ class HistoryGraph(Widget):
self._hourly_selected = min(
len(self._hour_data) - 1, self._hourly_selected + 1
)
self._select_history_hour()
self._refresh_hourly()
event.stop()
def _select_history_hour(self) -> None:
self.selection.select_history_hour(self._selected_history_hour())
def _selected_history_hour(self) -> Optional[str]:
if 0 <= self._hourly_selected < len(self._hour_data):
return self._hour_data[self._hourly_selected].get("hour")
return None
def navigate_to_date(self, date_str: str) -> bool:
"""Navigate to a specific date, adjusting range if needed.
@@ -594,6 +637,7 @@ class HistoryGraph(Widget):
idx,
)
new_day = self._day_data[self.selected_index].get("day")
self.selection.select_history_date(new_day)
return new_day != old_day
def on_click(self, event: Any) -> None:
@@ -608,12 +652,16 @@ class HistoryGraph(Widget):
global_idx = idx
if 0 <= global_idx < len(self._day_data):
self.selected_index = global_idx
self.selection.select_history_date(
self._day_data[global_idx].get("day")
)
self._refresh()
self._notify_selection()
else:
global_idx = idx
if 0 <= global_idx < len(self._hour_data):
self._hourly_selected = global_idx
self._select_history_hour()
self._refresh_hourly()
@@ -920,11 +968,13 @@ class LiveActivityGraph(Widget):
}
"""
def __init__(self, **kwargs: Any) -> None:
def __init__(
self, selection: ActivitySelection | None = None, **kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.selection = selection or ActivitySelection()
self._data: List[Dict[str, Any]] = []
self._selected_index: int = -1
self._measure: str = "written" # "written" or "read"
self._point_columns: list[int] = []
self._empty_message = "Awaiting intervals"
@@ -936,32 +986,36 @@ class LiveActivityGraph(Widget):
@property
def measure(self) -> str:
return self._measure
return self.selection.measure
def toggle_measure(self) -> None:
"""Toggle between written and read volumes."""
self._measure = "read" if self._measure == "written" else "written"
self.selection.toggle_measure()
self._refresh()
def set_data(self, data: List[Dict[str, Any]], empty_message: str = "Awaiting intervals") -> None:
def set_data(
self,
data: List[Dict[str, Any]],
empty_message: str = "Awaiting intervals",
reconcile_selection: bool = True,
) -> None:
"""Update graph with live interval data."""
self._empty_message = empty_message
prev_selected_end = None
if 0 <= self._selected_index < len(self._data):
prev_selected_end = self._data[self._selected_index].get("end_ts")
self._data = data
if prev_selected_end is not None:
self._selected_index = next(
(i for i, d in enumerate(data) if d.get("end_ts") == prev_selected_end),
len(data) - 1 if data else -1,
)
else:
self._selected_index = len(data) - 1 if data else -1
intervals = self._interval_ids()
if reconcile_selection:
self.selection.update_live(intervals)
self._selected_index = self.selection.selected_live_index(intervals)
self._refresh()
def _interval_ids(self) -> list[IntervalIdentity]:
return [
IntervalIdentity(item["start_ts"], item["end_ts"])
for item in self._data
]
def _current_bytes(self, item: Dict[str, Any]) -> int:
if self._measure == "written":
if self.measure == "written":
return item.get("bytes_written", 0)
return item.get("bytes_read", 0)
@@ -970,13 +1024,15 @@ class LiveActivityGraph(Widget):
if not self._data:
self.query_one("#live-render").update(Text(self._empty_message))
self.query_one("#live-legend").update("")
self.query_one("#live-readout").update("")
self.query_one("#live-readout").update(
self._expiration_notice() + self._empty_message
)
return
self._render_bars()
self._render_readout()
def _render_header(self) -> None:
label = "Writes" if self._measure == "written" else "Reads"
label = "Writes" if self.measure == "written" else "Reads"
n = len(self._data)
self.query_one("#live-header").update(
"%s · last %dh · %d intervals"
@@ -1009,18 +1065,21 @@ class LiveActivityGraph(Widget):
def _render_readout(self) -> None:
if self._selected_index < 0 or self._selected_index >= len(self._data):
self.query_one("#live-readout").update("[dim]← → Select interval[/dim]")
self.query_one("#live-readout").update(
self._expiration_notice() + "[dim]← → Select interval[/dim]"
)
return
item = self._data[self._selected_index]
if item.get("is_segment_boundary"):
self.query_one("#live-readout").update(
"%s → %s UTC · segment boundary\nW unavailable · R unavailable" % (
self._expiration_notice()
+ "%s → %s UTC · segment boundary\nW unavailable · R unavailable" % (
item.get("start_label", ""), item.get("end_label", "")))
return
bw = item.get("bytes_written", 0)
br = item.get("bytes_read", 0)
measure_val = bw if self._measure == "written" else br
measure_val = bw if self.measure == "written" else br
scale, unit = _unit_scale(measure_val) if measure_val > 0 else (1e6, "MB")
state_parts = []
@@ -1033,7 +1092,8 @@ class LiveActivityGraph(Widget):
state = " · " + ", ".join(state_parts) if state_parts else ""
self.query_one("#live-readout").update(
"[bold]%s → %s UTC[/bold] · W %.3f GB · R %.3f GB · %ds%s"
self._expiration_notice()
+ "[bold]%s → %s UTC[/bold] · W %.3f GB · R %.3f GB · %ds%s"
% (
item.get("start_label", ""),
item.get("end_label", ""),
@@ -1044,21 +1104,17 @@ class LiveActivityGraph(Widget):
)
)
def _expiration_notice(self) -> str:
if self.selection.live_interval_expired:
return "[bold yellow]Inspected interval expired; following live[/bold yellow]\n"
return ""
def on_key(self, event: Any) -> None:
if event.key == "left":
if self._selected_index < 0:
self._selected_index = len(self._data) - 1
else:
self._selected_index = max(0, self._selected_index - 1)
self._refresh()
event.stop()
elif event.key == "right":
if self._selected_index < 0:
self._selected_index = 0
else:
self._selected_index = min(
len(self._data) - 1, self._selected_index + 1
)
offset = -1 if event.key == "left" else 1 if event.key == "right" else 0
if offset:
intervals = self._interval_ids()
self.selection.move_live(intervals, offset)
self._selected_index = self.selection.selected_live_index(intervals)
self._refresh()
event.stop()
@@ -1070,7 +1126,9 @@ class LiveActivityGraph(Widget):
offset_x = event.screen_x - render.content_region.x
idx = min(range(len(self._point_columns)), key=lambda i: abs(self._point_columns[i] - offset_x))
if 0 <= idx < len(self._data):
self._selected_index = idx
intervals = self._interval_ids()
self.selection.inspect_live(intervals, intervals[idx])
self._selected_index = self.selection.selected_live_index(intervals)
self._refresh()
def on_resize(self) -> None:
@@ -1364,6 +1422,7 @@ class FenrisTuiApp(App):
**kwargs,
) -> None:
# Load user preferences before super().__init__ so theme is available
self._activity_selection = ActivitySelection()
self._prefs = load_preferences()
self._current_theme_name = self._prefs["theme"]
self._reduced_motion = self._prefs["reduced_motion"]
@@ -1383,11 +1442,24 @@ 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
self._activity_view = "live"
self._zoomed_panel: Optional[str] = None
@property
def _browse_date(self) -> Optional[str]:
return self._activity_selection.browse_date
@_browse_date.setter
def _browse_date(self, date: Optional[str]) -> None:
self._activity_selection.select_history_date(date)
@property
def _activity_view(self) -> str:
return self._activity_selection.view
@_activity_view.setter
def _activity_view(self, view: str) -> None:
self._activity_selection.set_view(view)
def compose(self) -> ComposeResult:
yield Static(_IDENTITY_FULL, id="identity")
yield Static("Open with fenris (no sudo). Actions authenticate via polkit. ? Help", id="auth-notice")
@@ -1404,8 +1476,12 @@ class FenrisTuiApp(App):
"[@click=app.prev_day]← Day[/] [@click=app.next_day]Day →[/] "
"[@click=app.go_to_date]g Date[/] [@click=app.today]t Today[/] "
"[@click=app.toggle_measure]w W/R[/]", id="activity-tools")
yield LiveActivityGraph(id="live-activity")
yield HistoryGraph(id="usage-history")
yield LiveActivityGraph(
id="live-activity", selection=self._activity_selection,
)
yield HistoryGraph(
id="usage-history", selection=self._activity_selection,
)
with VerticalScroll(id="drive-panel", classes="dashboard-pane"):
yield Static("", id="drive-health")
with VerticalScroll(id="status-area"):
@@ -1569,7 +1645,10 @@ class FenrisTuiApp(App):
graph = self.query_one("#usage-history")
graph.set_data([], empty_message=message)
self.query_one("#live-activity").set_data(
[], empty_message=message if comp.store_fault or comp.newer_schema else "Awaiting intervals")
[],
empty_message=message if comp.store_fault or comp.newer_schema else "Awaiting intervals",
reconcile_selection=False,
)
# Hide local-day widget when no data
local_day_widget = self.query_one("#local-day")
local_day_widget.styles.display = "none"
+94
View File
@@ -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
View File
@@ -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())