Add local-day activity summaries derived from UTC hour observations using the system timezone, with durable storage in a new local_days table. The collector derives local-day read/write totals after UTC aggregation; the TUI displays them with timezone, completeness state, and coverage. Schema: bump SCHEMA_VERSION to 3, add local_days table (migration 2→3 is pure addition, idempotent, preserves newer-schema refusal).
419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""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,
|
|
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()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Local-day derivation from UTC hours
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDeriveLocalDay:
|
|
"""Derive local-day summaries from UTC hour observations."""
|
|
|
|
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 == 300
|
|
assert summary.bytes_read == 130
|
|
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 == 300
|
|
assert summary.bytes_read == 130
|
|
conn.close()
|
|
|
|
def test_midnight_spanning_hour_included(self, tmp_path):
|
|
"""UTC hour straddling local midnight is included in the local day."""
|
|
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
|
|
# The midnight-spanning hour (18:00) is included
|
|
assert summary.bytes_written == 300
|
|
assert summary.bytes_read == 130
|
|
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()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
# US/Eastern 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, "US/Eastern", 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 22 * 3600 <= duration <= 24 * 3600 # ~23h ± tolerance
|
|
conn.close()
|