"""Local-day activity derivation tests (issue #90, ADR 0010). Verifies that local-day summaries are correctly derived from UTC hour observations, that the schema migration creates the local_days table, that timezone boundaries are preserved across system-timezone changes, and that midnight-spanning intervals are retained as shared evidence without double counting. Seams: - write side: collector.run_collection() → local_day derivation - read side: local_day.query_current_local_day(), query_local_day_summary() - persistence: store.init_store() → local_days table, migration 2→3 """ import sqlite3 from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict import pytest import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from fenris.store import init_store, SCHEMA_VERSION from fenris.local_day import ( derive_local_day_summary, persist_local_day, record_local_activity_interval, query_local_day_summary, query_current_local_day, LocalDaySummary, ) from fenris.monitoring_periods import ensure_period_open, close_period # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]: return { "json_format_version": [1, 0], "smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"}, "nvme_smart_health_information_log": { "critical_warning": 0, "temperature": 35, "available_spare": 100, "available_spare_threshold": 10, "percentage_used": 5, "data_units_written": duw, "data_units_read": dur, "power_on_hours": 8765, "power_cycles": 1234, "unsafe_shutdowns": 5, "media_errors": 0, "num_err_log_entries": 0, }, "user_capacity": {"bytes": 1024000000000, "units": "bytes"}, "model_name": "Samsung SSD 970 EVO Plus 1TB", "serial_number": "S4EWNX0N123456", "firmware_version": "2B2QEXM7", } @pytest.fixture def sysfs_tree(tmp_path: Path) -> Path: ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0" ctrl_dir.mkdir(parents=True) (ctrl_dir / "subsysnqn").write_text( "nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n" ) (ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n") (ctrl_dir / "serial").write_text("S4EWNX0N123456\n") (ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n") transport_dir = ctrl_dir / "transport" transport_dir.mkdir() (transport_dir / "address").write_text("0000:03:00.0") (transport_dir / "trstring").write_text("pcie") return tmp_path class _Clock: def __init__(self, initial: datetime): self.now = initial def utcnow(self): return self.now def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1, active=3600, idle=0, powered_off=0, unknown=0): """Insert a UTC hour observation.""" known = active + idle + powered_off coverage = known / 3600.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, active, idle, powered_off, unknown, bw, br, sample_count, coverage), ) conn.commit() # --------------------------------------------------------------------------- # Schema migration: local_days table is created # --------------------------------------------------------------------------- class TestSchemaMigration: """Store version 3 includes the local_days table.""" def test_fresh_store_has_local_days(self, tmp_path): conn = init_store(tmp_path / "obs.db") tables = {row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} assert "local_days" in tables conn.close() def test_migration_from_v2_creates_local_days(self, tmp_path): db = tmp_path / "obs.db" conn = sqlite3.connect(str(db)) conn.execute("PRAGMA user_version=2") conn.commit() conn.close() conn = init_store(db) tables = {row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} assert "local_days" in tables assert conn.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION conn.close() def test_migration_is_idempotent(self, tmp_path): db = tmp_path / "obs.db" conn = init_store(db) conn.close() # Reopening should not fail conn = init_store(db) count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0] assert count == 0 conn.close() def test_v4_totals_keep_values_but_are_not_treated_as_local_precision( self, tmp_path, ): db = tmp_path / "v4.db" conn = sqlite3.connect(db) conn.execute( "CREATE TABLE samples (id INTEGER PRIMARY KEY, ts TEXT, device TEXT)" ) conn.execute( "CREATE TABLE pending_publications " "(id INTEGER PRIMARY KEY, sample_ts TEXT, payload TEXT)" ) conn.execute( "CREATE TABLE local_days (" "id INTEGER PRIMARY KEY, local_date TEXT NOT NULL, tz_name TEXT NOT NULL, " "tz_offset TEXT NOT NULL, utc_start TEXT NOT NULL, utc_end TEXT NOT NULL, " "bytes_written INTEGER DEFAULT 0, bytes_read INTEGER DEFAULT 0, " "coverage REAL DEFAULT 0, sample_count INTEGER DEFAULT 0, " "complete BOOLEAN DEFAULT 0, UNIQUE(local_date, tz_name))" ) conn.execute( "INSERT INTO local_days VALUES " "(1, '2026-09-01', 'UTC', '+00:00', " "'2026-09-01T00:00:00+00:00', '2026-09-02T00:00:00+00:00', " "5120000, 2048000, 0.5, 10, 0)" ) conn.execute("PRAGMA user_version=4") conn.commit() conn.close() conn = init_store(db) stored = conn.execute( "SELECT bytes_written, activity_precision FROM local_days" ).fetchone() assert stored == (5_120_000, "unavailable") visible = query_local_day_summary(conn, "2026-09-01", "UTC") assert visible["bytes_written"] is None assert visible["activity_state"] == "unavailable" assert "local_tz" in { row[1] for row in conn.execute("PRAGMA table_info(samples)") } conn.close() # --------------------------------------------------------------------------- # Local-day coverage derivation from UTC hours # --------------------------------------------------------------------------- class TestDeriveLocalDay: """Derive coverage from UTC hours without allocating local-day bytes.""" def test_utc_plus_zero(self, tmp_path): """UTC timezone: local day boundaries = UTC day boundaries.""" conn = init_store(tmp_path / "obs.db") ensure_period_open(conn, datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)) _insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, br=50) _insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, br=80) clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "UTC", clock) assert summary is not None assert summary.local_date == "2026-09-01" assert summary.tz_name == "UTC" assert summary.tz_offset == "+00:00" assert summary.bytes_written == 0 assert summary.bytes_read == 0 assert summary.activity_precision == "unavailable" assert summary.complete is False # not all 24 hours covered conn.close() def test_utc_plus_five_thirty(self, tmp_path): """Asia/Kolkata half-hour offset: UTC boundaries are offset.""" conn = init_store(tmp_path / "obs.db") # Local day 2026-09-01 in +05:30 is UTC [2026-08-31T18:30, 2026-09-01T18:30) # Insert UTC hours that belong to this local day _insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=100, br=50) _insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=200, br=80) clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) # 17:30 local summary = derive_local_day_summary(conn, "Asia/Kolkata", clock) assert summary is not None assert summary.local_date == "2026-09-01" assert summary.tz_name == "Asia/Kolkata" assert summary.tz_offset == "+05:30" assert summary.bytes_written == 0 assert summary.bytes_read == 0 assert summary.activity_precision == "unavailable" conn.close() def test_hourly_bytes_cannot_be_allocated_to_local_midnight(self, tmp_path): """UTC-hour totals do not establish exact local-day byte totals.""" conn = init_store(tmp_path / "obs.db") # For UTC+5:30, local day 2026-09-01 starts at 2026-08-31T18:30 UTC # UTC hour 2026-08-31T18:00 covers 18:00-19:00, straddling the midnight _insert_hour(conn, "2026-08-31T18:00:00+00:00", bw=100, br=50) _insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=200, br=80) clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "Asia/Kolkata", clock) assert summary is not None assert summary.bytes_written == 0 assert summary.bytes_read == 0 assert summary.activity_precision == "unavailable" conn.close() def test_no_hours_returns_none(self, tmp_path): conn = init_store(tmp_path / "obs.db") clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "UTC", clock) assert summary is None conn.close() # --------------------------------------------------------------------------- # Persistence and querying # --------------------------------------------------------------------------- class TestLocalDayPersistence: """Local-day summaries persist and can be queried.""" def test_persist_and_query(self, tmp_path): conn = init_store(tmp_path / "obs.db") summary = LocalDaySummary( local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00", utc_start="2026-09-01T00:00:00+00:00", utc_end="2026-09-02T00:00:00+00:00", bytes_written=300, bytes_read=130, coverage=0.5, sample_count=4, complete=False, ) created = persist_local_day(conn, summary) assert created is True # Upsert again — should update, not create created2 = persist_local_day(conn, summary) assert created2 is False result = query_local_day_summary(conn, "2026-09-01") assert result is not None assert result["bytes_written"] == 300 assert result["tz_name"] == "UTC" conn.close() def test_query_nonexistent_returns_none(self, tmp_path): conn = init_store(tmp_path / "obs.db") assert query_local_day_summary(conn, "2026-01-01") is None conn.close() def test_query_current_local_day(self, tmp_path): conn = init_store(tmp_path / "obs.db") summary = LocalDaySummary( local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00", utc_start="2026-09-01T00:00:00+00:00", utc_end="2026-09-02T00:00:00+00:00", bytes_written=500, bytes_read=200, coverage=0.8, sample_count=10, complete=True, ) persist_local_day(conn, summary) clock = datetime(2026, 9, 1, 15, 0, 0, tzinfo=timezone.utc) result = query_current_local_day(conn, clock, "UTC") assert result is not None assert result["bytes_written"] == 500 conn.close() # --------------------------------------------------------------------------- # Timezone change handling # --------------------------------------------------------------------------- class TestTimezoneChange: """Historical summaries retain their recorded timezone.""" def test_old_tz_preserved_after_change(self, tmp_path): conn = init_store(tmp_path / "obs.db") # Old summary in UTC old = LocalDaySummary( local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00", utc_start="2026-09-01T00:00:00+00:00", utc_end="2026-09-02T00:00:00+00:00", bytes_written=100, bytes_read=50, coverage=0.5, sample_count=5, complete=False, ) persist_local_day(conn, old) # System timezone changes to Asia/Kolkata # Old summary retains UTC result = query_local_day_summary(conn, "2026-09-01") assert result is not None assert result["tz_name"] == "UTC" assert result["tz_offset"] == "+00:00" conn.close() def test_different_tz_same_date_stored_separately(self, tmp_path): conn = init_store(tmp_path / "obs.db") # Two summaries for same date in different timezones for tz, offset in [("UTC", "+00:00"), ("Asia/Kolkata", "+05:30")]: summary = LocalDaySummary( local_date="2026-09-01", tz_name=tz, tz_offset=offset, utc_start="2026-09-01T00:00:00+00:00", utc_end="2026-09-02T00:00:00+00:00", bytes_written=100, bytes_read=50, coverage=0.5, sample_count=5, complete=False, ) persist_local_day(conn, summary) # Both exist independently — query by specific timezone utc_row = conn.execute( "SELECT tz_name, bytes_written FROM local_days " "WHERE local_date = '2026-09-01' AND tz_name = 'UTC'" ).fetchone() assert utc_row is not None assert utc_row[0] == "UTC" assert utc_row[1] == 100 in_row = conn.execute( "SELECT tz_name, bytes_written FROM local_days " "WHERE local_date = '2026-09-01' AND tz_name = 'Asia/Kolkata'" ).fetchone() assert in_row is not None assert in_row[0] == "Asia/Kolkata" conn.close() # --------------------------------------------------------------------------- # Coverage and completeness # --------------------------------------------------------------------------- class TestLocalDayCompleteness: """Complete days vs partial current day.""" def test_incomplete_when_missing_hours(self, tmp_path): conn = init_store(tmp_path / "obs.db") _insert_hour(conn, "2026-09-01T12:00:00+00:00", bw=100, br=50) clock = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "UTC", clock) assert summary is not None assert summary.complete is False assert summary.coverage < 1.0 conn.close() def test_coverage_ratio(self, tmp_path): conn = init_store(tmp_path / "obs.db") # 2 hours of known data out of 24 total UTC hours in a UTC day _insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, active=3600) _insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, active=3600) clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "UTC", clock) assert summary is not None # 7200 known seconds / 86400 total = ~0.083 assert summary.coverage == pytest.approx(7200 / 86400, rel=0.01) conn.close() # --------------------------------------------------------------------------- # Collector integration # --------------------------------------------------------------------------- class TestCollectorIntegration: """Collector derives local-day summaries during collection.""" def test_collection_creates_local_day(self, tmp_path, sysfs_tree): from fenris.collector import run_collection t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1)) run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2)) conn = sqlite3.connect(store) count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0] assert count >= 1 # Verify the local day has the expected write/read data row = conn.execute( "SELECT bytes_written, bytes_read, complete FROM local_days LIMIT 1" ).fetchone() assert row is not None assert row[0] == 50 * 512000 # writes assert row[1] == 30 * 512000 # reads conn.close() def test_interval_crossing_utc_hour_stays_wholly_in_local_day( self, tmp_path, sysfs_tree, monkeypatch, ): """A measured interval inside one local date keeps its full delta.""" from fenris.collector import run_collection monkeypatch.setenv("TZ", "Asia/Kolkata") store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" first_at = datetime(2026, 9, 1, 12, 59, tzinfo=timezone.utc) second_at = datetime(2026, 9, 1, 13, 1, tzinfo=timezone.utc) first = run_collection( _make_smartctl(10_000_000, 8_000_000), sysfs_nvme, cfg, _Clock(first_at), ) second = run_collection( _make_smartctl(10_000_010, 8_000_004), sysfs_nvme, cfg, _Clock(second_at), ) assert first["ok"] and second["ok"] conn = sqlite3.connect(store) summary = query_local_day_summary(conn, "2026-09-01") assert summary is not None assert summary["bytes_written"] == 5_120_000 assert summary["bytes_read"] == 2_048_000 assert summary["segments"] == [{ "segment_id": 1, "bytes_written": 5_120_000, "bytes_read": 2_048_000, "activity_seconds": 120, "activity_intervals": 1, }] segment_totals = conn.execute( "SELECT segment_id, bytes_written, bytes_read, activity_intervals " "FROM local_day_segment_totals" ).fetchone() assert segment_totals == (1, 5_120_000, 2_048_000, 1) samples = conn.execute( "SELECT id, ts, bytes_written, bytes_read, local_tz " "FROM samples ORDER BY id" ).fetchall() first_sample, second_sample = samples assert record_local_activity_interval( conn, dict(zip(("id", "ts", "bytes_written", "bytes_read", "local_tz"), first_sample)), dict(zip(("id", "ts", "bytes_written", "bytes_read", "local_tz"), second_sample)), start_sample_id=first_sample[0], end_sample_id=second_sample[0], segment_id=1, ) == "known" repeated = query_local_day_summary(conn, "2026-09-01", "Asia/Kolkata") assert repeated["bytes_written"] == 5_120_000 assert repeated["bytes_read"] == 2_048_000 assert conn.execute( "SELECT SUM(bytes_written), SUM(bytes_read), SUM(activity_intervals) " "FROM local_day_segment_totals" ).fetchone() == (5_120_000, 2_048_000, 1) conn.close() @pytest.mark.parametrize( ( "first_at", "second_at", "local_date", "expected_hours", "expected_start", "expected_end", "expected_offset", ), [ ( datetime(2026, 3, 8, 6, 30, tzinfo=timezone.utc), datetime(2026, 3, 8, 7, 30, tzinfo=timezone.utc), "2026-03-08", 23, "2026-03-08T05:00:00+00:00", "2026-03-09T04:00:00+00:00", "-05:00", ), ( datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc), datetime(2026, 11, 1, 6, 30, tzinfo=timezone.utc), "2026-11-01", 25, "2026-11-01T04:00:00+00:00", "2026-11-02T05:00:00+00:00", "-04:00", ), ], ids=("spring-forward", "fall-back-repeated-hour"), ) def test_collector_retains_exact_interval_on_dst_local_day( self, tmp_path, sysfs_tree, monkeypatch, first_at, second_at, local_date, expected_hours, expected_start, expected_end, expected_offset, ): """Collection preserves exact activity on short and long local days.""" from fenris.collector import run_collection zone = "America/New_York" monkeypatch.setenv("TZ", zone) store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" assert run_collection( _make_smartctl(10_000_000, 8_000_000), sysfs_nvme, cfg, _Clock(first_at), )["ok"] assert run_collection( _make_smartctl(10_000_001, 8_000_002), sysfs_nvme, cfg, _Clock(second_at), )["ok"] conn = sqlite3.connect(store) summary = query_local_day_summary( conn, local_date, zone, second_at + timedelta(minutes=1) ) assert summary is not None assert summary["bytes_written"] == 512_000 assert summary["bytes_read"] == 1_024_000 assert summary["activity_seconds"] == 3_600 assert summary["utc_start"] == expected_start assert summary["utc_end"] == expected_end assert summary["tz_offset"] == expected_offset assert ( datetime.fromisoformat(summary["utc_end"]) - datetime.fromisoformat(summary["utc_start"]) ).total_seconds() == expected_hours * 3_600 conn.close() @pytest.mark.asyncio async def test_local_midnight_interval_is_shared_once( self, tmp_path, sysfs_tree, monkeypatch, ): """A real counter interval crossing local midnight stays unallocated.""" from fenris.collector import run_collection monkeypatch.setenv("TZ", "Asia/Kolkata") store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" first_at = datetime(2026, 9, 1, 18, 25, tzinfo=timezone.utc) second_at = datetime(2026, 9, 1, 18, 35, tzinfo=timezone.utc) first = run_collection( _make_smartctl(10_000_000, 8_000_000), sysfs_nvme, cfg, _Clock(first_at), ) second = run_collection( _make_smartctl(10_000_100, 8_000_060), sysfs_nvme, cfg, _Clock(second_at), ) assert first["ok"] and second["ok"] conn = sqlite3.connect(store) evidence = conn.execute( "SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) " "FROM local_day_unallocated_evidence" ).fetchone() assert evidence == (1, 51_200_000, 30_720_000) assert conn.execute( "SELECT SUM(bytes_written), SUM(bytes_read) FROM local_days" ).fetchone() == (0, 0) for local_date in ("2026-09-01", "2026-09-02"): summary = query_local_day_summary( conn, local_date, "Asia/Kolkata", second_at ) assert summary is not None assert summary["bytes_written"] is None assert summary["shared_bytes_written"] == 51_200_000 assert summary["shared_bytes_read"] == 30_720_000 assert summary["activity_state"] in ("incomplete", "so_far") conn.close() from fenris.status import read_status from fenris.tui import FenrisTuiApp app = FenrisTuiApp(store_path=Path(store), refresh_interval_s=999) async with app.run_test(size=(100, 32)): app._browse_date = "2026-09-02" with read_status( Path(store), second_at, query_services=False ) as (reader, _): assert reader is not None app._render_local_day(reader) visible = str(app.query_one("#local-day").render()) assert "2026-09-02" in visible assert "Asia/Kolkata +05:30" in visible assert "W unavailable" in visible assert "shared at midnight W 0.051 GB" in visible def test_synthetic_shared_bytes_conserve_one_hundred_bytes(self, tmp_path): """Shared evidence conserves byte values before display rounding.""" conn = init_store(tmp_path / "obs.db") first_at = datetime(2026, 9, 1, 18, 25, tzinfo=timezone.utc) second_at = datetime(2026, 9, 3, 18, 35, tzinfo=timezone.utc) ensure_period_open(conn, first_at) conn.execute( "INSERT INTO samples (ts, device, bytes_written, bytes_read, local_tz) " "VALUES (?, ?, ?, ?, ?)", (first_at.isoformat(), "/dev/nvme0", 1_000, 2_000, "Asia/Kolkata"), ) first_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( "INSERT INTO samples (ts, device, bytes_written, bytes_read, local_tz) " "VALUES (?, ?, ?, ?, ?)", (second_at.isoformat(), "/dev/nvme0", 1_100, 2_050, "Asia/Kolkata"), ) second_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] result = record_local_activity_interval( conn, { "id": first_id, "ts": first_at.isoformat(), "bytes_written": 1_000, "bytes_read": 2_000, "local_tz": "Asia/Kolkata", }, { "id": second_id, "ts": second_at.isoformat(), "bytes_written": 1_100, "bytes_read": 2_050, "local_tz": "Asia/Kolkata", }, start_sample_id=first_id, end_sample_id=second_id, segment_id=1, ) assert result == "local_midnight" stored = conn.execute( "SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) " "FROM local_day_unallocated_evidence" ).fetchone() assert stored == (1, 100, 50) record_local_activity_interval( conn, { "id": first_id, "ts": first_at.isoformat(), "bytes_written": 1_000, "bytes_read": 2_000, "local_tz": "Asia/Kolkata", }, { "id": second_id, "ts": second_at.isoformat(), "bytes_written": 1_100, "bytes_read": 2_050, "local_tz": "Asia/Kolkata", }, start_sample_id=first_id, end_sample_id=second_id, segment_id=1, ) assert conn.execute( "SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) " "FROM local_day_unallocated_evidence" ).fetchone() == (1, 100, 50) assert conn.execute( "SELECT SUM(bytes_written), SUM(bytes_read) FROM local_days" ).fetchone() == (0, 0) middle_day = query_local_day_summary( conn, "2026-09-03", "Asia/Kolkata", second_at + timedelta(days=1) ) assert middle_day["shared_bytes_written"] == 100 assert middle_day["shared_bytes_read"] == 50 assert middle_day["shared_evidence_count"] == 1 conn.close() def test_timezone_change_keeps_interval_unallocated( self, tmp_path, sysfs_tree, monkeypatch, ): from fenris.collector import run_collection store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" first_at = datetime(2026, 9, 1, 22, 0, tzinfo=timezone.utc) second_at = datetime(2026, 9, 1, 22, 5, tzinfo=timezone.utc) monkeypatch.setenv("TZ", "UTC") assert run_collection( _make_smartctl(10_000_000, 8_000_000), sysfs_nvme, cfg, _Clock(first_at), )["ok"] monkeypatch.setenv("TZ", "Asia/Kolkata") assert run_collection( _make_smartctl(10_000_010, 8_000_004), sysfs_nvme, cfg, _Clock(second_at), )["ok"] conn = sqlite3.connect(store) event = conn.execute( "SELECT COUNT(*), SUM(bytes_written), MIN(reason) " "FROM local_day_unallocated_evidence" ).fetchone() assert event == (1, 5_120_000, "timezone_change") old_zone = query_local_day_summary(conn, "2026-09-01", "UTC") new_zone = query_local_day_summary(conn, "2026-09-02", "Asia/Kolkata") assert old_zone["bytes_written"] is None assert new_zone["bytes_written"] is None assert old_zone["unallocated_bytes_written"] == 5_120_000 assert new_zone["unallocated_bytes_written"] == 5_120_000 conn.close() def test_pause_boundary_keeps_interval_unallocated( self, tmp_path, sysfs_tree, monkeypatch, ): from fenris.collector import run_collection monkeypatch.setenv("TZ", "UTC") store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" first_at = datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc) second_at = datetime(2026, 9, 1, 12, 10, tzinfo=timezone.utc) assert run_collection( _make_smartctl(10_000_000, 8_000_000), sysfs_nvme, cfg, _Clock(first_at), )["ok"] conn = sqlite3.connect(store) close_period(conn, first_at + timedelta(minutes=5), "user_disabled") conn.close() assert run_collection( _make_smartctl(10_000_010, 8_000_004), sysfs_nvme, cfg, _Clock(second_at), )["ok"] conn = sqlite3.connect(store) reason = conn.execute( "SELECT reason FROM local_day_unallocated_evidence" ).fetchone() summary = query_local_day_summary(conn, "2026-09-01", "UTC") assert reason == ("monitoring_period",) assert summary["bytes_written"] is None assert summary["unallocated_bytes_written"] == 5_120_000 assert summary["shared_bytes_written"] == 0 conn.close() def test_controller_segment_boundary_marks_local_activity_incomplete( self, tmp_path, sysfs_tree, monkeypatch, ): from fenris.collector import run_collection monkeypatch.setenv("TZ", "UTC") store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" first_at = datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc) second_at = datetime(2026, 9, 1, 12, 5, tzinfo=timezone.utc) assert run_collection( _make_smartctl(10_000_000, 8_000_000), sysfs_nvme, cfg, _Clock(first_at), )["ok"] assert run_collection( _make_smartctl(10, 8_000_004), sysfs_nvme, cfg, _Clock(second_at), )["ok"] conn = sqlite3.connect(store) summary = query_local_day_summary( conn, "2026-09-01", "UTC", second_at ) assert summary is not None assert summary["bytes_written"] is None assert summary["activity_state"] == "incomplete" assert conn.execute( "SELECT COUNT(*) FROM local_day_unallocated_evidence" ).fetchone()[0] == 0 conn.close() # --------------------------------------------------------------------------- # DST handling (23/25-hour days) # --------------------------------------------------------------------------- class TestDSTHandling: """Local days with non-24-hour durations from DST transitions.""" def test_short_day_23_hours(self, tmp_path): """Spring forward: local day is 23 hours.""" conn = init_store(tmp_path / "obs.db") # Simulate a 23-hour day in a timezone with DST # For simplicity, just verify the summary records the correct UTC range clock = datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc) # New York springs forward on 2026-03-08 # Local day 2026-03-08 is 23 hours: UTC [07:00, 06:00+1d) _insert_hour(conn, "2026-03-08T08:00:00+00:00", bw=100, active=3600) _insert_hour(conn, "2026-03-08T12:00:00+00:00", bw=200, active=3600) summary = derive_local_day_summary(conn, "America/New_York", clock) assert summary is not None assert summary.local_date == "2026-03-08" # UTC range should be approximately 23 hours utc_start = datetime.fromisoformat(summary.utc_start) utc_end = datetime.fromisoformat(summary.utc_end) duration = (utc_end - utc_start).total_seconds() assert duration == 23 * 3600 conn.close() def test_long_day_25_hours(self): from fenris.local_day import local_day_boundaries start, end, offset = local_day_boundaries( "2026-11-01", "America/New_York" ) assert (end - start).total_seconds() == 25 * 3600 assert offset == "-04:00" def test_half_hour_offset_records_exact_local_boundaries(self): from fenris.local_day import local_day_boundaries start, end, offset = local_day_boundaries( "2026-09-01", "Asia/Kolkata" ) assert start.isoformat() == "2026-08-31T18:30:00+00:00" assert end.isoformat() == "2026-09-01T18:30:00+00:00" assert offset == "+05:30" def test_repeated_local_clock_labels_remain_one_recorded_day( self, tmp_path, sysfs_tree, monkeypatch, ): from fenris.collector import run_collection monkeypatch.setenv("TZ", "America/New_York") store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" # Both endpoints display as 01:30 locally, on opposite UTC offsets. first_at = datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc) second_at = datetime(2026, 11, 1, 6, 30, tzinfo=timezone.utc) assert run_collection( _make_smartctl(10_000_000, 8_000_000), sysfs_nvme, cfg, _Clock(first_at), )["ok"] assert run_collection( _make_smartctl(10_000_001, 8_000_002), sysfs_nvme, cfg, _Clock(second_at), )["ok"] conn = sqlite3.connect(store) summary = query_local_day_summary( conn, "2026-11-01", "America/New_York", second_at ) assert summary is not None assert summary["bytes_written"] == 512_000 assert summary["bytes_read"] == 1_024_000 assert summary["utc_start"] == "2026-11-01T04:00:00+00:00" assert summary["utc_end"] == "2026-11-02T05:00:00+00:00" conn.close()