"""Measured drive activity integration tests (issue #89). Drives successive controlled acquisition readings through the public collector into a real temporary observation store, then verifies the normal reader and visible dashboard report correct read/write deltas, hour/day evidence, accumulation, and boundary behaviour. Seams: - write side: run_collection() → observation store - read side: _query_daily_graph_data(), _query_hourly_graph_data(), derive_day(), get_status() → observation store """ 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.collector import run_collection from fenris.store import init_store from fenris.day_aggregate import derive_day from fenris.monitoring_periods import ensure_period_open from fenris.tui import _query_daily_graph_data, _query_hourly_graph_data # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]: """Build a smartctl fixture with specific DUW/DUR counters.""" 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: """Create a minimal sysfs fixture tree with controller identity.""" 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: """Injected clock returning controlled time.""" def __init__(self, initial: datetime): self.now = initial def utcnow(self): return self.now # --------------------------------------------------------------------------- # AC1: Two successive readings produce correct read/write deltas # visible through both the hour observations and the TUI query path. # --------------------------------------------------------------------------- class TestSuccessiveReadings: """First reading is an anchor; second yields a measured interval.""" def test_two_readings_produce_both_rw_deltas( self, tmp_path, sysfs_tree, ): 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" # DUW=10000000, DUR=8000000 r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1)) assert r1["ok"] # +50 DUW, +30 DUR r2 = run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2)) assert r2["ok"] conn = sqlite3.connect(store) # Hour observation should have both read and write deltas hour = conn.execute( "SELECT bytes_written_delta, bytes_read_delta " "FROM hour_observations WHERE hour LIKE '2026-09-01T12%'" ).fetchone() assert hour is not None assert hour[0] == 50 * 512000 # writes assert hour[1] == 30 * 512000 # reads # TUI graph query should report both daily = _query_daily_graph_data(conn) assert len(daily) >= 1 day_entry = daily[-1] bw_expected = 50 * 512000 br_expected = 30 * 512000 assert day_entry["total_written"] == bw_expected assert day_entry["total_read"] == br_expected assert day_entry["allocated_bytes"] == bw_expected assert day_entry["allocated_read"] == br_expected conn.close() def test_first_reading_is_anchor_no_delta( self, tmp_path, sysfs_tree, ): """First sample alone produces no hour observation or day aggregate.""" t1 = datetime(2026, 9, 1, 12, 0, 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)) conn = sqlite3.connect(store) assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1 assert conn.execute("SELECT COUNT(*) FROM hour_observations").fetchone()[0] == 0 daily = _query_daily_graph_data(conn) assert all(d["is_zero"] or d["is_gap"] for d in daily) conn.close() # --------------------------------------------------------------------------- # AC3: Repeated same-hour collections accumulate both reads and writes # --------------------------------------------------------------------------- class TestSameHourAccumulation: """Multiple intervals in the same hour accumulate both BW and BR.""" def test_three_same_hour_readings_accumulate(self, tmp_path, sysfs_tree): store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) # Three readings: 0→20→50 DUW, 0→10→35 DUR (all same hour) duw_seq = [10000000, 10000020, 10000050] dur_seq = [8000000, 8000010, 8000035] for i in range(3): t = base_t + timedelta(minutes=i * 3) r = run_collection( _make_smartctl(duw_seq[i], dur_seq[i]), sysfs_nvme, cfg, _Clock(t) ) assert r["ok"] conn = sqlite3.connect(store) hour = conn.execute( "SELECT bytes_written_delta, bytes_read_delta, sample_count " "FROM hour_observations WHERE hour LIKE '2026-09-01T12%'" ).fetchone() assert hour is not None # 0→20 + 20→50 = 50 DUW delta assert hour[0] == 50 * 512000 # 0→10 + 10→35 = 35 DUR delta assert hour[1] == 35 * 512000 # 2 intervals × 2 samples each = 4 sample_count assert hour[2] == 4 conn.close() def test_same_hour_zero_delta_both_counters( self, tmp_path, sysfs_tree, ): """Repeated identical readings produce zero in both BW and BR.""" store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) for i in range(3): t = base_t + timedelta(minutes=i * 3) r = run_collection( _make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t) ) assert r["ok"] conn = sqlite3.connect(store) hour = conn.execute( "SELECT bytes_written_delta, bytes_read_delta " "FROM hour_observations WHERE hour LIKE '2026-09-01T12%'" ).fetchone() assert hour is not None assert hour[0] == 0 assert hour[1] == 0 conn.close() # --------------------------------------------------------------------------- # AC4: Cross-hour measurements attributed to correct hours # --------------------------------------------------------------------------- class TestCrossHourAttribution: """Cross-hour deltas are unattributed to hours, kept at day level.""" def test_cross_hour_unattributed_bytes( self, tmp_path, sysfs_tree, ): store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc) t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1)) assert r1["ok"] r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2)) assert r2["ok"] conn = sqlite3.connect(store) # Hour 11 and 12 should NOT contain the full cross-hour delta full_bw = 100 * 512000 full_br = 60 * 512000 for prefix in ("2026-09-01T11%", "2026-09-01T12%"): row = conn.execute( "SELECT bytes_written_delta, bytes_read_delta " "FROM hour_observations WHERE hour LIKE ?", (prefix,) ).fetchone() if row is not None: assert row[0] != full_bw, "Hour should not have full cross-hour BW" assert row[1] != full_br, "Hour should not have full cross-hour BR" # Day aggregates should have unattributed bytes for both reads and writes for day in ("2026-09-01",): agg = derive_day(conn, day) assert agg is not None total_accounted = agg.bytes_written_delta + agg.bytes_read_delta unattributed_w = conn.execute( "SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?", (day,) ).fetchone() unattributed_r = conn.execute( "SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?", (day,) ).fetchone() # Unattributed bytes should be present assert unattributed_w is not None assert unattributed_r is not None conn.close() # --------------------------------------------------------------------------- # AC5: Repeated refresh does not duplicate measured bytes # --------------------------------------------------------------------------- class TestNoDuplication: """Repeated collection does not duplicate measured bytes.""" def test_multiple_same_hour_no_duplication( self, tmp_path, sysfs_tree, ): """Five same-hour reads, all monotonic: bytes never double-count.""" store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) # Monotonically increasing DUW: 100, 110, 125, 145, 180 duw_increments = [0, 10, 15, 20, 35] dur_increments = [0, 5, 8, 12, 20] for i in range(5): duw = 10000000 + sum(duw_increments[:i + 1]) dur = 8000000 + sum(dur_increments[:i + 1]) t = base_t + timedelta(minutes=i * 2) r = run_collection(_make_smartctl(duw, dur), sysfs_nvme, cfg, _Clock(t)) assert r["ok"] conn = sqlite3.connect(store) hour = conn.execute( "SELECT bytes_written_delta, bytes_read_delta " "FROM hour_observations WHERE hour LIKE '2026-09-01T12%'" ).fetchone() assert hour is not None # Total should be the cumulative delta across all 5 readings total_duw_delta = sum(duw_increments) total_dur_delta = sum(dur_increments) assert hour[0] == total_duw_delta * 512000 assert hour[1] == total_dur_delta * 512000 conn.close() # --------------------------------------------------------------------------- # AC: Consistent read-only snapshot # --------------------------------------------------------------------------- class TestConcurrentReadConsistency: """A read-only reader sees consistent pre- or post-publication state.""" def test_read_only_sees_consistent_state( self, tmp_path, sysfs_tree, ): 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)) # Open read-only before second sample ro_conn = sqlite3.connect("file:%s?mode=ro" % store, uri=True) ro_conn.execute("BEGIN") count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] assert count_before == 1 ro_conn.close() run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2)) # Read-only after second sample sees updated state ro_conn2 = sqlite3.connect("file:%s?mode=ro" % store, uri=True) ro_conn2.execute("BEGIN") count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0] assert count_after == 2 ro_conn2.close() # --------------------------------------------------------------------------- # AC: Counter reset / segment boundary # --------------------------------------------------------------------------- class TestCounterResetPreservesHistory: """Counter reset opens new segment without invalidating prior data.""" def test_duw_decrease_preserves_hour_data( self, tmp_path, sysfs_tree, ): t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) t3 = datetime(2026, 9, 1, 12, 10, 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" # Sample 1: normal run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t1)) # Sample 2: counter decrease (reset) run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2)) # Sample 3: new segment continuation run_collection(_make_smartctl(10000080, 8000050), sysfs_nvme, cfg, _Clock(t3)) conn = sqlite3.connect(store) # New segment opened for the reset seg_count = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0] assert seg_count >= 2 # The reset sample (t2) is in a new segment; t3 derives from t2 hour_12 = conn.execute( "SELECT bytes_written_delta, bytes_read_delta " "FROM hour_observations WHERE hour LIKE '2026-09-01T12%'" ).fetchone() assert hour_12 is not None # Hour 12 should have data from at least the valid interval (t2→t3) # t2→t3: 10000080-10000050=30 DUW, 8000050-8000030=20 DUR assert hour_12[0] >= 30 * 512000 assert hour_12[1] >= 20 * 512000 conn.close() # --------------------------------------------------------------------------- # AC: Monitoring period is opened and preserved # --------------------------------------------------------------------------- class TestMonitoringPeriodPreserved: """Collector opens monitoring period; subsequent samples keep it open.""" def test_first_sample_opens_period(self, tmp_path, sysfs_tree): t1 = datetime(2026, 9, 1, 12, 0, 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)) conn = sqlite3.connect(store) period = conn.execute( "SELECT COUNT(*) FROM monitoring_periods" ).fetchone()[0] conn.close() assert period == 1 def test_subsequent_samples_keep_one_period( self, tmp_path, sysfs_tree, ): 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) period_count = conn.execute( "SELECT COUNT(*) FROM monitoring_periods" ).fetchone()[0] conn.close() assert period_count == 1 # --------------------------------------------------------------------------- # AC: Derivation failure preserves sample # --------------------------------------------------------------------------- class TestDerivationFailurePreservesSample: """Sample persists even if derivation fails.""" def test_sample_survives_derivation_failure( self, tmp_path, sysfs_tree, ): 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)) # Second sample succeeds normally r2 = run_collection( _make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2) ) assert r2["ok"] conn = sqlite3.connect(store) count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] conn.close() assert count == 2 # --------------------------------------------------------------------------- # AC: Cross-day UTC boundary # --------------------------------------------------------------------------- class TestCrossDayBoundary: """Measurements crossing UTC day boundary are conserved.""" def test_cross_day_unattributed_both_days( self, tmp_path, sysfs_tree, ): t1 = datetime(2026, 9, 1, 23, 55, 0, tzinfo=timezone.utc) t2 = datetime(2026, 9, 2, 0, 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" r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1)) assert r1["ok"] r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2)) assert r2["ok"] conn = sqlite3.connect(store) daily = _query_daily_graph_data(conn) day_map = {d["day"]: d for d in daily} # Both days should appear assert "2026-09-01" in day_map assert "2026-09-02" in day_map # The cross-day delta is unattributed at the day level sep1 = day_map["2026-09-01"] sep2 = day_map["2026-09-02"] # Both days may show the unattributed bytes # (the delta is added to both days' unattributed totals as evidence) total_w = sep1["allocated_bytes"] + sep1["unallocated_bytes"] total_r = sep1["allocated_read"] + sep1["unallocated_read"] # Day 1 has at least some recorded data assert total_w >= 0 assert total_r >= 0 conn.close() # --------------------------------------------------------------------------- # AC: Hourly graph query includes read data # --------------------------------------------------------------------------- class TestHourlyQueryIncludesReads: """Hourly graph data includes bytes_read_delta.""" def test_hourly_query_returns_read_data( self, tmp_path, sysfs_tree, ): 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) hourly = _query_hourly_graph_data(conn, "2026-09-01", t2) # Hour 12 should have read data h12 = next(h for h in hourly if h["hour"] == "2026-09-01T12:00:00+00:00") assert h12["bytes_written"] == 50 * 512000 assert h12["bytes_read"] == 30 * 512000 conn.close() # --------------------------------------------------------------------------- # AC: Day aggregate derivation surfaces both read/write # --------------------------------------------------------------------------- class TestDayAggregateReadWrite: """Day aggregate includes both read and write deltas.""" def test_derive_day_shows_both_rw( self, tmp_path, sysfs_tree, ): 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) day = derive_day(conn, "2026-09-01") assert day is not None assert day.bytes_written_delta == 50 * 512000 assert day.bytes_read_delta == 30 * 512000 assert day.sample_count >= 2 conn.close()