Move activity data assembly out of tui.py into activity_readout, behind readout(conn, selection, now). Dashboard and fenris-monitor enable now show local time with a timezone label, inspected points keep a secondary UTC line, and the legacy UTC-relabelled day and hour branches are gone. Time formatting (including freshness_age_human) lives in timefmt. Hour offsets now render as +05:30 to match day labels.
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""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.
|
||
"""
|
||
import sqlite3
|
||
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 (
|
||
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):
|
||
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, sample_count, "
|
||
"coverage) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||
(hour, active, idle, off, unknown, written, read, tmin, tmax,
|
||
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"
|