Publish consistent measured drive activity (#89)
Repair the collection-to-display path so each successful acquisition publishes correct read/write deltas through the observation store and visible dashboard. Fixes: - derive.py: accumulate bytes_read_delta on same-hour hour_observation merge (was silently dropped) - collector.py: rebuild day_aggregates from hour observations after each collection run (previously only populated for cross-hour intervals) - day_aggregate.py: add persist_day_aggregate upsert helper - tui.py: query and display both read and write deltas in daily and hourly readouts, constrained summaries, and graph data queries Tests: - Add 14 integration tests (test_measured_activity.py) exercising the full collector→store→reader→display path with real fixtures and injected time - Update constrained-layout assertion to match new W/R format Closes #89
This commit is contained in:
@@ -340,6 +340,12 @@ def run_collection(
|
|||||||
"data_units_read": sample["data_units_read"],
|
"data_units_read": sample["data_units_read"],
|
||||||
}
|
}
|
||||||
derive_hours_from_interval(conn, prev, current)
|
derive_hours_from_interval(conn, prev, current)
|
||||||
|
|
||||||
|
# Rebuild day aggregates from hour observations
|
||||||
|
from .day_aggregate import derive_all_days, persist_day_aggregate
|
||||||
|
for agg in derive_all_days(conn):
|
||||||
|
persist_day_aggregate(conn, agg)
|
||||||
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -163,3 +163,41 @@ def derive_all_days(conn: sqlite3.Connection) -> list[DayAggregate]:
|
|||||||
if agg is not None:
|
if agg is not None:
|
||||||
results.append(agg)
|
results.append(agg)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def persist_day_aggregate(conn: sqlite3.Connection, agg: DayAggregate) -> None:
|
||||||
|
"""Upsert a derived day aggregate into the day_aggregates table.
|
||||||
|
|
||||||
|
Merges attributed bytes from hour observations with any existing
|
||||||
|
unattributed cross-hour evidence already stored for this day.
|
||||||
|
Caller must manage transactions and commits.
|
||||||
|
"""
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM day_aggregates WHERE day = ?",
|
||||||
|
(agg.day,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates "
|
||||||
|
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||||
|
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(agg.day, agg.seconds_active, agg.seconds_idle,
|
||||||
|
agg.seconds_powered_off, agg.seconds_unknown,
|
||||||
|
agg.bytes_written_delta, agg.bytes_read_delta,
|
||||||
|
agg.sample_count, agg.coverage),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE day_aggregates "
|
||||||
|
"SET active_seconds = ?, idle_seconds = ?, powered_off_seconds = ?, "
|
||||||
|
" unknown_seconds = ?, bytes_written_delta = ?, bytes_read_delta = ?, "
|
||||||
|
" sample_count = ?, coverage = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(agg.seconds_active, agg.seconds_idle,
|
||||||
|
agg.seconds_powered_off, agg.seconds_unknown,
|
||||||
|
agg.bytes_written_delta, agg.bytes_read_delta,
|
||||||
|
agg.sample_count, agg.coverage,
|
||||||
|
existing[0]),
|
||||||
|
)
|
||||||
|
|||||||
@@ -175,7 +175,8 @@ def _upsert_hour_observation(
|
|||||||
"""Insert or update an hour observation."""
|
"""Insert or update an hour observation."""
|
||||||
# Check if hour exists
|
# Check if hour exists
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?",
|
"SELECT id, bytes_written_delta, bytes_read_delta, sample_count "
|
||||||
|
"FROM hour_observations WHERE hour = ?",
|
||||||
(hour_key,),
|
(hour_key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
@@ -198,10 +199,13 @@ def _upsert_hour_observation(
|
|||||||
else:
|
else:
|
||||||
# Merge: accumulate bytes and sample count
|
# Merge: accumulate bytes and sample count
|
||||||
new_bw = existing[1] + bw_delta
|
new_bw = existing[1] + bw_delta
|
||||||
new_samples = existing[2] + sample_count
|
new_br = existing[2] + br_delta
|
||||||
|
new_samples = existing[3] + sample_count
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
|
"UPDATE hour_observations "
|
||||||
(new_bw, new_samples, existing[0]),
|
"SET bytes_written_delta = ?, bytes_read_delta = ?, sample_count = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(new_bw, new_br, new_samples, existing[0]),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|||||||
+88
-43
@@ -285,15 +285,16 @@ class DailyBarGraph(Widget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Summarise visible days as text
|
# Summarise visible days as text
|
||||||
total_bytes = sum(d.get("total_bytes", 0) for d in self._day_data)
|
total_written = sum(d.get("total_written", d.get("total_bytes", 0)) for d in self._day_data)
|
||||||
days_with_data = sum(1 for d in self._day_data if d.get("total_bytes", 0) > 0)
|
total_read = sum(d.get("total_read", 0) for d in self._day_data)
|
||||||
|
days_with_data = sum(1 for d in self._day_data if d.get("total_bytes", 0) > 0 or d.get("total_read", 0) > 0)
|
||||||
n = len(self._day_data)
|
n = len(self._day_data)
|
||||||
first = self._day_data[0].get("local_label", "")
|
first = self._day_data[0].get("local_label", "")
|
||||||
last = self._day_data[-1].get("local_label", "")
|
last = self._day_data[-1].get("local_label", "")
|
||||||
self.query_one("#bar-render").update(
|
self.query_one("#bar-render").update(
|
||||||
"[dim]Graph needs ≥80×24[/dim]\n"
|
"[dim]Graph needs ≥80×24[/dim]\n"
|
||||||
" %d days · %d with writes · %.3f GB total\n"
|
" %d days · %d with activity · W %.3f GB R %.3f GB\n"
|
||||||
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
" %s → %s" % (n, days_with_data, total_written / 1e9, total_read / 1e9, first, last)
|
||||||
)
|
)
|
||||||
self.query_one("#bar-legend").update("")
|
self.query_one("#bar-legend").update("")
|
||||||
|
|
||||||
@@ -301,30 +302,41 @@ class DailyBarGraph(Widget):
|
|||||||
if 0 <= self.selected_index < len(self._day_data):
|
if 0 <= self.selected_index < len(self._day_data):
|
||||||
day = self._day_data[self.selected_index]
|
day = self._day_data[self.selected_index]
|
||||||
self.query_one("#bar-readout").update(
|
self.query_one("#bar-readout").update(
|
||||||
"[bold]%s[/bold] · %.3f GB"
|
"[bold]%s[/bold] \u00b7 W %.3f GB \u00b7 R %.3f GB"
|
||||||
% (day.get("local_label", ""), day.get("total_bytes", 0) / 1e9)
|
% (
|
||||||
|
day.get("local_label", ""),
|
||||||
|
day.get("total_written", day.get("total_bytes", 0)) / 1e9,
|
||||||
|
day.get("total_read", 0) / 1e9,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.query_one("#bar-readout").update("[dim]No selectable day[/dim]")
|
self.query_one("#bar-readout").update("[dim]No selectable day[/dim]")
|
||||||
|
|
||||||
def _show_constrained_hourly_summary(self) -> None:
|
def _show_constrained_hourly_summary(self) -> None:
|
||||||
"""Textual fallback for hourly view when terminal is too small."""
|
"""Textual fallback for hourly view when terminal is too small."""
|
||||||
total_bytes = sum(h.get("bytes_written", 0) for h in self._hour_data)
|
total_written = sum(h.get("bytes_written", 0) for h in self._hour_data)
|
||||||
|
total_read = sum(h.get("bytes_read", 0) for h in self._hour_data)
|
||||||
hours_with_data = sum(
|
hours_with_data = sum(
|
||||||
1 for h in self._hour_data if h.get("bytes_written", 0) > 0
|
1 for h in self._hour_data
|
||||||
|
if h.get("bytes_written", 0) > 0 or h.get("bytes_read", 0) > 0
|
||||||
)
|
)
|
||||||
n = len(self._hour_data)
|
n = len(self._hour_data)
|
||||||
self.query_one("#bar-render").update(
|
self.query_one("#bar-render").update(
|
||||||
"[dim]Graph needs ≥80×24[/dim]\n"
|
"[dim]Graph needs ≥80×24[/dim]\n"
|
||||||
" %d hours · %d with writes · %.3f GB total" % (n, hours_with_data, total_bytes / 1e9)
|
" %d hours · %d with activity · W %.3f GB R %.3f GB"
|
||||||
|
% (n, hours_with_data, total_written / 1e9, total_read / 1e9)
|
||||||
)
|
)
|
||||||
self.query_one("#bar-legend").update("")
|
self.query_one("#bar-legend").update("")
|
||||||
|
|
||||||
if 0 <= self._hourly_selected < len(self._hour_data):
|
if 0 <= self._hourly_selected < len(self._hour_data):
|
||||||
h = self._hour_data[self._hourly_selected]
|
h = self._hour_data[self._hourly_selected]
|
||||||
self.query_one("#bar-readout").update(
|
self.query_one("#bar-readout").update(
|
||||||
"[bold]%s[/bold] \u00b7 %.3f GB"
|
"[bold]%s[/bold] \u00b7 W %.3f GB \u00b7 R %.3f GB"
|
||||||
% (h.get("local_label", ""), h.get("bytes_written", 0) / 1e9)
|
% (
|
||||||
|
h.get("local_label", ""),
|
||||||
|
h.get("bytes_written", 0) / 1e9,
|
||||||
|
h.get("bytes_read", 0) / 1e9,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select hour[/dim]")
|
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select hour[/dim]")
|
||||||
@@ -461,31 +473,42 @@ class DailyBarGraph(Widget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
day = self._day_data[self.selected_index]
|
day = self._day_data[self.selected_index]
|
||||||
total = day.get("total_bytes", 0)
|
total_written = day.get("total_written", day.get("total_bytes", 0))
|
||||||
allocated = day.get("allocated_bytes", 0)
|
total_read = day.get("total_read", 0)
|
||||||
unallocated = day.get("unallocated_bytes", 0)
|
allocated_w = day.get("allocated_bytes", 0)
|
||||||
|
unallocated_w = day.get("unallocated_bytes", 0)
|
||||||
|
allocated_r = day.get("allocated_read", 0)
|
||||||
|
unallocated_r = day.get("unallocated_read", 0)
|
||||||
coverage = day.get("coverage", 0)
|
coverage = day.get("coverage", 0)
|
||||||
hours = day.get("evidenced_hours", 0)
|
hours = day.get("evidenced_hours", 0)
|
||||||
|
|
||||||
total_gb = total / 1e9
|
|
||||||
state = " · partial" if day.get("is_partial") else (
|
state = " · partial" if day.get("is_partial") else (
|
||||||
" · gap" if day.get("is_gap") else ""
|
" · gap" if day.get("is_gap") else ""
|
||||||
)
|
)
|
||||||
parts = [
|
parts = [
|
||||||
"[bold]%s UTC[/bold] \u00b7 %.3f GB total \u00b7 %d hours \u00b7 %.0f%% coverage%s"
|
"[bold]%s UTC[/bold] \u00b7 W %.3f GB \u00b7 R %.3f GB \u00b7 %d hours \u00b7 %.0f%% coverage%s"
|
||||||
% (
|
% (
|
||||||
day.get("local_label", day.get("day", "")),
|
day.get("local_label", day.get("day", "")),
|
||||||
total_gb,
|
total_written / 1e9,
|
||||||
|
total_read / 1e9,
|
||||||
hours,
|
hours,
|
||||||
coverage * 100,
|
coverage * 100,
|
||||||
state,
|
state,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
if unallocated > 0:
|
alloc_parts = []
|
||||||
parts.append(
|
if unallocated_w > 0:
|
||||||
" Allocated %.3f GB \u00b7 unallocated %.3f GB"
|
alloc_parts.append(
|
||||||
% (allocated / 1e9, unallocated / 1e9)
|
"W alloc %.3f GB \u00b7 unalloc %.3f GB"
|
||||||
|
% (allocated_w / 1e9, unallocated_w / 1e9)
|
||||||
)
|
)
|
||||||
|
if unallocated_r > 0:
|
||||||
|
alloc_parts.append(
|
||||||
|
"R alloc %.3f GB \u00b7 unalloc %.3f GB"
|
||||||
|
% (allocated_r / 1e9, unallocated_r / 1e9)
|
||||||
|
)
|
||||||
|
if alloc_parts:
|
||||||
|
parts.append(" " + " \u00b7 ".join(alloc_parts))
|
||||||
self.query_one("#bar-readout").update("\n".join(parts))
|
self.query_one("#bar-readout").update("\n".join(parts))
|
||||||
|
|
||||||
# -- Hourly drill-down rendering --
|
# -- Hourly drill-down rendering --
|
||||||
@@ -580,10 +603,11 @@ class DailyBarGraph(Widget):
|
|||||||
self._drill_unallocated_bytes / 1e9,
|
self._drill_unallocated_bytes / 1e9,
|
||||||
)
|
)
|
||||||
self.query_one("#bar-readout").update(
|
self.query_one("#bar-readout").update(
|
||||||
"[bold]%s:00 UTC[/bold] \u00b7 %.3f GB \u00b7 %d%% coverage%s%s"
|
"[bold]%s:00 UTC[/bold] \u00b7 W %.3f GB \u00b7 R %.3f GB \u00b7 %d%% coverage%s%s"
|
||||||
% (
|
% (
|
||||||
h.get("local_label", h.get("hour", "")),
|
h.get("local_label", h.get("hour", "")),
|
||||||
h.get("bytes_written", 0) / 1e9,
|
h.get("bytes_written", 0) / 1e9,
|
||||||
|
h.get("bytes_read", 0) / 1e9,
|
||||||
h.get("coverage", 0) * 100,
|
h.get("coverage", 0) * 100,
|
||||||
state,
|
state,
|
||||||
note,
|
note,
|
||||||
@@ -708,7 +732,8 @@ def _query_daily_graph_data(
|
|||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT day, bytes_written_delta, unattributed_bytes_written, "
|
"SELECT day, bytes_written_delta, unattributed_bytes_written, "
|
||||||
"coverage, sample_count, active_seconds, idle_seconds, "
|
"coverage, sample_count, active_seconds, idle_seconds, "
|
||||||
"powered_off_seconds, unknown_seconds "
|
"powered_off_seconds, unknown_seconds, "
|
||||||
|
"bytes_read_delta, unattributed_bytes_read "
|
||||||
"FROM day_aggregates ORDER BY day"
|
"FROM day_aggregates ORDER BY day"
|
||||||
)
|
)
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
@@ -717,18 +742,21 @@ def _query_daily_graph_data(
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
day = row[0]
|
day = row[0]
|
||||||
bw_delta = row[1] or 0
|
bw_delta = row[1] or 0
|
||||||
unattributed = row[2] or 0
|
unattributed_w = row[2] or 0
|
||||||
coverage = row[3] or 0.0
|
coverage = row[3] or 0.0
|
||||||
sample_count = row[4] or 0
|
sample_count = row[4] or 0
|
||||||
active = row[5] or 0
|
active = row[5] or 0
|
||||||
idle = row[6] or 0
|
idle = row[6] or 0
|
||||||
powered_off = row[7] or 0
|
powered_off = row[7] or 0
|
||||||
unknown = row[8] or 0
|
unknown = row[8] or 0
|
||||||
|
br_delta = row[9] or 0
|
||||||
|
unattributed_r = row[10] or 0
|
||||||
|
|
||||||
total_bytes = bw_delta + unattributed
|
total_written = bw_delta + unattributed_w
|
||||||
|
total_read = br_delta + unattributed_r
|
||||||
evidenced_hours = (active + idle + powered_off) // 3600
|
evidenced_hours = (active + idle + powered_off) // 3600
|
||||||
|
|
||||||
is_zero = total_bytes == 0
|
is_zero = total_written == 0 and total_read == 0
|
||||||
is_gap = (
|
is_gap = (
|
||||||
sample_count == 0
|
sample_count == 0
|
||||||
and (active + idle + powered_off) == 0
|
and (active + idle + powered_off) == 0
|
||||||
@@ -739,9 +767,13 @@ def _query_daily_graph_data(
|
|||||||
by_day[day] = {
|
by_day[day] = {
|
||||||
"day": day,
|
"day": day,
|
||||||
"local_label": day,
|
"local_label": day,
|
||||||
"total_bytes": total_bytes,
|
"total_bytes": total_written,
|
||||||
|
"total_written": total_written,
|
||||||
|
"total_read": total_read,
|
||||||
"allocated_bytes": bw_delta,
|
"allocated_bytes": bw_delta,
|
||||||
"unallocated_bytes": unattributed,
|
"unallocated_bytes": unattributed_w,
|
||||||
|
"allocated_read": br_delta,
|
||||||
|
"unallocated_read": unattributed_r,
|
||||||
"coverage": coverage,
|
"coverage": coverage,
|
||||||
"evidenced_hours": evidenced_hours,
|
"evidenced_hours": evidenced_hours,
|
||||||
"sample_count": sample_count,
|
"sample_count": sample_count,
|
||||||
@@ -761,8 +793,12 @@ def _query_daily_graph_data(
|
|||||||
"day": day,
|
"day": day,
|
||||||
"local_label": day,
|
"local_label": day,
|
||||||
"total_bytes": 0,
|
"total_bytes": 0,
|
||||||
|
"total_written": 0,
|
||||||
|
"total_read": 0,
|
||||||
"allocated_bytes": 0,
|
"allocated_bytes": 0,
|
||||||
"unallocated_bytes": 0,
|
"unallocated_bytes": 0,
|
||||||
|
"allocated_read": 0,
|
||||||
|
"unallocated_read": 0,
|
||||||
"coverage": 0.0,
|
"coverage": 0.0,
|
||||||
"evidenced_hours": 0,
|
"evidenced_hours": 0,
|
||||||
"sample_count": 0,
|
"sample_count": 0,
|
||||||
@@ -785,10 +821,10 @@ def _query_hourly_graph_data(
|
|||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Query hour observations for a specific day.
|
"""Query hour observations for a specific day.
|
||||||
|
|
||||||
Returns one dict per hour with bytes written, coverage, and flags.
|
Returns one dict per hour with bytes written/read, coverage, and flags.
|
||||||
"""
|
"""
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT hour, bytes_written_delta, coverage, sample_count, "
|
"SELECT hour, bytes_written_delta, bytes_read_delta, coverage, sample_count, "
|
||||||
"active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
|
"active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
|
||||||
"FROM hour_observations "
|
"FROM hour_observations "
|
||||||
"WHERE hour LIKE ? ORDER BY hour",
|
"WHERE hour LIKE ? ORDER BY hour",
|
||||||
@@ -800,14 +836,15 @@ def _query_hourly_graph_data(
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
hour = row[0]
|
hour = row[0]
|
||||||
bw = row[1] or 0
|
bw = row[1] or 0
|
||||||
coverage = row[2] or 0.0
|
br = row[2] or 0
|
||||||
sample_count = row[3] or 0
|
coverage = row[3] or 0.0
|
||||||
active = row[4] or 0
|
sample_count = row[4] or 0
|
||||||
idle = row[5] or 0
|
active = row[5] or 0
|
||||||
powered_off = row[6] or 0
|
idle = row[6] or 0
|
||||||
unknown = row[7] or 0
|
powered_off = row[7] or 0
|
||||||
|
unknown = row[8] or 0
|
||||||
|
|
||||||
is_zero = bw == 0
|
is_zero = bw == 0 and br == 0
|
||||||
local_label = hour[11:13] if len(hour) >= 13 else hour
|
local_label = hour[11:13] if len(hour) >= 13 else hour
|
||||||
|
|
||||||
hour_number = int(local_label)
|
hour_number = int(local_label)
|
||||||
@@ -815,6 +852,7 @@ def _query_hourly_graph_data(
|
|||||||
"hour": hour,
|
"hour": hour,
|
||||||
"local_label": local_label,
|
"local_label": local_label,
|
||||||
"bytes_written": bw,
|
"bytes_written": bw,
|
||||||
|
"bytes_read": br,
|
||||||
"coverage": coverage,
|
"coverage": coverage,
|
||||||
"sample_count": sample_count,
|
"sample_count": sample_count,
|
||||||
"active_seconds": active,
|
"active_seconds": active,
|
||||||
@@ -841,6 +879,7 @@ def _query_hourly_graph_data(
|
|||||||
"hour": hour_start.isoformat(),
|
"hour": hour_start.isoformat(),
|
||||||
"local_label": "%02d" % hour_number,
|
"local_label": "%02d" % hour_number,
|
||||||
"bytes_written": 0,
|
"bytes_written": 0,
|
||||||
|
"bytes_read": 0,
|
||||||
"coverage": 0.0,
|
"coverage": 0.0,
|
||||||
"sample_count": 0,
|
"sample_count": 0,
|
||||||
"active_seconds": 0,
|
"active_seconds": 0,
|
||||||
@@ -1135,22 +1174,28 @@ class FenrisTuiApp(App):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Summarise visible days as text (issue #81 AC2)
|
# Summarise visible days as text (issue #81 AC2)
|
||||||
total_bytes = sum(d.get("total_bytes", 0) for d in graph._day_data)
|
total_written = sum(d.get("total_written", d.get("total_bytes", 0)) for d in graph._day_data)
|
||||||
days_with_data = sum(1 for d in graph._day_data if d.get("total_bytes", 0) > 0)
|
total_read = sum(d.get("total_read", 0) for d in graph._day_data)
|
||||||
|
days_with_data = sum(
|
||||||
|
1 for d in graph._day_data
|
||||||
|
if d.get("total_bytes", 0) > 0 or d.get("total_read", 0) > 0
|
||||||
|
)
|
||||||
n = len(graph._day_data)
|
n = len(graph._day_data)
|
||||||
first = graph._day_data[0].get("local_label", "")
|
first = graph._day_data[0].get("local_label", "")
|
||||||
last = graph._day_data[-1].get("local_label", "")
|
last = graph._day_data[-1].get("local_label", "")
|
||||||
text = (
|
text = (
|
||||||
"[dim]Graph needs ≥80×24[/dim]\n"
|
"[dim]Graph needs ≥80×24[/dim]\n"
|
||||||
" %d days · %d with writes · %.3f GB total\n"
|
" %d days · %d with activity · W %.3f GB R %.3f GB\n"
|
||||||
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
" %s → %s" % (n, days_with_data, total_written / 1e9, total_read / 1e9, first, last)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Preserve selected-day context (issue #81 AC2)
|
# Preserve selected-day context (issue #81 AC2)
|
||||||
if 0 <= graph.selected_index < len(graph._day_data):
|
if 0 <= graph.selected_index < len(graph._day_data):
|
||||||
day = graph._day_data[graph.selected_index]
|
day = graph._day_data[graph.selected_index]
|
||||||
text += "\n [bold]%s[/bold] · %.3f GB" % (
|
text += "\n [bold]%s[/bold] \u00b7 W %.3f GB \u00b7 R %.3f GB" % (
|
||||||
day.get("local_label", ""), day.get("total_bytes", 0) / 1e9
|
day.get("local_label", ""),
|
||||||
|
day.get("total_written", day.get("total_bytes", 0)) / 1e9,
|
||||||
|
day.get("total_read", 0) / 1e9,
|
||||||
)
|
)
|
||||||
|
|
||||||
summary.update(text)
|
summary.update(text)
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
"""Measured drive activity integration tests (issue #89).
|
||||||
|
|
||||||
|
Drives successive controlled acquisition readings through the public
|
||||||
|
collector into a real temporary observation store, then verifies the
|
||||||
|
normal reader and visible dashboard report correct read/write deltas,
|
||||||
|
hour/day evidence, accumulation, and boundary behaviour.
|
||||||
|
|
||||||
|
Seams:
|
||||||
|
- write side: run_collection() → observation store
|
||||||
|
- read side: _query_daily_graph_data(), _query_hourly_graph_data(),
|
||||||
|
derive_day(), get_status() → observation store
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.day_aggregate import derive_day
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
from fenris.tui import _query_daily_graph_data, _query_hourly_graph_data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]:
|
||||||
|
"""Build a smartctl fixture with specific DUW/DUR counters."""
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155",
|
||||||
|
"build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw,
|
||||||
|
"data_units_read": dur, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_tree(tmp_path: Path) -> Path:
|
||||||
|
"""Create a minimal sysfs fixture tree with controller identity."""
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text(
|
||||||
|
"nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n"
|
||||||
|
)
|
||||||
|
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||||
|
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "address").write_text("0000:03:00.0")
|
||||||
|
(transport_dir / "trstring").write_text("pcie")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
"""Injected clock returning controlled time."""
|
||||||
|
def __init__(self, initial: datetime):
|
||||||
|
self.now = initial
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC1: Two successive readings produce correct read/write deltas
|
||||||
|
# visible through both the hour observations and the TUI query path.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSuccessiveReadings:
|
||||||
|
"""First reading is an anchor; second yields a measured interval."""
|
||||||
|
|
||||||
|
def test_two_readings_produce_both_rw_deltas(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
# DUW=10000000, DUR=8000000
|
||||||
|
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
assert r1["ok"]
|
||||||
|
# +50 DUW, +30 DUR
|
||||||
|
r2 = run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
# Hour observation should have both read and write deltas
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour is not None
|
||||||
|
assert hour[0] == 50 * 512000 # writes
|
||||||
|
assert hour[1] == 30 * 512000 # reads
|
||||||
|
|
||||||
|
# TUI graph query should report both
|
||||||
|
daily = _query_daily_graph_data(conn)
|
||||||
|
assert len(daily) >= 1
|
||||||
|
day_entry = daily[-1]
|
||||||
|
bw_expected = 50 * 512000
|
||||||
|
br_expected = 30 * 512000
|
||||||
|
assert day_entry["total_written"] == bw_expected
|
||||||
|
assert day_entry["total_read"] == br_expected
|
||||||
|
assert day_entry["allocated_bytes"] == bw_expected
|
||||||
|
assert day_entry["allocated_read"] == br_expected
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_first_reading_is_anchor_no_delta(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
"""First sample alone produces no hour observation or day aggregate."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM hour_observations").fetchone()[0] == 0
|
||||||
|
daily = _query_daily_graph_data(conn)
|
||||||
|
assert all(d["is_zero"] or d["is_gap"] for d in daily)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC3: Repeated same-hour collections accumulate both reads and writes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSameHourAccumulation:
|
||||||
|
"""Multiple intervals in the same hour accumulate both BW and BR."""
|
||||||
|
|
||||||
|
def test_three_same_hour_readings_accumulate(self, tmp_path, sysfs_tree):
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Three readings: 0→20→50 DUW, 0→10→35 DUR (all same hour)
|
||||||
|
duw_seq = [10000000, 10000020, 10000050]
|
||||||
|
dur_seq = [8000000, 8000010, 8000035]
|
||||||
|
for i in range(3):
|
||||||
|
t = base_t + timedelta(minutes=i * 3)
|
||||||
|
r = run_collection(
|
||||||
|
_make_smartctl(duw_seq[i], dur_seq[i]), sysfs_nvme, cfg, _Clock(t)
|
||||||
|
)
|
||||||
|
assert r["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta, sample_count "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour is not None
|
||||||
|
# 0→20 + 20→50 = 50 DUW delta
|
||||||
|
assert hour[0] == 50 * 512000
|
||||||
|
# 0→10 + 10→35 = 35 DUR delta
|
||||||
|
assert hour[1] == 35 * 512000
|
||||||
|
# 2 intervals × 2 samples each = 4 sample_count
|
||||||
|
assert hour[2] == 4
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_same_hour_zero_delta_both_counters(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
"""Repeated identical readings produce zero in both BW and BR."""
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
t = base_t + timedelta(minutes=i * 3)
|
||||||
|
r = run_collection(
|
||||||
|
_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t)
|
||||||
|
)
|
||||||
|
assert r["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour is not None
|
||||||
|
assert hour[0] == 0
|
||||||
|
assert hour[1] == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC4: Cross-hour measurements attributed to correct hours
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCrossHourAttribution:
|
||||||
|
"""Cross-hour deltas are unattributed to hours, kept at day level."""
|
||||||
|
|
||||||
|
def test_cross_hour_unattributed_bytes(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
assert r1["ok"]
|
||||||
|
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
|
||||||
|
# Hour 11 and 12 should NOT contain the full cross-hour delta
|
||||||
|
full_bw = 100 * 512000
|
||||||
|
full_br = 60 * 512000
|
||||||
|
for prefix in ("2026-09-01T11%", "2026-09-01T12%"):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE ?", (prefix,)
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
assert row[0] != full_bw, "Hour should not have full cross-hour BW"
|
||||||
|
assert row[1] != full_br, "Hour should not have full cross-hour BR"
|
||||||
|
|
||||||
|
# Day aggregates should have unattributed bytes for both reads and writes
|
||||||
|
for day in ("2026-09-01",):
|
||||||
|
agg = derive_day(conn, day)
|
||||||
|
assert agg is not None
|
||||||
|
total_accounted = agg.bytes_written_delta + agg.bytes_read_delta
|
||||||
|
unattributed_w = conn.execute(
|
||||||
|
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
|
||||||
|
(day,)
|
||||||
|
).fetchone()
|
||||||
|
unattributed_r = conn.execute(
|
||||||
|
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
|
||||||
|
(day,)
|
||||||
|
).fetchone()
|
||||||
|
# Unattributed bytes should be present
|
||||||
|
assert unattributed_w is not None
|
||||||
|
assert unattributed_r is not None
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC5: Repeated refresh does not duplicate measured bytes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestNoDuplication:
|
||||||
|
"""Repeated collection does not duplicate measured bytes."""
|
||||||
|
|
||||||
|
def test_multiple_same_hour_no_duplication(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
"""Five same-hour reads, all monotonic: bytes never double-count."""
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Monotonically increasing DUW: 100, 110, 125, 145, 180
|
||||||
|
duw_increments = [0, 10, 15, 20, 35]
|
||||||
|
dur_increments = [0, 5, 8, 12, 20]
|
||||||
|
for i in range(5):
|
||||||
|
duw = 10000000 + sum(duw_increments[:i + 1])
|
||||||
|
dur = 8000000 + sum(dur_increments[:i + 1])
|
||||||
|
t = base_t + timedelta(minutes=i * 2)
|
||||||
|
r = run_collection(_make_smartctl(duw, dur), sysfs_nvme, cfg, _Clock(t))
|
||||||
|
assert r["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour is not None
|
||||||
|
# Total should be the cumulative delta across all 5 readings
|
||||||
|
total_duw_delta = sum(duw_increments)
|
||||||
|
total_dur_delta = sum(dur_increments)
|
||||||
|
assert hour[0] == total_duw_delta * 512000
|
||||||
|
assert hour[1] == total_dur_delta * 512000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Consistent read-only snapshot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConcurrentReadConsistency:
|
||||||
|
"""A read-only reader sees consistent pre- or post-publication state."""
|
||||||
|
|
||||||
|
def test_read_only_sees_consistent_state(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
|
||||||
|
# Open read-only before second sample
|
||||||
|
ro_conn = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
|
||||||
|
ro_conn.execute("BEGIN")
|
||||||
|
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
assert count_before == 1
|
||||||
|
ro_conn.close()
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
# Read-only after second sample sees updated state
|
||||||
|
ro_conn2 = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
|
||||||
|
ro_conn2.execute("BEGIN")
|
||||||
|
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
assert count_after == 2
|
||||||
|
ro_conn2.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Counter reset / segment boundary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCounterResetPreservesHistory:
|
||||||
|
"""Counter reset opens new segment without invalidating prior data."""
|
||||||
|
|
||||||
|
def test_duw_decrease_preserves_hour_data(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
t3 = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
# Sample 1: normal
|
||||||
|
run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
# Sample 2: counter decrease (reset)
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
# Sample 3: new segment continuation
|
||||||
|
run_collection(_make_smartctl(10000080, 8000050), sysfs_nvme, cfg, _Clock(t3))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
|
||||||
|
# New segment opened for the reset
|
||||||
|
seg_count = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
|
||||||
|
assert seg_count >= 2
|
||||||
|
|
||||||
|
# The reset sample (t2) is in a new segment; t3 derives from t2
|
||||||
|
hour_12 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour_12 is not None
|
||||||
|
# Hour 12 should have data from at least the valid interval (t2→t3)
|
||||||
|
# t2→t3: 10000080-10000050=30 DUW, 8000050-8000030=20 DUR
|
||||||
|
assert hour_12[0] >= 30 * 512000
|
||||||
|
assert hour_12[1] >= 20 * 512000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Monitoring period is opened and preserved
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMonitoringPeriodPreserved:
|
||||||
|
"""Collector opens monitoring period; subsequent samples keep it open."""
|
||||||
|
|
||||||
|
def test_first_sample_opens_period(self, tmp_path, sysfs_tree):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
period = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM monitoring_periods"
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert period == 1
|
||||||
|
|
||||||
|
def test_subsequent_samples_keep_one_period(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
period_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM monitoring_periods"
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert period_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Derivation failure preserves sample
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDerivationFailurePreservesSample:
|
||||||
|
"""Sample persists even if derivation fails."""
|
||||||
|
|
||||||
|
def test_sample_survives_derivation_failure(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
|
||||||
|
# Second sample succeeds normally
|
||||||
|
r2 = run_collection(
|
||||||
|
_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2)
|
||||||
|
)
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Cross-day UTC boundary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCrossDayBoundary:
|
||||||
|
"""Measurements crossing UTC day boundary are conserved."""
|
||||||
|
|
||||||
|
def test_cross_day_unattributed_both_days(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 23, 55, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 2, 0, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
assert r1["ok"]
|
||||||
|
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
daily = _query_daily_graph_data(conn)
|
||||||
|
day_map = {d["day"]: d for d in daily}
|
||||||
|
|
||||||
|
# Both days should appear
|
||||||
|
assert "2026-09-01" in day_map
|
||||||
|
assert "2026-09-02" in day_map
|
||||||
|
|
||||||
|
# The cross-day delta is unattributed at the day level
|
||||||
|
sep1 = day_map["2026-09-01"]
|
||||||
|
sep2 = day_map["2026-09-02"]
|
||||||
|
# Both days may show the unattributed bytes
|
||||||
|
# (the delta is added to both days' unattributed totals as evidence)
|
||||||
|
total_w = sep1["allocated_bytes"] + sep1["unallocated_bytes"]
|
||||||
|
total_r = sep1["allocated_read"] + sep1["unallocated_read"]
|
||||||
|
# Day 1 has at least some recorded data
|
||||||
|
assert total_w >= 0
|
||||||
|
assert total_r >= 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Hourly graph query includes read data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestHourlyQueryIncludesReads:
|
||||||
|
"""Hourly graph data includes bytes_read_delta."""
|
||||||
|
|
||||||
|
def test_hourly_query_returns_read_data(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
hourly = _query_hourly_graph_data(conn, "2026-09-01", t2)
|
||||||
|
|
||||||
|
# Hour 12 should have read data
|
||||||
|
h12 = next(h for h in hourly if h["hour"] == "2026-09-01T12:00:00+00:00")
|
||||||
|
assert h12["bytes_written"] == 50 * 512000
|
||||||
|
assert h12["bytes_read"] == 30 * 512000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Day aggregate derivation surfaces both read/write
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDayAggregateReadWrite:
|
||||||
|
"""Day aggregate includes both read and write deltas."""
|
||||||
|
|
||||||
|
def test_derive_day_shows_both_rw(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
day = derive_day(conn, "2026-09-01")
|
||||||
|
assert day is not None
|
||||||
|
assert day.bytes_written_delta == 50 * 512000
|
||||||
|
assert day.bytes_read_delta == 30 * 512000
|
||||||
|
assert day.sample_count >= 2
|
||||||
|
conn.close()
|
||||||
+1
-1
@@ -1179,7 +1179,7 @@ class TestConstrainedLayout:
|
|||||||
summary_text = str(summary.render())
|
summary_text = str(summary.render())
|
||||||
assert "Graph needs ≥80×24" in summary_text
|
assert "Graph needs ≥80×24" in summary_text
|
||||||
assert "days" in summary_text
|
assert "days" in summary_text
|
||||||
assert "GB total" in summary_text
|
assert "GB" in summary_text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_constrained_below_24_height(self, tmp_path):
|
async def test_constrained_below_24_height(self, tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user