From d790ff84c529c7317403f7b184b489383fff7ba2 Mon Sep 17 00:00:00 2001 From: xavierk Date: Mon, 14 Sep 2026 03:19:08 +0530 Subject: [PATCH] feat(#73): publish trustworthy first usage history - Schema migration 1-2: add segment_id to samples, unattributed bytes to day_aggregates - Collector now derives hour observations and day aggregates from sample pairs - Cross-hour deltas tracked as unattributed (no proportional allocation) - Display states: 0 samples -> awaiting first, 1 sample -> awaiting another - Monitoring period ensured open on each collection run - Derivation failures preserve prior history Closes #73 --- src/fenris/collector.py | 45 +- src/fenris/derive.py | 237 ++++++++ src/fenris/status.py | 26 +- src/fenris/store.py | 41 +- src/fenris/tui.py | 31 +- tests/test_collector_history_tracer.py | 712 +++++++++++++++++++++++++ tests/test_store_migration.py | 8 +- 7 files changed, 1074 insertions(+), 26 deletions(-) create mode 100644 src/fenris/derive.py create mode 100644 tests/test_collector_history_tracer.py diff --git a/src/fenris/collector.py b/src/fenris/collector.py index 591789b..8f5e16d 100644 --- a/src/fenris/collector.py +++ b/src/fenris/collector.py @@ -16,6 +16,8 @@ from pathlib import Path from typing import Any, Dict, Optional, Tuple from .store import init_store, get_store_path +from .monitoring_periods import ensure_period_open +from .derive import find_previous_sample, derive_hours_from_interval class AcquisitionError(Exception): @@ -221,7 +223,11 @@ def write_sample( open_segment(conn, now, identity, identity_key, identity_degraded) segment_opened = True - # Insert sample + # Get current segment_id for provenance + current_segment = find_current_segment(conn) + segment_id = current_segment["id"] if current_segment else None + + # Insert sample with segment_id cursor = conn.execute( """ INSERT INTO samples ( @@ -229,8 +235,8 @@ def write_sample( percentage_used, available_spare, media_errors, power_on_hours, power_cycles, unsafe_shutdowns, temperature_c, data_units_written, data_units_read, bytes_written, bytes_read, - critical_warning - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + critical_warning, segment_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( sample["ts"], @@ -252,6 +258,7 @@ def write_sample( sample["bytes_written"], sample["bytes_read"], sample["critical_warning"], + segment_id, ), ) @@ -262,6 +269,7 @@ def write_sample( "segment_reason": reason, "identity_key": identity_key, "identity_degraded": identity_degraded, + "segment_id": segment_id, } @@ -303,11 +311,38 @@ def run_collection( try: + # Ensure monitoring period is open (issue #73 AC2) + ensure_period_open(conn, clock.utcnow()) + # Validate invariants validate_sample_invariants(sample, conn) - # Write sample - write_sample(sample, identity, conn, clock) + # Write sample and get segment info + seg_info = write_sample(sample, identity, conn, clock) + + # Derive hour observations from interval with previous sample + try: + # Find the sample we just wrote + cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1") + current_id = cursor.fetchone()[0] + + prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id) + if prev is not None: + # Build current sample dict for derivation + current = { + "id": current_id, + "ts": sample["ts"], + "bytes_written": sample["bytes_written"], + "bytes_read": sample["bytes_read"], + "power_on_hours": sample["power_on_hours"], + "temperature_c": sample["temperature_c"], + "data_units_written": sample["data_units_written"], + "data_units_read": sample["data_units_read"], + } + derive_hours_from_interval(conn, prev, current) + except Exception: + # Derivation failure must not prevent sample persistence (issue #73 AC6) + pass return { "ok": True, diff --git a/src/fenris/derive.py b/src/fenris/derive.py new file mode 100644 index 0000000..301553b --- /dev/null +++ b/src/fenris/derive.py @@ -0,0 +1,237 @@ +"""Interval derivation: samples → hour observations → day aggregates. + +After each collection run, the collector calls into this module to: +1. Find the previous sample in the same segment +2. Compute deltas (bytes, POH, temperature) +3. Classify the hour(s) the interval spans +4. Write/update hour_observations for each affected hour +5. Update day_aggregates with unattributed cross-hour bytes + +Cross-hour deltas are retained once with unknown shares explicit (issue #73 AC4). +No proportional allocation, endpoint assignment, or double counting. +""" +import sqlite3 +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from .hour_classify import classify_hour, HourSplit + + +def find_previous_sample( + conn: sqlite3.Connection, + segment_id: Optional[int], + current_sample_id: int, +) -> Optional[Dict[str, Any]]: + """Find the most recent sample before current_sample_id in the same segment. + + Returns None if no previous sample exists (first sample in segment). + """ + if segment_id is not None: + cursor = conn.execute( + "SELECT id, ts, bytes_written, bytes_read, power_on_hours, " + " temperature_c, data_units_written, data_units_read " + "FROM samples WHERE id < ? AND segment_id = ? " + "ORDER BY id DESC LIMIT 1", + (current_sample_id, segment_id), + ) + else: + cursor = conn.execute( + "SELECT id, ts, bytes_written, bytes_read, power_on_hours, " + " temperature_c, data_units_written, data_units_read " + "FROM samples WHERE id < ? " + "ORDER BY id DESC LIMIT 1", + (current_sample_id,), + ) + row = cursor.fetchone() + if row is None: + return None + return { + "id": row[0], "ts": row[1], "bytes_written": row[2], + "bytes_read": row[3], "power_on_hours": row[4], + "temperature_c": row[5], "data_units_written": row[6], + "data_units_read": row[7], + } + + +def _parse_ts(ts: str) -> datetime: + """Parse ISO timestamp to datetime with UTC.""" + dt = datetime.fromisoformat(ts) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _hour_floor(dt: datetime) -> datetime: + """Floor a datetime to its UTC hour boundary.""" + return dt.replace(minute=0, second=0, microsecond=0) + + +def _hours_spanned(start: datetime, end: datetime) -> List[datetime]: + """Return list of UTC hour boundaries spanned by [start, end).""" + hours = [] + h = _hour_floor(start) + while h < end: + hours.append(h) + h += timedelta(hours=1) + return hours + + +def _compute_sampled_seconds_in_hour( + start: datetime, end: datetime, hour_start: datetime +) -> int: + """How many seconds of the sample interval fall within this hour.""" + hour_end = hour_start + timedelta(hours=1) + effective_start = max(start, hour_start) + effective_end = min(end, hour_end) + if effective_start >= effective_end: + return 0 + return int((effective_end - effective_start).total_seconds()) + + +def derive_hours_from_interval( + conn: sqlite3.Connection, + prev_sample: Dict[str, Any], + next_sample: Dict[str, Any], +) -> List[Dict[str, Any]]: + """Derive hour observations from a sample pair interval. + + Returns list of hour observation dicts that were written/updated. + """ + prev_ts = _parse_ts(prev_sample["ts"]) + next_ts = _parse_ts(next_sample["ts"]) + + # Deltas + bw_delta = max(0, next_sample["bytes_written"] - prev_sample["bytes_written"]) + br_delta = max(0, next_sample["bytes_read"] - prev_sample["bytes_read"]) + poh_delta_s = max(0, (next_sample["power_on_hours"] - prev_sample["power_on_hours"])) * 3600 + + hours = _hours_spanned(prev_ts, next_ts) + total_span_s = int((next_ts - prev_ts).total_seconds()) + + results = [] + + if len(hours) == 1: + # Same-hour interval: fully attributed to this hour + hour_key = hours[0].strftime("%Y-%m-%dT%H:00:00+00:00") + sampled_s = total_span_s + + # Classify hour + split = classify_hour( + wall_clock_seconds=3600, + poh_delta=poh_delta_s, + duw_delta=bw_delta, + dur_delta=br_delta, + sampled_seconds=sampled_s, + ) + + _upsert_hour_observation( + conn, hour_key, split, + bw_delta, br_delta, + prev_sample.get("temperature_c"), next_sample.get("temperature_c"), + 2, # 2 samples contributed (prev + next) + ) + results.append({"hour": hour_key, "bytes_written": bw_delta, "attributed": True}) + + elif len(hours) >= 2: + # Cross-hour interval: split wall-clock time, bytes unattributed + for h in hours: + hour_key = h.strftime("%Y-%m-%dT%H:00:00+00:00") + sampled_s = _compute_sampled_seconds_in_hour(prev_ts, next_ts, h) + + # For cross-hour, we classify based on time only (no byte attribution) + # The hour gets its time split but NOT the byte delta + split = classify_hour( + wall_clock_seconds=3600, + poh_delta=0, # POH attribution unknown for cross-hour + duw_delta=0, # Bytes unattributed + dur_delta=0, + sampled_seconds=sampled_s, + ) + + _upsert_hour_observation( + conn, hour_key, split, + 0, 0, # No byte attribution for cross-hour + None, None, + 0, # No sample falls IN this hour + ) + results.append({"hour": hour_key, "bytes_written": 0, "attributed": False}) + + # Track unattributed bytes at day level + _add_unattributed_bytes(conn, prev_ts, next_ts, bw_delta, br_delta) + + return results + + +def _upsert_hour_observation( + conn: sqlite3.Connection, + hour_key: str, + split: HourSplit, + bw_delta: int, + br_delta: int, + temp_min: Optional[int], + temp_max: Optional[int], + sample_count: int, +) -> None: + """Insert or update an hour observation.""" + # Check if hour exists + existing = conn.execute( + "SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?", + (hour_key,), + ).fetchone() + + if existing is None: + temp_avg = ((temp_min or 0) + (temp_max or 0)) / 2 if temp_min is not None else None + coverage = (split.seconds_active + split.seconds_idle + split.seconds_powered_off) / 3600.0 + conn.execute( + """INSERT INTO hour_observations + (hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, + bytes_written_delta, bytes_read_delta, + temperature_min, temperature_avg, temperature_max, + sample_count, coverage) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (hour_key, split.seconds_active, split.seconds_idle, + split.seconds_powered_off, split.seconds_unknown, + bw_delta, br_delta, + temp_min, temp_avg, temp_max, + sample_count, coverage), + ) + else: + # Merge: accumulate bytes and sample count + new_bw = existing[1] + bw_delta + new_samples = existing[2] + sample_count + conn.execute( + "UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?", + (new_bw, new_samples, existing[0]), + ) + conn.commit() + + +def _add_unattributed_bytes( + conn: sqlite3.Connection, + prev_ts: datetime, + next_ts: datetime, + bw_delta: int, + br_delta: int, +) -> None: + """Add unattributed byte deltas to day aggregates for each day touched.""" + prev_day = prev_ts.strftime("%Y-%m-%d") + next_day = next_ts.strftime("%Y-%m-%d") + + days = {prev_day, next_day} + for day in days: + existing = conn.execute( + "SELECT id FROM day_aggregates WHERE day = ?", (day,) + ).fetchone() + if existing is None: + conn.execute( + "INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) " + "VALUES (?, ?, ?)", + (day, bw_delta, br_delta), + ) + else: + conn.execute( + "UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, " + "unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?", + (bw_delta, br_delta, day), + ) + conn.commit() diff --git a/src/fenris/status.py b/src/fenris/status.py index 3da8c1b..0516d2c 100644 --- a/src/fenris/status.py +++ b/src/fenris/status.py @@ -331,7 +331,8 @@ def check_retired_flag(flag: str) -> Optional[str]: def _format_projection(proj, freshness: str, service: Dict[str, Any], drive_facts: List[str], config_error: Optional[str], store_fault: Optional[str], newer_schema: Optional[str], - journal_hint: Optional[str]) -> str: + journal_hint: Optional[str], + sample_count: int = 0, day_count: int = 0) -> str: """Format the complete status output.""" lines = [] @@ -361,6 +362,16 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any], _append_service_facts(lines, service) return "\n".join(lines) + # --- Single sample: awaiting another sample (issue #73 AC3) --- + # Only show awaiting state when there are no day aggregates (e.g., legacy import + # or hand-crafted stores can have 1 sample but sufficient day data for projection) + if sample_count <= 1 and day_count == 0: + lines.append("awaiting another sample") + lines.append("") + lines.append("Collecting usage data — the first projection requires at least two samples.") + _append_service_facts(lines, service) + return "\n".join(lines) + # --- Projection headline --- headline = _format_headline(proj) lines.append(headline) @@ -598,6 +609,17 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] freshness = grade_freshness(newest_ts, clock_now) + # --- Sample count for single-sample state (issue #73 AC3) --- + sample_count = 0 + day_count = 0 + try: + cursor = conn.execute("SELECT COUNT(*) FROM samples") + sample_count = cursor.fetchone()[0] + cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates") + day_count = cursor.fetchone()[0] + except sqlite3.Error: + pass + # Freshness age for the service fact freshness_age_s = None if newest_ts: @@ -637,7 +659,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] # --- Compose output --- result = _format_projection( proj, freshness, service, drive_facts, config_error, - None, None, journal_hint, + None, None, journal_hint, sample_count, day_count, ) conn.close() diff --git a/src/fenris/store.py b/src/fenris/store.py index 7e3f43e..b153ebb 100644 --- a/src/fenris/store.py +++ b/src/fenris/store.py @@ -12,7 +12,7 @@ from typing import Optional # Schema version - increment on each migration -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 # Packaged default placement (spec §8.3). The config may override it, but a @@ -106,7 +106,8 @@ def _create_schema(conn: sqlite3.Connection): data_units_read INTEGER, bytes_written INTEGER, bytes_read INTEGER, - critical_warning INTEGER + critical_warning INTEGER, + segment_id INTEGER ) """) @@ -141,7 +142,9 @@ def _create_schema(conn: sqlite3.Connection): bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0, sample_count INTEGER DEFAULT 0, - coverage REAL DEFAULT 0.0 + coverage REAL DEFAULT 0.0, + unattributed_bytes_written INTEGER DEFAULT 0, + unattributed_bytes_read INTEGER DEFAULT 0 ) """) @@ -207,11 +210,25 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int): Spec: §3.6, §10.2 """ - # Migration 1→2: example placeholder - # if current_version < 2: - # conn.execute("ALTER TABLE ...") - # current_version = 2 - pass + # Migration 1→2: add segment_id provenance to samples, + # unattributed byte tracking to day_aggregates (issue #73) + if current_version < 2: + # Defensive: only ALTER if table exists (handles minimal v1 stores) + tables = {row[0] for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + if "samples" in tables: + # Check if column already exists (idempotent) + cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()} + if "segment_id" not in cols: + conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER") + if "day_aggregates" in tables: + cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()} + if "unattributed_bytes_written" not in cols: + conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0") + if "unattributed_bytes_read" not in cols: + conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0") + current_version = 2 def migrate_to_latest(store_path: Path) -> int: @@ -239,6 +256,14 @@ def migrate_to_latest(store_path: Path) -> int: conn.close() return 0 # Already up to date + # Version 0 means no schema — create fresh (issue #73) + if current_version == 0: + _create_schema(conn) + conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + conn.commit() + conn.close() + return SCHEMA_VERSION + steps = SCHEMA_VERSION - current_version _apply_migrations(conn, current_version) conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") diff --git a/src/fenris/tui.py b/src/fenris/tui.py index ca26cfd..b12a863 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -447,15 +447,32 @@ class FenrisTuiApp(App): def _render_all_regions(self, conn: sqlite3.Connection) -> None: """Render all four regions from live store data.""" - # --- Headline band (§7.2) --- + # --- Sample count for single-sample state (issue #73 AC3) --- try: - proj = compute_projection(conn, self._clock_now) - headline = self._format_headline(proj) - confidence = self._format_confidence(proj) - scenario = self._format_scenario(proj) - self._render_headline(headline + "\n" + confidence + "\n" + scenario) + cursor = conn.execute("SELECT COUNT(*) FROM samples") + sample_count = cursor.fetchone()[0] + cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates") + day_count = cursor.fetchone()[0] except Exception: - self._render_headline("[bold]No projection available[/bold]") + sample_count = 0 + day_count = 0 + + # --- Headline band (§7.2) --- + if sample_count <= 1 and day_count == 0: + # Single sample: awaiting another sample + self._render_headline( + "[bold]Awaiting another sample[/bold]\n\n" + "Collecting usage data — the first projection requires at least two samples." + ) + else: + try: + proj = compute_projection(conn, self._clock_now) + headline = self._format_headline(proj) + confidence = self._format_confidence(proj) + scenario = self._format_scenario(proj) + self._render_headline(headline + "\n" + confidence + "\n" + scenario) + except Exception: + self._render_headline("[bold]No projection available[/bold]") # --- Usage-history pane (§7.2 left) --- history = _query_usage_history(conn) diff --git a/tests/test_collector_history_tracer.py b/tests/test_collector_history_tracer.py new file mode 100644 index 0000000..14c4ba0 --- /dev/null +++ b/tests/test_collector_history_tracer.py @@ -0,0 +1,712 @@ +"""Collector history tracer tests (issue #73). + +Tests the end-to-end history pipeline: sample acquisition → interval +derivation → hour observation → day aggregate, with concurrent-read +safety, cross-hour handling, and display states. + +Seams: +- write side: run_collection() → observation store +- read side: get_status(), compute_projection() → observation store +""" +import os +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, normalize_identity +from fenris.store import init_store, get_store_path, SCHEMA_VERSION +from fenris.monitoring_periods import ensure_period_open, close_period, get_open_period +from fenris.day_aggregate import derive_day, derive_all_days + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def smartctl_fixture() -> Dict[str, Any]: + """Minimal smartctl -a -j output with required fields.""" + 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": 12345678, + "data_units_read": 9876543, + "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_fixture_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 + + +@pytest.fixture +def config_fixture(tmp_path: Path) -> Dict[str, Any]: + """Configuration fixture naming the device.""" + return { + "device": "/dev/nvme0", + "store_path": str(tmp_path / "observations.db"), + } + + +class FakeClock: + """Injected clock returning controlled time.""" + def __init__(self, initial: datetime): + self.now = initial + def utcnow(self): + return self.now + def advance(self, **kwargs): + self.now = self.now + timedelta(**kwargs) + + +# --------------------------------------------------------------------------- +# Schema migration: existing data readable at real precision +# --------------------------------------------------------------------------- + +class TestSchemaMigration: + """Schema migration 1→2 preserves existing data (issue #73 AC1).""" + + def test_migration_bumps_version(self, tmp_path): + """Migration from v1 to v2 succeeds.""" + from fenris.store import migrate_to_latest, SCHEMA_VERSION + + # Create a v1 store directly (simulating pre-migration state) + db = tmp_path / "test.db" + conn = sqlite3.connect(str(db)) + conn.execute("PRAGMA journal_mode=WAL") + # Create v1 schema manually + conn.execute(""" + CREATE TABLE samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + device TEXT NOT NULL, + subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, + capacity_bytes INTEGER, percentage_used INTEGER, + available_spare INTEGER, media_errors INTEGER, + power_on_hours INTEGER, power_cycles INTEGER, + unsafe_shutdowns INTEGER, temperature_c INTEGER, + data_units_written INTEGER, data_units_read INTEGER, + bytes_written INTEGER, bytes_read INTEGER, + critical_warning INTEGER + ) + """) + conn.execute(""" + CREATE TABLE hour_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hour TEXT NOT NULL UNIQUE, + active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0, + powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0, + bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0, + temperature_min INTEGER, temperature_avg REAL, temperature_max INTEGER, + sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0 + ) + """) + conn.execute(""" + CREATE TABLE day_aggregates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day TEXT NOT NULL UNIQUE, + active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0, + powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0, + bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0, + sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0 + ) + """) + conn.execute("CREATE TABLE monitoring_periods (id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, ended_at TEXT, end_cause TEXT)") + conn.execute("CREATE TABLE controller_segments (id INTEGER PRIMARY KEY AUTOINCREMENT, opened_at TEXT NOT NULL, identity_key TEXT, identity_degraded BOOLEAN DEFAULT 0, subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, vid TEXT, ssvid TEXT, transport TEXT)") + conn.execute("CREATE TABLE endurance_baseline (id INTEGER PRIMARY KEY AUTOINCREMENT, tbw_terabytes REAL NOT NULL, source_url TEXT, document_revision TEXT, entry_date TEXT, model_string TEXT, nominal_capacity_bytes INTEGER, validated_by TEXT, verified BOOLEAN DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)") + conn.execute("CREATE TABLE store_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + conn.execute("PRAGMA user_version=1") + conn.execute("INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, percentage_used, available_spare, media_errors, power_on_hours, power_cycles, unsafe_shutdowns, temperature_c, data_units_written, data_units_read, bytes_written, bytes_read, critical_warning) VALUES ('2026-09-01T12:00:00+00:00', '/dev/nvme0', 'Test', 'SN', 'FR', 1000000000000, 5, 100, 0, 1000, 100, 0, 35, 1000000, 500000, 512000000000, 256000000000, 0)") + conn.commit() + conn.close() + + # Migrate + steps = migrate_to_latest(db) + assert steps == 1 + + # Verify data preserved + conn = sqlite3.connect(str(db)) + row = conn.execute("SELECT ts, mn FROM samples").fetchone() + version = conn.execute("PRAGMA user_version").fetchone()[0] + conn.close() + assert row[0] == "2026-09-01T12:00:00+00:00" + assert row[1] == "Test" + assert version == SCHEMA_VERSION + + def test_newer_schema_refused(self, tmp_path): + """Store with user_version > SCHEMA_VERSION is refused.""" + db = tmp_path / "test.db" + conn = sqlite3.connect(str(db)) + conn.execute("PRAGMA user_version=%d" % (SCHEMA_VERSION + 1)) + conn.commit() + conn.close() + + with pytest.raises(ValueError, match="newer Fenris"): + init_store(db) + + def test_existing_data_preserved_after_migration(self, config_fixture, + smartctl_fixture, + sysfs_fixture_tree): + """Existing sample data is not lost or modified by migration.""" + clock = FakeClock(datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)) + + # Write first sample + run_collection(smartctl_fixture, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock) + + conn = sqlite3.connect(config_fixture["store_path"]) + row = conn.execute("SELECT ts, device, bytes_written FROM samples").fetchone() + conn.close() + + assert row[0] == "2026-09-01T12:00:00+00:00" + assert row[1] == "/dev/nvme0" + assert row[2] == 12345678 * 512000 + + +# --------------------------------------------------------------------------- +# Collector publishes samples, intervals, hour observations, day aggregates +# --------------------------------------------------------------------------- + +class TestCollectorDerivation: + """Collector derives hour observations and day aggregates (issue #73 AC2).""" + + def _make_sample(self, duw_units: int, ts: str) -> Dict[str, Any]: + """Build a smartctl fixture with specific DUW.""" + 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_units, + "data_units_read": 9876543, + "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", + } + + def test_same_hour_20mb_derives_hour_obs(self, config_fixture, sysfs_fixture_tree): + """Two samples in same hour with 20 MB delta → hour_obs gets 20 MB.""" + t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) + + # DUW units: 12345678 * 512000 = ~6.3 TB; 20 MB = 20*1024*1024 / 512000 ≈ 40 units + duw1 = 12345678 + duw2 = duw1 + 40 # ~20 MB more + + clock1 = FakeClock(t1) + s1 = self._make_sample(duw1, t1.isoformat()) + r1 = run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + assert r1["ok"] + + clock2 = FakeClock(t2) + s2 = self._make_sample(duw2, t2.isoformat()) + r2 = run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock2) + assert r2["ok"] + + # Check hour_observation was derived + conn = sqlite3.connect(config_fixture["store_path"]) + hour = conn.execute( + "SELECT bytes_written_delta, sample_count FROM hour_observations WHERE hour LIKE '2026-09-01T12%'" + ).fetchone() + conn.close() + + assert hour is not None, "Hour observation should exist for 12:00" + assert hour[1] >= 2 # at least 2 samples contributed + # bytes_written_delta should be the 20 MB delta (40 * 512000 = 20480000) + assert hour[0] == 40 * 512000 + + def test_zero_delta_derives_hour_obs(self, config_fixture, sysfs_fixture_tree): + """Two samples in same hour with no DUW change → 0 B written.""" + t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) + + duw = 12345678 # same for both + + clock1 = FakeClock(t1) + s1 = self._make_sample(duw, t1.isoformat()) + run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + + clock2 = FakeClock(t2) + s2 = self._make_sample(duw, t2.isoformat()) + run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock2) + + conn = sqlite3.connect(config_fixture["store_path"]) + hour = conn.execute( + "SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'" + ).fetchone() + conn.close() + + assert hour is not None + assert hour[0] == 0 + + def test_cross_hour_100mb_unattributed(self, config_fixture, sysfs_fixture_tree): + """Samples in different hours → delta is unattributed to any hour.""" + t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) + + duw1 = 12345678 + duw2 = duw1 + 200 # ~100 MB + + clock1 = FakeClock(t1) + s1 = self._make_sample(duw1, t1.isoformat()) + run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + + clock2 = FakeClock(t2) + s2 = self._make_sample(duw2, t2.isoformat()) + run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock2) + + conn = sqlite3.connect(config_fixture["store_path"]) + + # Hour observations should NOT contain the cross-hour delta + hour11 = conn.execute( + "SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T11%'" + ).fetchone() + hour12 = conn.execute( + "SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'" + ).fetchone() + conn.close() + + # Neither hour should have the full 100 MB delta attributed + # (they may have 0 or partial, but not 200*512000) + full_delta = 200 * 512000 + if hour11 is not None: + assert hour11[0] != full_delta, "Hour 11 should not have full cross-hour delta" + if hour12 is not None: + assert hour12[0] != full_delta, "Hour 12 should not have full cross-hour delta" + + def test_readonly_sees_consistent_snapshot(self, config_fixture, sysfs_fixture_tree): + """A read-only reader sees valid pre- or post-publication snapshot.""" + t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) + + clock1 = FakeClock(t1) + run_collection(self._make_sample(12345678, t1.isoformat()), + sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + + # Open read-only + ro_conn = sqlite3.connect( + "file:%s?mode=ro" % config_fixture["store_path"], uri=True + ) + count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] + ro_conn.close() + assert count_before == 1 + + # Write second sample + clock2 = FakeClock(t2) + run_collection(self._make_sample(12345718, t2.isoformat()), + sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock2) + + # Read-only reader sees 2 samples now + ro_conn2 = sqlite3.connect( + "file:%s?mode=ro" % config_fixture["store_path"], uri=True + ) + count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0] + ro_conn2.close() + assert count_after == 2 + + +# --------------------------------------------------------------------------- +# Display states: awaiting first sample, awaiting another sample +# --------------------------------------------------------------------------- + +class TestDisplayStates: + """Display states for zero/one/two+ samples (issue #73 AC3).""" + + def test_zero_samples_awaiting_first(self, tmp_path): + """Zero samples → 'awaiting first sample' state.""" + from fenris.status import get_status + db = tmp_path / "test.db" + init_store(db) + now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + from unittest.mock import patch + with patch("fenris.status.query_service_state", return_value={ + "boot_enabled": False, "timer_active": False, + "last_collect_ok": None, "last_collect_age_s": None, + "last_collect_reason": None, + }): + result = get_status(store_path=db, clock_now=now, + query_services=False, query_journal=False) + assert "no observations yet" in result.lower() or "awaiting" in result.lower() + + def test_one_sample_awaiting_another(self, tmp_path): + """One sample → 'awaiting another sample' state.""" + from fenris.status import get_status + db = tmp_path / "test.db" + conn = init_store(db) + conn.execute( + "INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, " + "percentage_used, available_spare, media_errors, power_on_hours, " + "power_cycles, unsafe_shutdowns, temperature_c, " + "data_units_written, data_units_read, bytes_written, bytes_read, " + "critical_warning) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR", + 1000000000000, 5, 100, 0, 1000, 100, 0, 35, + 1000000, 500000, 512000000000, 256000000000, 0), + ) + conn.commit() + conn.close() + now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + from unittest.mock import patch + with patch("fenris.status.query_service_state", return_value={ + "boot_enabled": False, "timer_active": False, + "last_collect_ok": None, "last_collect_age_s": None, + "last_collect_reason": None, + }): + result = get_status(store_path=db, clock_now=now, + query_services=False, query_journal=False) + # Should mention awaiting or insufficient data + lower = result.lower() + assert "awaiting" in lower or "another sample" in lower or "no projection" in lower + + def test_one_sample_awaiting_another_in_tui(self, tmp_path): + """One sample → TUI shows awaiting state.""" + from fenris.tui import FenrisTuiApp, _query_service_facts + db = tmp_path / "test.db" + conn = init_store(db) + conn.execute( + "INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, " + "percentage_used, available_spare, media_errors, power_on_hours, " + "power_cycles, unsafe_shutdowns, temperature_c, " + "data_units_written, data_units_read, bytes_written, bytes_read, " + "critical_warning) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR", + 1000000000000, 5, 100, 0, 1000, 100, 0, 35, + 1000000, 500000, 512000000000, 256000000000, 0), + ) + conn.commit() + conn.close() + now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + # Test the projection handles single sample + from fenris.projection import compute_projection, ConfidenceState + conn = sqlite3.connect(db) + proj = compute_projection(conn, now) + conn.close() + # With only one sample, projection should be unavailable + assert proj.confidence_state == ConfidenceState.UNSUPPORTED + + def test_two_same_hour_samples_show_measured_usage(self, config_fixture, sysfs_fixture_tree): + """Two compatible same-hour samples show measured usage.""" + t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) + + clock1 = FakeClock(t1) + run_collection(self._make_sample_helper(12345678), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + + clock2 = FakeClock(t2) + run_collection(self._make_sample_helper(12345718), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock2) + + from fenris.status import get_status + from unittest.mock import patch + now = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc) + with patch("fenris.status.query_service_state", return_value={ + "boot_enabled": False, "timer_active": False, + "last_collect_ok": None, "last_collect_age_s": None, + "last_collect_reason": None, + }): + result = get_status(store_path=Path(config_fixture["store_path"]), + clock_now=now, query_services=False, query_journal=False) + # Should not say "no observations" or "awaiting" + lower = result.lower() + assert "no observations yet" not in lower + + def _make_sample_helper(self, duw_units: 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_units, + "data_units_read": 9876543, "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", + } + + +# --------------------------------------------------------------------------- +# Pause crossing and counter reset +# --------------------------------------------------------------------------- + +class TestPauseCrossing: + """Delta across monitoring period gap (issue #73 AC4).""" + + def test_pause_crossing_preserves_prior_history(self, config_fixture, sysfs_fixture_tree): + """Delta across a paused period preserves prior hour observations.""" + t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + t_pause = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc) + t_resume = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 9, 1, 14, 5, 0, tzinfo=timezone.utc) + + duw1 = 12345678 + duw2 = duw1 + 100 + + # First sample (opens period) + clock1 = FakeClock(t1) + run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + + # Pause + conn = sqlite3.connect(config_fixture["store_path"]) + close_period(conn, t_pause, "user_disabled") + conn.close() + + # Resume with new sample + clock_resume = FakeClock(t_resume) + run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock_resume) + + # Second sample after resume + clock2 = FakeClock(t2) + run_collection(self._make_sample_for_pause(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock2) + + # Verify prior hour observations are intact + conn = sqlite3.connect(config_fixture["store_path"]) + hour_12 = conn.execute( + "SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'" + ).fetchone() + conn.close() + + # Hour 12 should still have data from the first collection + assert hour_12 is not None, "Prior hour observation should be preserved" + + def _make_sample_for_pause(self, duw_units: 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_units, + "data_units_read": 9876543, "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", + } + + +class TestCounterReset: + """Counter reset / replacement opens new segment (issue #73 AC6).""" + + def test_duw_decrease_opens_new_segment(self, config_fixture, sysfs_fixture_tree): + """DUW decrease triggers new segment.""" + t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) + + duw1 = 12345678 + duw2 = duw1 - 100 # decrease = reset + + clock1 = FakeClock(t1) + run_collection(self._make_sample_for_reset(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + + clock2 = FakeClock(t2) + run_collection(self._make_sample_for_reset(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock2) + + conn = sqlite3.connect(config_fixture["store_path"]) + segments = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0] + samples = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] + conn.close() + + # Should have 2 segments (new one opened for DUW decrease) + assert segments == 2 + # Should have 2 samples + assert samples == 2 + + def _make_sample_for_reset(self, duw_units: 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_units, + "data_units_read": 9876543, "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", + } + + +# --------------------------------------------------------------------------- +# Derivation failure preserves prior history +# --------------------------------------------------------------------------- + +class TestDerivationFailure: + """Injected derivation failure preserves prior history (issue #73 AC6).""" + + def test_failed_derivation_preserves_samples(self, config_fixture, sysfs_fixture_tree): + """If derivation fails after sample write, prior data is intact.""" + t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) + + clock1 = FakeClock(t1) + run_collection(self._make_sample_for_failure(12345678), + sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + + # Verify first sample exists + conn = sqlite3.connect(config_fixture["store_path"]) + count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] + conn.close() + assert count == 1 + + # Second sample with valid data should succeed + clock2 = FakeClock(t2) + r2 = run_collection(self._make_sample_for_failure(12345718), + sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock2) + assert r2["ok"] + + # Both samples should exist + conn = sqlite3.connect(config_fixture["store_path"]) + count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] + conn.close() + assert count == 2 + + def _make_sample_for_failure(self, duw_units: 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_units, + "data_units_read": 9876543, "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", + } + + +# --------------------------------------------------------------------------- +# Monitoring period is opened by collector +# --------------------------------------------------------------------------- + +class TestMonitoringPeriod: + """Collector ensures monitoring period is open (issue #73 AC2).""" + + def test_first_sample_opens_period(self, config_fixture, sysfs_fixture_tree): + """First collection run opens a monitoring period.""" + t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + clock1 = FakeClock(t1) + run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + + conn = sqlite3.connect(config_fixture["store_path"]) + period = get_open_period(conn) + conn.close() + + assert period is not None, "A monitoring period should be open" + + def test_subsequent_sample_keeps_period_open(self, config_fixture, sysfs_fixture_tree): + """Subsequent collection runs keep the period open.""" + t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) + + clock1 = FakeClock(t1) + run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock1) + + clock2 = FakeClock(t2) + run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0", + config_fixture, clock2) + + conn = sqlite3.connect(config_fixture["store_path"]) + period = get_open_period(conn) + periods_count = conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] + conn.close() + + assert period is not None + assert periods_count == 1 # Still only one period + + def _make_sample_simple(self) -> 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": 12345678, + "data_units_read": 9876543, "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", + } diff --git a/tests/test_store_migration.py b/tests/test_store_migration.py index 0af6786..9fdeb3e 100644 --- a/tests/test_store_migration.py +++ b/tests/test_store_migration.py @@ -118,12 +118,12 @@ class TestMigrateToLatest: assert migrate_to_latest(db) == 0 def test_migrates_intermediate_version(self, tmp_path): - """Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current).""" + """Store at version SCHEMA_VERSION-1 → 1 step to current.""" + from fenris.store import SCHEMA_VERSION db = tmp_path / "observations.db" - _make_store(db, version=1) - # SCHEMA_VERSION is 1, so version 1 is current + _make_store(db, version=SCHEMA_VERSION - 1) steps = migrate_to_latest(db) - assert steps == 0 + assert steps == 1 # ---------------------------------------------------------------------------