"""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