diff --git a/src/fenris/activity_selection.py b/src/fenris/activity_selection.py new file mode 100644 index 0000000..7709500 --- /dev/null +++ b/src/fenris/activity_selection.py @@ -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 diff --git a/src/fenris/tui.py b/src/fenris/tui.py index 722730c..2dd0864 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -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" diff --git a/tests/test_activity_selection.py b/tests/test_activity_selection.py new file mode 100644 index 0000000..657c184 --- /dev/null +++ b/tests/test_activity_selection.py @@ -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 diff --git a/tests/test_live_graph.py b/tests/test_live_graph.py index 100f553..00815c4 100644 --- a/tests/test_live_graph.py +++ b/tests/test_live_graph.py @@ -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())