925 lines
35 KiB
Python
925 lines
35 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,
|
|
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()
|