fix: follow live activity until inspection (#102)
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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