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.widget import Widget
from textual.widgets import Input, Static, Tab, Tabs 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 .activity_plot import VolumePoint, volume_plot
from .projection import ( from .projection import (
@@ -109,7 +114,7 @@ def _can_render_wolf(width: int) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_RANGE_OPTIONS = (7, 14, 30, 90) _RANGE_OPTIONS = (7, 14, 30, 90)
_RANGE_DEFAULT = 14 _RANGE_DEFAULT = HISTORY_RANGE_DEFAULT
# Terminal size thresholds for constrained layout (issue #81) # Terminal size thresholds for constrained layout (issue #81)
_MIN_WIDTH = 80 _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) super().__init__(**kwargs)
self.range_days: int = _RANGE_DEFAULT self.selection = selection or ActivitySelection()
self.selected_index: int = -1 self.selected_index: int = -1
self.view_mode: str = "daily" self.view_mode: str = "daily"
self.drill_day: Optional[str] = None self.drill_day: Optional[str] = None
@@ -170,10 +177,27 @@ class HistoryGraph(Widget):
self._hourly_selected: int = -1 self._hourly_selected: int = -1
self._drill_unallocated_bytes: int = 0 self._drill_unallocated_bytes: int = 0
self._on_drill: Optional[Callable[[str], None]] = None self._on_drill: Optional[Callable[[str], None]] = None
self.measure = "written"
self._point_columns: list[int] = [] self._point_columns: list[int] = []
self._empty_message = "Awaiting first sample" 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: def compose(self) -> ComposeResult:
yield Static("", id="bar-range") yield Static("", id="bar-range")
yield Static("", id="bar-render") yield Static("", id="bar-render")
@@ -189,7 +213,7 @@ class HistoryGraph(Widget):
) -> None: ) -> None:
"""Update graph with day data. on_drill(day) called on drill entry.""" """Update graph with day data. on_drill(day) called on drill entry."""
self._empty_message = empty_message 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): if selected_day is None and 0 <= self.selected_index < len(self._day_data):
selected_day = self._day_data[self.selected_index].get("day") selected_day = self._day_data[self.selected_index].get("day")
self._all_day_data = day_data self._all_day_data = day_data
@@ -206,15 +230,18 @@ class HistoryGraph(Widget):
None, None,
) )
self.selected_index = matching if matching is not None else len(self._day_data) - 1 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: else:
self.selected_index = -1 self.selected_index = -1
self._refresh() self._refresh()
def set_hour_data(self, hour_data: List[Dict[str, Any]]) -> None: def set_hour_data(self, hour_data: List[Dict[str, Any]]) -> None:
"""Set hourly data for drill-down view.""" """Set hourly data for drill-down view."""
selected_hour = None selected_hour = self.selection.selected_history_hour
if 0 <= self._hourly_selected < len(self._hour_data): selected_hour = selected_hour or self._selected_history_hour()
selected_hour = self._hour_data[self._hourly_selected].get("hour")
self._hour_data = hour_data self._hour_data = hour_data
matching = next( matching = next(
(i for i, item in enumerate(hour_data) if item.get("hour") == selected_hour), (i for i, item in enumerate(hour_data) if item.get("hour") == selected_hour),
@@ -225,6 +252,7 @@ class HistoryGraph(Widget):
else: else:
selectable = [i for i, item in enumerate(hour_data) if not item.get("is_future")] 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._hourly_selected = selectable[-1] if selectable else -1
self.selection.select_history_hour(self._selected_history_hour())
self._refresh_hourly() self._refresh_hourly()
def _trim_to_range(self) -> None: def _trim_to_range(self) -> None:
@@ -517,13 +545,17 @@ class HistoryGraph(Widget):
def _notify_selection(self) -> None: def _notify_selection(self) -> None:
if 0 <= self.selected_index < len(self._day_data): 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: def _enter_drill(self) -> None:
if self.selected_index < 0 or self.selected_index >= len(self._day_data): if self.selected_index < 0 or self.selected_index >= len(self._day_data):
return return
day = self._day_data[self.selected_index] day = self._day_data[self.selected_index]
self.drill_day = day.get("day") 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._drill_unallocated_bytes = day.get("unallocated_bytes", 0)
self.view_mode = "hourly" self.view_mode = "hourly"
self._hourly_selected = -1 self._hourly_selected = -1
@@ -536,6 +568,7 @@ class HistoryGraph(Widget):
self.drill_day = None self.drill_day = None
self._hour_data = [] self._hour_data = []
self._hourly_selected = -1 self._hourly_selected = -1
self.selection.select_history_hour(None)
self._refresh() self._refresh()
def _handle_hourly_key(self, event: Any) -> None: def _handle_hourly_key(self, event: Any) -> None:
@@ -551,6 +584,7 @@ class HistoryGraph(Widget):
self._hourly_selected = len(self._hour_data) - 1 self._hourly_selected = len(self._hour_data) - 1
else: else:
self._hourly_selected = max(0, self._hourly_selected - 1) self._hourly_selected = max(0, self._hourly_selected - 1)
self._select_history_hour()
self._refresh_hourly() self._refresh_hourly()
event.stop() event.stop()
elif event.key == "right": elif event.key == "right":
@@ -560,9 +594,18 @@ class HistoryGraph(Widget):
self._hourly_selected = min( self._hourly_selected = min(
len(self._hour_data) - 1, self._hourly_selected + 1 len(self._hour_data) - 1, self._hourly_selected + 1
) )
self._select_history_hour()
self._refresh_hourly() self._refresh_hourly()
event.stop() 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: def navigate_to_date(self, date_str: str) -> bool:
"""Navigate to a specific date, adjusting range if needed. """Navigate to a specific date, adjusting range if needed.
@@ -594,6 +637,7 @@ class HistoryGraph(Widget):
idx, idx,
) )
new_day = self._day_data[self.selected_index].get("day") new_day = self._day_data[self.selected_index].get("day")
self.selection.select_history_date(new_day)
return new_day != old_day return new_day != old_day
def on_click(self, event: Any) -> None: def on_click(self, event: Any) -> None:
@@ -608,12 +652,16 @@ class HistoryGraph(Widget):
global_idx = idx global_idx = idx
if 0 <= global_idx < len(self._day_data): if 0 <= global_idx < len(self._day_data):
self.selected_index = global_idx self.selected_index = global_idx
self.selection.select_history_date(
self._day_data[global_idx].get("day")
)
self._refresh() self._refresh()
self._notify_selection() self._notify_selection()
else: else:
global_idx = idx global_idx = idx
if 0 <= global_idx < len(self._hour_data): if 0 <= global_idx < len(self._hour_data):
self._hourly_selected = global_idx self._hourly_selected = global_idx
self._select_history_hour()
self._refresh_hourly() 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) super().__init__(**kwargs)
self.selection = selection or ActivitySelection()
self._data: List[Dict[str, Any]] = [] self._data: List[Dict[str, Any]] = []
self._selected_index: int = -1 self._selected_index: int = -1
self._measure: str = "written" # "written" or "read"
self._point_columns: list[int] = [] self._point_columns: list[int] = []
self._empty_message = "Awaiting intervals" self._empty_message = "Awaiting intervals"
@@ -936,32 +986,36 @@ class LiveActivityGraph(Widget):
@property @property
def measure(self) -> str: def measure(self) -> str:
return self._measure return self.selection.measure
def toggle_measure(self) -> None: def toggle_measure(self) -> None:
"""Toggle between written and read volumes.""" """Toggle between written and read volumes."""
self._measure = "read" if self._measure == "written" else "written" self.selection.toggle_measure()
self._refresh() 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.""" """Update graph with live interval data."""
self._empty_message = empty_message 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 self._data = data
if prev_selected_end is not None: intervals = self._interval_ids()
self._selected_index = next( if reconcile_selection:
(i for i, d in enumerate(data) if d.get("end_ts") == prev_selected_end), self.selection.update_live(intervals)
len(data) - 1 if data else -1, self._selected_index = self.selection.selected_live_index(intervals)
)
else:
self._selected_index = len(data) - 1 if data else -1
self._refresh() 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: 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_written", 0)
return item.get("bytes_read", 0) return item.get("bytes_read", 0)
@@ -970,13 +1024,15 @@ class LiveActivityGraph(Widget):
if not self._data: if not self._data:
self.query_one("#live-render").update(Text(self._empty_message)) self.query_one("#live-render").update(Text(self._empty_message))
self.query_one("#live-legend").update("") 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 return
self._render_bars() self._render_bars()
self._render_readout() self._render_readout()
def _render_header(self) -> None: 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) n = len(self._data)
self.query_one("#live-header").update( self.query_one("#live-header").update(
"%s · last %dh · %d intervals" "%s · last %dh · %d intervals"
@@ -1009,18 +1065,21 @@ class LiveActivityGraph(Widget):
def _render_readout(self) -> None: def _render_readout(self) -> None:
if self._selected_index < 0 or self._selected_index >= len(self._data): 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 return
item = self._data[self._selected_index] item = self._data[self._selected_index]
if item.get("is_segment_boundary"): if item.get("is_segment_boundary"):
self.query_one("#live-readout").update( 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", ""))) item.get("start_label", ""), item.get("end_label", "")))
return return
bw = item.get("bytes_written", 0) bw = item.get("bytes_written", 0)
br = item.get("bytes_read", 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") scale, unit = _unit_scale(measure_val) if measure_val > 0 else (1e6, "MB")
state_parts = [] state_parts = []
@@ -1033,7 +1092,8 @@ class LiveActivityGraph(Widget):
state = " · " + ", ".join(state_parts) if state_parts else "" state = " · " + ", ".join(state_parts) if state_parts else ""
self.query_one("#live-readout").update( 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("start_label", ""),
item.get("end_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: def on_key(self, event: Any) -> None:
if event.key == "left": offset = -1 if event.key == "left" else 1 if event.key == "right" else 0
if self._selected_index < 0: if offset:
self._selected_index = len(self._data) - 1 intervals = self._interval_ids()
else: self.selection.move_live(intervals, offset)
self._selected_index = max(0, self._selected_index - 1) self._selected_index = self.selection.selected_live_index(intervals)
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
)
self._refresh() self._refresh()
event.stop() event.stop()
@@ -1070,7 +1126,9 @@ class LiveActivityGraph(Widget):
offset_x = event.screen_x - render.content_region.x 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)) idx = min(range(len(self._point_columns)), key=lambda i: abs(self._point_columns[i] - offset_x))
if 0 <= idx < len(self._data): 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() self._refresh()
def on_resize(self) -> None: def on_resize(self) -> None:
@@ -1364,6 +1422,7 @@ class FenrisTuiApp(App):
**kwargs, **kwargs,
) -> None: ) -> None:
# Load user preferences before super().__init__ so theme is available # Load user preferences before super().__init__ so theme is available
self._activity_selection = ActivitySelection()
self._prefs = load_preferences() self._prefs = load_preferences()
self._current_theme_name = self._prefs["theme"] self._current_theme_name = self._prefs["theme"]
self._reduced_motion = self._prefs["reduced_motion"] self._reduced_motion = self._prefs["reduced_motion"]
@@ -1383,11 +1442,24 @@ class FenrisTuiApp(App):
# Track constrained state for resize handling (issue #81) # Track constrained state for resize handling (issue #81)
self._is_constrained_mode = False 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 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: def compose(self) -> ComposeResult:
yield Static(_IDENTITY_FULL, id="identity") yield Static(_IDENTITY_FULL, id="identity")
yield Static("Open with fenris (no sudo). Actions authenticate via polkit. ? Help", id="auth-notice") 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.prev_day]← Day[/] [@click=app.next_day]Day →[/] "
"[@click=app.go_to_date]g Date[/] [@click=app.today]t Today[/] " "[@click=app.go_to_date]g Date[/] [@click=app.today]t Today[/] "
"[@click=app.toggle_measure]w W/R[/]", id="activity-tools") "[@click=app.toggle_measure]w W/R[/]", id="activity-tools")
yield LiveActivityGraph(id="live-activity") yield LiveActivityGraph(
yield HistoryGraph(id="usage-history") id="live-activity", selection=self._activity_selection,
)
yield HistoryGraph(
id="usage-history", selection=self._activity_selection,
)
with VerticalScroll(id="drive-panel", classes="dashboard-pane"): with VerticalScroll(id="drive-panel", classes="dashboard-pane"):
yield Static("", id="drive-health") yield Static("", id="drive-health")
with VerticalScroll(id="status-area"): with VerticalScroll(id="status-area"):
@@ -1569,7 +1645,10 @@ class FenrisTuiApp(App):
graph = self.query_one("#usage-history") graph = self.query_one("#usage-history")
graph.set_data([], empty_message=message) graph.set_data([], empty_message=message)
self.query_one("#live-activity").set_data( 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 # Hide local-day widget when no data
local_day_widget = self.query_one("#local-day") local_day_widget = self.query_one("#local-day")
local_day_widget.styles.display = "none" 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 - Three-minute cadence constants
- TUI integration with live graph - TUI integration with live graph
""" """
import sys
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
import pytest import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src")) 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.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 ( from fenris.tui import (
LIVE_WINDOW_H,
FenrisTuiApp, FenrisTuiApp,
LiveActivityGraph, LiveActivityGraph,
_query_live_graph_data, _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 # Helpers
@@ -63,6 +61,27 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start)) 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) # Cadence constants (issue #91 AC1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -322,3 +341,85 @@ class TestTUILiveIntegration:
await pilot.pause() await pilot.pause()
main_grid = app.query_one("#main-grid") main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained") 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())