"""Day aggregate tests. From spec §5.4, §3.3, ST-4, ST-5: - One row per UTC day, derived monotonically from hour rows - No 23/25-hour days (UTC-bounded, DST never applies) - Coverage: share of wall-clock seconds inside monitoring periods whose classification is known - No absent hour ever interpolated/estimated/fabricated (FL-3) """ import sqlite3 import sys from datetime import datetime, timezone, timedelta from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from fenris.store import init_store from fenris.monitoring_periods import ensure_period_open, close_period from fenris.day_aggregate import derive_day, derive_all_days, DayAggregate from fenris.hour_classify import HourSplit, ACTIVE_THRESHOLD_BYTES @pytest.fixture def store_conn(tmp_path: Path): db_path = tmp_path / "test.db" conn = init_store(db_path) yield conn conn.close() def _insert_hour(conn, hour_iso, split, bytes_written_delta=0, bytes_read_delta=0, sample_count=1, coverage=1.0): conn.execute( "INSERT INTO hour_observations " "(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, " " bytes_written_delta, bytes_read_delta, sample_count, coverage) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ( hour_iso, split.seconds_active, split.seconds_idle, split.seconds_powered_off, split.seconds_unknown, bytes_written_delta, bytes_read_delta, sample_count, coverage, ), ) conn.commit() class TestDeriveDay: """Spec §5.4: One row per UTC day, derived monotonically from hour rows.""" def test_single_hour_day(self, store_conn): t_start = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t_end = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") hour = "2026-09-01T12:00:00+00:00" split = HourSplit(seconds_active=3600, seconds_idle=0, seconds_powered_off=0, seconds_unknown=0) _insert_hour(store_conn, hour, split, bytes_written_delta=1024*1024*100) day = derive_day(store_conn, "2026-09-01") assert day is not None assert day.day == "2026-09-01" assert day.seconds_active == 3600 assert day.seconds_idle == 0 assert day.seconds_powered_off == 0 assert day.bytes_written_delta == 1024*1024*100 assert day.sample_count == 1 def test_multiple_hours(self, store_conn): t_start = datetime(2026, 9, 1, 10, 0, 0, tzinfo=timezone.utc) t_end = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") hours = [ ("2026-09-01T10:00:00+00:00", HourSplit(1800, 1800, 0, 0), 50*1024*1024), ("2026-09-01T11:00:00+00:00", HourSplit(3600, 0, 0, 0), 200*1024*1024), ("2026-09-01T12:00:00+00:00", HourSplit(0, 0, 3600, 0), 0), ] for h, s, bw in hours: _insert_hour(store_conn, h, s, bytes_written_delta=bw) day = derive_day(store_conn, "2026-09-01") assert day is not None assert day.seconds_active == 1800 + 3600 + 0 assert day.seconds_idle == 1800 + 0 + 0 assert day.seconds_powered_off == 0 + 0 + 3600 assert day.bytes_written_delta == 50*1024*1024 + 200*1024*1024 + 0 assert day.sample_count == 3 def test_no_hours_returns_none(self, store_conn): day = derive_day(store_conn, "2026-09-01") assert day is None class TestUtcBounded: """Spec §3.3, ST-4: Hours and days are UTC-bounded. No 23/25-hour days.""" def test_day_keys_are_utc_date_strings(self, store_conn): t_start = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t_end = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") hour = "2026-09-01T12:00:00+00:00" split = HourSplit(0, 0, 3600, 0) _insert_hour(store_conn, hour, split) day = derive_day(store_conn, "2026-09-01") assert day is not None assert day.day == "2026-09-01" def test_cross_midnight_hours_produce_two_days(self, store_conn): # Period spans both days t_start = datetime(2026, 9, 1, 23, 0, 0, tzinfo=timezone.utc) t_end = datetime(2026, 9, 2, 1, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") hours = [ ("2026-09-01T23:00:00+00:00", HourSplit(3600, 0, 0, 0), 100), ("2026-09-02T00:00:00+00:00", HourSplit(0, 0, 3600, 0), 0), ] for h, s, bw in hours: _insert_hour(store_conn, h, s, bytes_written_delta=bw) days = derive_all_days(store_conn) day_keys = [d.day for d in days] assert "2026-09-01" in day_keys assert "2026-09-02" in day_keys assert len(days) == 2 class TestCoverage: """Spec §5.2, §5.3, PR-5: Coverage is known share of wall-clock seconds inside monitoring periods.""" def test_full_coverage_all_known(self, store_conn): """All hours in period classified -> coverage = 1.0.""" t_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc) t_end = datetime(2026, 9, 1, 3, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") hours = [ ("2026-09-01T00:00:00+00:00", HourSplit(3600, 0, 0, 0), 100), ("2026-09-01T01:00:00+00:00", HourSplit(0, 3600, 0, 0), 50), ("2026-09-01T02:00:00+00:00", HourSplit(0, 0, 3600, 0), 0), ] for h, s, bw in hours: _insert_hour(store_conn, h, s, bytes_written_delta=bw) day = derive_day(store_conn, "2026-09-01") assert day is not None assert day.coverage == pytest.approx(1.0) def test_gap_reduces_coverage(self, store_conn): """Missing hour -> unknown seconds reduce coverage.""" t_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc) t_end = datetime(2026, 9, 1, 3, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") _insert_hour(store_conn, "2026-09-01T00:00:00+00:00", HourSplit(3600, 0, 0, 0), bytes_written_delta=100) _insert_hour(store_conn, "2026-09-01T02:00:00+00:00", HourSplit(0, 3600, 0, 0), bytes_written_delta=50) day = derive_day(store_conn, "2026-09-01") assert day is not None assert day.coverage == pytest.approx(7200 / 10800) assert day.seconds_unknown == 3600 def test_outside_period_excluded(self, store_conn): """Hours outside any monitoring period excluded from denominator.""" t_start = datetime(2026, 9, 1, 1, 0, 0, tzinfo=timezone.utc) t_end = datetime(2026, 9, 1, 2, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") # Hour 01 is inside the period _insert_hour(store_conn, "2026-09-01T01:00:00+00:00", HourSplit(3600, 0, 0, 0), bytes_written_delta=100) # Hour 02 is outside (period ends at 02:00) _insert_hour(store_conn, "2026-09-01T02:00:00+00:00", HourSplit(0, 3600, 0, 0), bytes_written_delta=50) day = derive_day(store_conn, "2026-09-01") assert day is not None assert day.coverage == pytest.approx(1.0) def test_unknown_in_period_reduces_coverage(self, store_conn): """Unknown seconds inside period count in denominator but not numerator.""" t_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc) t_end = datetime(2026, 9, 1, 1, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") _insert_hour(store_conn, "2026-09-01T00:00:00+00:00", HourSplit(1800, 0, 0, 1800), bytes_written_delta=100) day = derive_day(store_conn, "2026-09-01") assert day is not None assert day.coverage == pytest.approx(0.5) assert day.seconds_unknown == 1800 class TestNoFabrication: """Spec §5.3, FL-3: No absent hour is ever interpolated/estimated/fabricated.""" def test_missing_hours_stay_unknown(self, store_conn): """Gap hours are never filled in — they remain as unknown seconds.""" t_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc) t_end = datetime(2026, 9, 1, 3, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") # Only hour 02 — hours 00 and 01 are gaps _insert_hour(store_conn, "2026-09-01T02:00:00+00:00", HourSplit(3600, 0, 0, 0), bytes_written_delta=100) day = derive_day(store_conn, "2026-09-01") assert day is not None assert day.seconds_unknown == 2 * 3600 assert day.seconds_active == 3600 assert day.sample_count == 1