Files
Fenris/tests/test_activity_readout.py
xavierk 8c8710fe5a Merge origin/main into main
Reconcile the staged-projection and Diagnostics feature work with the CI
quality-gate changes from origin/main. The origin side of every conflict was
lint-only, so the feature imports were kept and the ruff fixes re-applied:

- keep the branch's added imports (timefmt, activity_readout, projection
  ladder helpers) and drop the unused ones ruff reported
- restore `noqa: E402` on the post-bootstrap imports in fenris-monitor and
  keep the `noqa: F401` re-exports in the TUI that tests assert on
- make the TUI monitoring-glyph test clock-relative instead of pinned to a
  fixed date
2026-10-06 10:14:09 +05:30

484 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the Activity readout module (issue #108).
The readout is exercised against SQLite fixtures only; no Textual is involved.
The local timezone is pinned through the TZ environment variable.
"""
from datetime import datetime, timezone
from pathlib import Path
import sys
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.activity_readout import (
critical_warning_badge,
decode_critical_warning,
diagnostics_text,
day_readout_text,
hour_readout_text,
local_day_text,
interval_readout_text,
readout,
selected_local_day,
)
from fenris.activity_selection import (
HISTORY_RANGE_MAX,
ActivitySelection,
HistoryDayIdentity,
)
from fenris.store import init_store
from fenris.timefmt import (
freshness_age_human,
local_timestamp,
utc_range_label,
utc_stamp_label,
)
NOW = datetime(2026, 9, 30, 20, 0, tzinfo=timezone.utc) # 01:30 on 10-01 in Kolkata
@pytest.fixture
def kolkata(monkeypatch):
monkeypatch.setenv("TZ", "Asia/Kolkata")
@pytest.fixture
def conn(tmp_path):
connection = init_store(tmp_path / "observations.db")
yield connection
connection.close()
def _local_day(conn, local_date, utc_start, utc_end, tz="Asia/Kolkata",
offset="+05:30", written=1_000_000_000, read=500_000_000,
intervals=1, seconds=86400, incomplete=0, precision="measured"):
conn.execute(
"INSERT INTO local_days (local_date, tz_name, tz_offset, utc_start, "
"utc_end, bytes_written, bytes_read, coverage, sample_count, complete, "
"activity_seconds, activity_intervals, activity_incomplete, "
"activity_precision) VALUES (?,?,?,?,?,?,?,1.0,24,1,?,?,?,?)",
(local_date, tz, offset, utc_start, utc_end, written, read,
seconds, intervals, incomplete, precision),
)
conn.commit()
def _kolkata_day(conn, local_date, **kwargs):
day = datetime.fromisoformat(local_date)
start = datetime(day.year, day.month, day.day, tzinfo=timezone.utc)
start_utc = start.timestamp() - 19800
end_utc = start_utc + 86400
_local_day(
conn, local_date,
datetime.fromtimestamp(start_utc, timezone.utc).isoformat(),
datetime.fromtimestamp(end_utc, timezone.utc).isoformat(),
**kwargs,
)
def _hour(conn, hour, written=0, read=0, active=0, idle=0, off=0, unknown=0,
samples=1, coverage=1.0, tmin=None, tmax=None, tavg=None):
conn.execute(
"INSERT INTO hour_observations (hour, active_seconds, idle_seconds, "
"powered_off_seconds, unknown_seconds, bytes_written_delta, "
"bytes_read_delta, temperature_min, temperature_max, temperature_avg, "
"sample_count, coverage) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(hour, active, idle, off, unknown, written, read, tmin, tmax, tavg,
samples, coverage),
)
conn.commit()
def _sample(conn, ts, written, read, segment=1, temp=35, critical=0):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours, "
"segment_id, temperature_c, critical_warning) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(ts, "/dev/nvme0n1", 1, 1, 1, written, read, 100, segment, temp,
critical),
)
conn.commit()
class TestHistoryDays:
def test_daily_bars_use_local_dates_not_utc_aggregates(self, conn, kolkata):
# A UTC aggregate for 09-30 must never be relabelled as a local day.
conn.execute(
"INSERT INTO day_aggregates (day, bytes_written_delta, "
"sample_count, coverage) VALUES ('2026-09-30', 9000000000, 24, 1.0)"
)
conn.commit()
_kolkata_day(conn, "2026-10-01")
result = readout(conn, ActivitySelection(), NOW)
assert result.days[-1]["day"] == "2026-10-01"
assert result.days[-1]["timezone_label"] == "Asia/Kolkata +05:30"
by_day = {d["day"]: d for d in result.days}
assert by_day["2026-09-30"]["is_gap"]
assert by_day["2026-09-30"]["total_written"] is None
def test_window_is_exact_and_missing_days_stay_explicit_gaps(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29")
result = readout(conn, ActivitySelection(), NOW)
assert len(result.days) == HISTORY_RANGE_MAX
gaps = [d for d in result.days if d["is_gap"]]
assert len(gaps) == HISTORY_RANGE_MAX - 1 # only 09-29 has evidence
known = next(d for d in result.days if d["day"] == "2026-09-29")
assert known["activity_state"] == "complete"
assert known["total_written"] == 1_000_000_000
def test_current_local_day_is_partial_and_incomplete_day_is_flagged(self, conn, kolkata):
_kolkata_day(conn, "2026-10-01", written=100)
_kolkata_day(conn, "2026-09-30", written=200, seconds=3600)
by_day = {d["day"]: d for d in readout(conn, ActivitySelection(), NOW).days}
assert by_day["2026-10-01"]["activity_state"] == "so_far"
assert by_day["2026-10-01"]["is_partial"]
assert by_day["2026-09-30"]["activity_state"] == "incomplete"
assert by_day["2026-09-30"]["is_partial"]
def test_shared_local_day_evidence_is_kept_apart_not_prorated(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29", written=7_000_000_000, read=3_000_000_000)
conn.execute(
"INSERT INTO local_day_unallocated_evidence (start_sample_id, "
"end_sample_id, start_local_date, end_local_date, start_tz_name, "
"end_tz_name, started_at, ended_at, bytes_written, bytes_read, "
"reason) VALUES (1,2,'2026-09-29','2026-09-30','Asia/Kolkata',"
"'Asia/Kolkata','2026-09-29T18:00:00+00:00',"
"'2026-09-29T18:40:00+00:00',1000000000,500000000,'local_midnight')"
)
conn.commit()
day = next(
d for d in readout(conn, ActivitySelection(), NOW).days
if d["day"] == "2026-09-29"
)
assert day["total_written"] == 7_000_000_000
assert day["shared_bytes_written"] == 1_000_000_000
assert day["shared_bytes_read"] == 500_000_000
text = day_readout_text(day)
assert "W 7.000 GB known" in text
assert "shared at midnight W 1.000 GB · R 0.500 GB" in text
def test_unrecorded_selected_date_is_an_unavailable_gap(self, conn, kolkata):
selection = ActivitySelection()
selection.select_history_date("2026-06-01")
result = readout(conn, selection, NOW)
assert result.local_date == "2026-06-01"
assert result.local_day is None
picked = [d for d in result.days if d["day"] == "2026-06-01"]
assert picked and picked[0]["is_gap"]
assert "local-day evidence unavailable" in day_readout_text(picked[0])
class TestLocalDayStrip:
def test_current_day_text_has_zone_state_and_habit(self, conn, kolkata):
_kolkata_day(conn, "2026-10-01", written=2_000_000_000, read=1_000_000_000)
_hour(conn, "2026-09-30T19:00:00+00:00", active=3600, tmin=30, tmax=44)
local_date, local = selected_local_day(conn, ActivitySelection(), NOW)
text = local_day_text(local_date, local)
assert "2026-10-01" in text and "Asia/Kolkata +05:30" in text
assert "totals so far" in text
assert "W 2.000 GB known · R 1.000 GB known" in text
assert "active 1h 00m" in text and "30–44°C" in text
def test_missing_day_text_is_unavailable(self, conn, kolkata):
local_date, local = selected_local_day(conn, ActivitySelection(), NOW)
assert local is None
assert "local-day evidence unavailable" in local_day_text(local_date, local)
class TestHours:
def _selected_day(self, conn):
_kolkata_day(conn, "2026-09-29")
selection = ActivitySelection()
selection.select_history_date(
"2026-09-29",
HistoryDayIdentity(
"2026-09-29", "Asia/Kolkata",
"2026-09-28T18:30:00+00:00", "2026-09-29T18:30:00+00:00",
),
)
selection.set_view("day")
return selection
def test_hours_only_in_day_view_with_local_and_secondary_utc_labels(self, conn, kolkata):
selection = self._selected_day(conn)
_hour(conn, "2026-09-28T19:00:00+00:00", written=4_000_000_000,
active=600, idle=2400, off=0, unknown=600, tmin=31, tmax=40,
coverage=0.8)
result = readout(conn, selection, NOW)
first = result.hours[0]
assert first["local_label"] == "00:30"
assert first["timezone_label"] == "Asia/Kolkata +05:30"
assert first["utc_label"] == "2026-09-28 19:00 UTC"
assert first["bytes_written"] == 4_000_000_000
text = hour_readout_text(first, "2026-09-29")
assert "00:30 Asia/Kolkata +05:30" in text
assert text.splitlines()[-1] == "2026-09-28 19:00 UTC"
assert "active 10m · idle 40m · off 0m · unknown 10m" in text
assert "31–40°C" in text
def test_live_view_has_no_hours(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29")
assert readout(conn, ActivitySelection(), NOW).hours == ()
def test_missing_hour_is_gap_and_future_hour_is_not(self, conn, kolkata):
_kolkata_day(conn, "2026-10-01")
selection = ActivitySelection()
selection.select_history_date("2026-10-01")
selection.set_view("day")
result = readout(conn, selection, NOW)
past = next(h for h in result.hours if h["local_label"] == "00:30")
future = next(h for h in result.hours if h["local_label"] == "06:30")
assert past["is_gap"] and not past["is_future"]
assert future["is_future"] and not future["is_gap"]
assert "W unavailable · R unavailable" in hour_readout_text(past, "2026-10-01")
class TestLiveIntervals:
def test_interval_labels_are_local_with_secondary_utc_line(self, conn, kolkata):
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500)
_sample(conn, "2026-09-30T19:33:00+00:00", 3000, 900, temp=41)
result = readout(conn, ActivitySelection(), NOW)
point = result.live[0]
assert point["start_label"] == "01:00"
assert point["end_label"] == "01:03"
assert point["timezone_label"] == "Asia/Kolkata +05:30"
assert point["bytes_written"] == 2000
assert point["temperature_c"] == 41
text = interval_readout_text(point)
assert "01:00 → 01:03 Asia/Kolkata +05:30" in text
assert text.splitlines()[-1] == "19:30 → 19:33 UTC"
assert "41°C" in text
def test_drive_warning_is_part_of_the_readout(self, conn, kolkata):
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500)
_sample(conn, "2026-09-30T19:33:00+00:00", 3000, 900, critical=4)
point = readout(conn, ActivitySelection(), NOW).live[0]
assert "critical warning" in interval_readout_text(point)
def test_segment_boundary_is_unavailable(self, conn, kolkata):
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500, segment=1)
_sample(conn, "2026-09-30T19:33:00+00:00", 5, 5, segment=2)
point = readout(conn, ActivitySelection(), NOW).live[0]
assert "segment boundary" in interval_readout_text(point)
assert "W unavailable · R unavailable" in interval_readout_text(point)
class TestTimezone:
def test_utc_zone_still_carries_a_label(self, conn, monkeypatch):
monkeypatch.setenv("TZ", "UTC")
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500)
_sample(conn, "2026-09-30T19:33:00+00:00", 3000, 900)
result = readout(conn, ActivitySelection(), NOW)
assert result.timezone_name == "UTC"
assert result.timezone_label == "UTC +00:00"
assert result.live[0]["start_label"] == "19:30"
assert result.live[0]["timezone_label"] == "UTC +00:00"
def test_unknown_zone_falls_back_to_utc(self, conn, monkeypatch):
monkeypatch.setenv("TZ", "Not/AZone")
assert readout(conn, ActivitySelection(), NOW).timezone_name == "UTC"
def test_health_comes_from_the_latest_sample(self, conn, kolkata):
assert readout(conn, ActivitySelection(), NOW).health["model"] == "unknown"
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500, temp=38)
assert readout(conn, ActivitySelection(), NOW).health["temp"] == 38
class TestTimeFormatting:
def test_freshness_age_human_lives_here(self):
assert freshness_age_human(30) == "30s ago"
assert freshness_age_human(None) == "unknown age"
def test_local_timestamp_names_the_zone(self):
when = datetime(2026, 9, 30, 20, 0, tzinfo=timezone.utc)
assert local_timestamp(when, "Asia/Kolkata") == (
"2026-10-01 01:30 Asia/Kolkata +05:30"
)
def test_utc_labels(self):
a = datetime(2026, 9, 30, 19, 30, tzinfo=timezone.utc)
b = datetime(2026, 9, 30, 19, 33, tzinfo=timezone.utc)
assert utc_range_label(a, b) == "19:30 → 19:33 UTC"
assert utc_stamp_label(a) == "2026-09-30 19:30 UTC"
def _counters(conn, ts, poh, cycles, unsafe, media, spare, critical=0):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours, "
"power_cycles, unsafe_shutdowns, media_errors, available_spare, "
"segment_id, temperature_c, critical_warning) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(ts, "/dev/nvme0n1", 1, 1, 1, 1, 1, poh, cycles, unsafe, media, spare,
1, 35, critical),
)
conn.commit()
class TestCriticalWarning:
def test_zero_decodes_to_nothing_and_each_bit_to_its_meaning(self):
assert decode_critical_warning(0) == ()
assert decode_critical_warning(None) == ()
assert decode_critical_warning(0x01) == ("available spare below threshold",)
assert decode_critical_warning(0x05) == (
"available spare below threshold", "reliability degraded",
)
assert "temperature outside threshold" in decode_critical_warning(0x02)
assert "media in read-only mode" in decode_critical_warning(0x08)
assert "volatile memory backup failed" in decode_critical_warning(0x10)
assert "persistent memory region read-only" in decode_critical_warning(0x20)
assert decode_critical_warning(0x40) == ("unrecognised bits 0x40",)
def test_health_carries_latest_critical_warning_and_badge_follows_it(self, conn, kolkata):
_counters(conn, "2026-09-30T19:00:00+00:00", 10, 1, 0, 0, 100, critical=0)
assert critical_warning_badge(
readout(conn, ActivitySelection(), NOW).health) == ""
_counters(conn, "2026-09-30T19:30:00+00:00", 11, 1, 0, 0, 100, critical=0x04)
health = readout(conn, ActivitySelection(), NOW).health
assert health["critical_warning"] == 0x04
badge = critical_warning_badge(health)
assert "0x04" in badge and "reliability degraded" in badge
def test_no_samples_means_no_badge(self, conn, kolkata):
assert critical_warning_badge(
readout(conn, ActivitySelection(), NOW).health) == ""
class TestDiagnosticsReadout:
def test_diagnostics_are_absent_until_selected(self, conn, kolkata):
assert readout(conn, ActivitySelection(), NOW).diagnostics is None
selection = ActivitySelection()
selection.toggle_diagnostics()
assert selection.diagnostics
assert readout(conn, selection, NOW).diagnostics is not None
selection.toggle_diagnostics()
assert not selection.diagnostics
def test_live_counters_have_deltas_over_the_live_window(self, conn, kolkata):
_counters(conn, "2026-09-30T16:00:00+00:00", 90, 5, 1, 0, 100) # before window
_counters(conn, "2026-09-30T18:00:00+00:00", 100, 6, 1, 0, 100)
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98, critical=0x02)
selection = ActivitySelection()
selection.toggle_diagnostics()
diag = readout(conn, selection, NOW).diagnostics
assert diag["window_label"] == "last 3h"
assert diag["critical_warning"] == 0x02
assert diag["critical_warning_flags"] == ("temperature outside threshold",)
counters = diag["counters"]
assert counters["power_on_hours"] == {"value": 103, "delta": 13}
assert counters["power_cycles"] == {"value": 8, "delta": 3}
assert counters["unsafe_shutdowns"] == {"value": 3, "delta": 2}
assert counters["media_errors"] == {"value": 2, "delta": 2}
assert counters["available_spare"] == {"value": 98, "delta": -2}
def test_no_delta_across_a_controller_segment_change(self, conn, kolkata):
_counters(conn, "2026-09-30T18:00:00+00:00", 100, 6, 1, 0, 100)
_counters(conn, "2026-09-30T19:50:00+00:00", 5, 1, 0, 0, 100)
conn.execute("UPDATE samples SET segment_id = 2 WHERE ts > '2026-09-30T19'")
conn.commit()
selection = ActivitySelection()
selection.toggle_diagnostics()
counters = readout(conn, selection, NOW).diagnostics["counters"]
assert counters["power_on_hours"] == {"value": 5, "delta": None}
def test_single_sample_has_no_delta(self, conn, kolkata):
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98)
selection = ActivitySelection()
selection.toggle_diagnostics()
counters = readout(conn, selection, NOW).diagnostics["counters"]
assert counters["power_on_hours"] == {"value": 103, "delta": None}
def test_day_selection_measures_deltas_across_that_local_day(self, conn, kolkata):
selection = TestHours()._selected_day(conn)
_counters(conn, "2026-09-28T18:00:00+00:00", 50, 2, 0, 0, 100)
_counters(conn, "2026-09-29T06:00:00+00:00", 56, 2, 1, 0, 100)
_counters(conn, "2026-09-29T20:00:00+00:00", 99, 9, 9, 9, 50) # after the day
selection.toggle_diagnostics()
diag = readout(conn, selection, NOW).diagnostics
assert diag["window_label"] == "day 2026-09-29"
assert diag["counters"]["power_on_hours"] == {"value": 56, "delta": 6}
assert diag["counters"]["unsafe_shutdowns"]["delta"] == 1
def test_hours_carry_temperature_average(self, conn, kolkata):
selection = TestHours()._selected_day(conn)
_hour(conn, "2026-09-29T00:00:00+00:00", active=1800, idle=1800,
tmin=31, tavg=36.5, tmax=44)
hour = next(h for h in readout(conn, selection, NOW).hours
if h["hour"] == "2026-09-29T00:00:00+00:00")
assert (hour["temperature_min"], hour["temperature_avg"],
hour["temperature_max"]) == (31, 36.5, 44)
def test_history_days_carry_split_and_temperature_range(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29")
_hour(conn, "2026-09-29T00:00:00+00:00", active=1800, idle=1200, off=600,
tmin=30, tavg=34.0, tmax=40)
_hour(conn, "2026-09-29T01:00:00+00:00", active=0, idle=3000, unknown=600,
tmin=28, tavg=30.0, tmax=33)
selection = ActivitySelection()
selection.set_view("history")
selection.toggle_diagnostics()
days = {d["day"]: d for d in readout(conn, selection, NOW).diagnostics["days"]}
day = days["2026-09-29"]
assert (day["active_seconds"], day["idle_seconds"],
day["powered_off_seconds"], day["unknown_seconds"]) == (1800, 4200, 600, 600)
assert (day["temperature_min"], day["temperature_max"]) == (28, 40)
assert day["temperature_avg"] == pytest.approx(32.0)
assert "2026-09-30" not in days # no evidence, no invented split
class TestDiagnosticsText:
def _diag(self, conn, selection):
selection.toggle_diagnostics()
return readout(conn, selection, NOW)
def test_panel_shows_decoded_warning_counters_and_deltas(self, conn, kolkata):
_counters(conn, "2026-09-30T18:00:00+00:00", 100, 6, 1, 0, 100)
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98, critical=0x05)
text = diagnostics_text(self._diag(conn, ActivitySelection()))
assert "last 3h" in text
assert "0x05" in text and "reliability degraded" in text
assert "Power-on hours" in text and "103" in text and "+3" in text
assert "Unsafe shutdowns" in text and "+2" in text
assert "Available spare" in text and "98%" in text and "-2" in text
def test_no_warning_says_none_and_missing_delta_says_so(self, conn, kolkata):
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98)
text = diagnostics_text(self._diag(conn, ActivitySelection()))
assert "Critical warning none" in text
assert "Δ n/a" in text
def test_day_view_lists_each_hour_with_temperature_split_and_coverage(self, conn, kolkata):
selection = TestHours()._selected_day(conn)
_hour(conn, "2026-09-29T00:00:00+00:00", active=1800, idle=1200, off=300,
unknown=300, tmin=31, tavg=36.4, tmax=44, coverage=0.75)
text = diagnostics_text(self._diag(conn, selection))
row = next(line for line in text.splitlines() if line.startswith("05:30"))
assert "31/36/44°C" in row
assert "A30m I20m Off5m ?5m" in row
assert "75%" in row
assert any("gap" in line for line in text.splitlines()
if line.startswith("06:30"))
def test_history_view_lists_each_day_with_split_and_temperature(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29")
_hour(conn, "2026-09-29T00:00:00+00:00", active=7200, idle=3600, off=0,
tmin=30, tavg=34.0, tmax=40)
selection = ActivitySelection()
selection.set_view("history")
text = diagnostics_text(self._diag(conn, selection))
row = next(line for line in text.splitlines() if line.startswith("09-29"))
assert "A2h 00m" in row and "I1h 00m" in row and "30/34/40°C" in row
def test_live_view_summarises_interval_temperature(self, conn, kolkata):
for index, (minute, temp) in enumerate(((0, 30), (3, 36), (6, 42))):
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, "
"segment_id, temperature_c) VALUES (?,?,?,?,?,?)",
("2026-09-30T19:%02d:00+00:00" % (50 + minute), "/dev/n", index, index, 1, temp),
)
conn.commit()
text = diagnostics_text(self._diag(conn, ActivitySelection()))
assert "36/39/42°C" in text and "2 intervals" in text # interval ends