"""Monitoring period tests. From spec §5.2, §8.6, §9.8: - Run finding no open period opens one at the run moment, never backdated - Wall-clock outside periods excluded from numerator/denominator - Powered-off time stays inside a period; disabled time does not - End causes: user_disabled, migrated, unknown_gap """ 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, get_open_period, is_inside_period, ) @pytest.fixture def store_conn(tmp_path: Path): """Initialize an observation store and return a connection.""" db_path = tmp_path / "test.db" conn = init_store(db_path) yield conn conn.close() class TestEnsurePeriodOpen: """Spec §9.8: Run finding no open period opens one at the run moment.""" def test_opens_period_when_none_exists(self, store_conn): """First collection run opens a period at the run moment.""" run_time = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, run_time) period = get_open_period(store_conn) assert period is not None assert period["started_at"] == "2026-09-01T12:00:00+00:00" assert period["ended_at"] is None assert period["end_cause"] is None def test_no_opener_when_already_open(self, store_conn): """If a period is already open, no new period is created.""" t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t1) ensure_period_open(store_conn, t2) period = get_open_period(store_conn) assert period is not None assert period["started_at"] == "2026-09-01T12:00:00+00:00" def test_never_backdated(self, store_conn): """Period starts at the run moment, not the beginning of the hour.""" run_time = datetime(2026, 9, 1, 12, 3, 45, tzinfo=timezone.utc) ensure_period_open(store_conn, run_time) period = get_open_period(store_conn) assert period["started_at"] == "2026-09-01T12:03:45+00:00" def test_new_period_after_close(self, store_conn): """After closing a period, next run opens a new one.""" t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t2 = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc) t3 = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t1) close_period(store_conn, t2, "user_disabled") ensure_period_open(store_conn, t3) period = get_open_period(store_conn) assert period is not None assert period["started_at"] == "2026-09-01T14:00:00+00:00" class TestClosePeriod: """Spec §8.6: Pause closes with user_disabled.""" def test_close_with_user_disabled(self, store_conn): t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t2 = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t1) close_period(store_conn, t2, "user_disabled") period = get_open_period(store_conn) assert period is None cursor = store_conn.execute( "SELECT ended_at, end_cause FROM monitoring_periods WHERE id = 1" ) row = cursor.fetchone() assert row[0] == "2026-09-01T13:00:00+00:00" assert row[1] == "user_disabled" def test_close_nonexistent_is_noop(self, store_conn): """Closing when no period is open is a no-op (spec §8.6 pause otherwise).""" t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) close_period(store_conn, t, "user_disabled") cursor = store_conn.execute("SELECT COUNT(*) FROM monitoring_periods") assert cursor.fetchone()[0] == 0 class TestIsInsidePeriod: """Spec §5.2: Wall-clock outside periods excluded from numerator/denominator.""" def test_inside_open_period(self, store_conn): t_start = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t_inside = datetime(2026, 9, 1, 12, 30, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) assert is_inside_period(store_conn, t_inside) is True def test_outside_closed_period(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) t_outside = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t_start) close_period(store_conn, t_end, "user_disabled") assert is_inside_period(store_conn, t_outside) is False def test_outside_no_periods(self, store_conn): t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) assert is_inside_period(store_conn, t) is False def test_inside_second_period(self, store_conn): """Two periods with a gap; time in second period is inside.""" t1_start = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t1_end = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc) t2_start = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc) t_inside = datetime(2026, 9, 1, 14, 30, 0, tzinfo=timezone.utc) ensure_period_open(store_conn, t1_start) close_period(store_conn, t1_end, "user_disabled") ensure_period_open(store_conn, t2_start) assert is_inside_period(store_conn, t_inside) is True