From 88672e12bf77ca9f3e9fd9cc3200437ca8ef8417 Mon Sep 17 00:00:00 2001 From: xavierk Date: Fri, 18 Sep 2026 17:18:22 +0530 Subject: [PATCH] test: add edge-case tests for issue #72 acceptance criteria --- tests/test_issue_72_edge_cases.py | 376 ++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 tests/test_issue_72_edge_cases.py diff --git a/tests/test_issue_72_edge_cases.py b/tests/test_issue_72_edge_cases.py new file mode 100644 index 0000000..4aaf2c8 --- /dev/null +++ b/tests/test_issue_72_edge_cases.py @@ -0,0 +1,376 @@ +"""Edge-case tests for issue #72 remaining acceptance criteria. + +Covers: +- TPH-#34-36: Pause-crossing intervals +- TPH-#59: Staleness frozen estimate wording +- TPH-#62: Zero-delta evidence in live graph +- TPH-#63: Horizon anchoring at evidence endpoint +- TPH-#64: Burst/habit checks with unknown daily shares +""" +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from fenris.store import init_store +from fenris.monitoring_periods import ensure_period_open, close_period +from fenris.derive import derive_hours_from_interval +from fenris.day_aggregate import derive_day +from fenris.projection import compute_projection, ConfidenceState, STALENESS_HOURS +from fenris.tui import _query_live_graph_data, _query_daily_graph_data + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _clock(year=2026, month=10, day=1, hour=12): + return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc) + + +def _insert_sample(conn, ts, bw=512000000000, br=256000000000, segment_id=1): + conn.execute( + "INSERT INTO samples (ts, device, data_units_written, data_units_read, " + "percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (ts, "/dev/nvme0n1", 1000000, 500000, 5, bw, br, 8765, segment_id), + ) + conn.commit() + + +def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00", + identity_key="nqn.test", degraded=False): + conn.execute( + "INSERT INTO controller_segments " + "(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (opened_at, identity_key, degraded, "nqn.test", "SN123", + "Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"), + ) + conn.commit() + + +def _insert_baseline(conn, tbw_tb=1.0, verified=True): + conn.execute( + "INSERT INTO endurance_baseline " + "(tbw_terabytes, source_url, document_revision, entry_date, model_string, " + " nominal_capacity_bytes, validated_by, verified, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01", + "Samsung SSD 970 EVO Plus 1TB", 1024000000000, + "machine_match" if verified else None, verified, + "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"), + ) + conn.commit() + + +def _insert_day(conn, day, bw=10*1024*1024*1024, br=0, coverage=0.95, samples=24): + """Insert a day aggregate with ~10GB writes (realistic for projection).""" + conn.execute( + "INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, " + "unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (day, 3600, 0, 0, 0, bw, br, samples, coverage), + ) + conn.commit() + + +def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00", + utc_start=None, utc_end=None, bw=10*1024*1024*1024, + br=0, coverage=0.95, samples=24, complete=True): + """Insert a local_days row for testing the observation day gate.""" + if utc_start is None: + utc_start = local_date + "T00:00:00+00:00" + if utc_end is None: + dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1) + utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00") + conn.execute( + "INSERT INTO local_days " + "(local_date, tz_name, tz_offset, utc_start, utc_end, " + " bytes_written, bytes_read, coverage, sample_count, complete) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (local_date, tz_name, tz_offset, utc_start, utc_end, + bw, br, coverage, samples, complete), + ) + conn.commit() + + +def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1, + active=3600, idle=0, powered_off=0, unknown=0): + 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() + + +# --------------------------------------------------------------------------- +# TPH-#34-36: Pause-crossing intervals +# --------------------------------------------------------------------------- + +class TestPauseCrossingIntervals: + """Intervals crossing a pause boundary preserve evidence correctly.""" + + def test_interval_crossing_pause_preserves_total(self, tmp_path): + """An interval that spans a pause boundary keeps the full delta + in hour observations; the monitoring-period exclusion happens at + the day-aggregate coverage level.""" + conn = init_store(tmp_path / "test.db") + _insert_segment(conn) + + t1 = datetime(2026, 10, 1, 10, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 10, 1, 11, 0, 0, tzinfo=timezone.utc) + t3 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc) + + ensure_period_open(conn, t1) + _insert_sample(conn, t1.isoformat(), bw=100000000000) + _insert_sample(conn, t2.isoformat(), bw=200000000000) + + prev = {"id": 1, "ts": t1.isoformat(), "bytes_written": 100000000000, + "bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35, + "data_units_written": 1000000, "data_units_read": 0} + curr = {"id": 2, "ts": t2.isoformat(), "bytes_written": 200000000000, + "bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35, + "data_units_written": 2000000, "data_units_read": 0} + derive_hours_from_interval(conn, prev, curr) + + close_period(conn, t2, "user_disabled") + ensure_period_open(conn, t3) + _insert_sample(conn, t3.isoformat(), bw=300000000000) + + prev2 = {"id": 2, "ts": t2.isoformat(), "bytes_written": 200000000000, + "bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35, + "data_units_written": 2000000, "data_units_read": 0} + curr2 = {"id": 3, "ts": t3.isoformat(), "bytes_written": 300000000000, + "bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35, + "data_units_written": 3000000, "data_units_read": 0} + derive_hours_from_interval(conn, prev2, curr2) + + hour11 = conn.execute( + "SELECT bytes_written_delta FROM hour_observations WHERE hour = ?", + ("2026-10-01T11:00:00+00:00",) + ).fetchone() + assert hour11 is not None + assert hour11[0] == 100000000000 + + agg = derive_day(conn, "2026-10-01") + assert agg is not None + assert agg.bytes_written_delta == 100000000000 + + conn.close() + + def test_deliberate_pause_vs_raw_stop(self, tmp_path): + """Deliberate pause closes period with user_disabled; + raw service stop leaves period open (interrupted).""" + conn = init_store(tmp_path / "test.db") + _insert_segment(conn) + + t1 = datetime(2026, 10, 1, 10, 0, 0, tzinfo=timezone.utc) + ensure_period_open(conn, t1) + _insert_sample(conn, t1.isoformat()) + + close_period(conn, t1 + timedelta(hours=1), "user_disabled") + + period = conn.execute( + "SELECT ended_at, end_cause FROM monitoring_periods WHERE ended_at IS NOT NULL" + ).fetchone() + assert period is not None + assert period[1] == "user_disabled" + + t2 = datetime(2026, 10, 1, 14, 0, 0, tzinfo=timezone.utc) + ensure_period_open(conn, t2) + _insert_sample(conn, t2.isoformat()) + + open_periods = conn.execute( + "SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL" + ).fetchone() + assert open_periods[0] == 1 + + conn.close() + + +# --------------------------------------------------------------------------- +# TPH-#59: Staleness frozen estimate wording +# --------------------------------------------------------------------------- + +class TestStalenessFact: + """Projection includes staleness fact when evidence is old.""" + + def test_staleness_fact_present_when_old(self, tmp_path): + """When newest data is ≥48h old, projection includes staleness fact.""" + conn = init_store(tmp_path / "test.db") + _insert_segment(conn) + _insert_baseline(conn) + + base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) + for i in range(14): + day = (base + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, day, bw=10*1024*1024*1024) + _insert_local_day(conn, day, complete=True) + + clock = datetime(2026, 10, 2, 12, 0, 0, tzinfo=timezone.utc) + proj = compute_projection(conn, clock) + + assert proj.staleness_fact is not None + assert "48" in proj.staleness_fact or "old" in proj.staleness_fact + + staleness_in_facts = any("old" in f or "48" in f for f in proj.contributing_facts) + assert staleness_in_facts + + conn.close() + + def test_no_staleness_fact_when_fresh(self, tmp_path): + """When newest data is recent, no staleness fact.""" + conn = init_store(tmp_path / "test.db") + _insert_segment(conn) + _insert_baseline(conn) + + base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc) + for i in range(14): + day = (base + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, day, bw=10*1024*1024*1024) + _insert_local_day(conn, day, complete=True) + + clock = datetime(2026, 10, 2, 12, 0, 0, tzinfo=timezone.utc) + proj = compute_projection(conn, clock) + + assert proj.staleness_fact is None + + conn.close() + + +# --------------------------------------------------------------------------- +# TPH-#62: Zero-delta evidence in live graph +# --------------------------------------------------------------------------- + +class TestZeroDeltaEvidence: + """Zero-delta intervals are distinguished from missing evidence.""" + + def test_zero_delta_shown_as_zero(self, tmp_path): + """Two identical readings produce a zero-delta interval.""" + conn = init_store(tmp_path / "test.db") + now = _clock() + + _insert_sample(conn, (now - timedelta(minutes=6)).isoformat(), bw=100000000000) + _insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=100000000000) + + data = _query_live_graph_data(conn, now) + assert len(data) == 1 + assert data[0]["bytes_written"] == 0 + assert data[0]["is_zero"] is True + assert data[0]["is_gap"] is False + + conn.close() + + def test_gap_distinguished_from_zero(self, tmp_path): + """A gap (missed collection) with non-zero delta is distinct from measured zero.""" + conn = init_store(tmp_path / "test.db") + now = _clock() + + _insert_sample(conn, (now - timedelta(hours=2)).isoformat(), bw=100000000000) + _insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=200000000000) + + data = _query_live_graph_data(conn, now) + assert len(data) == 1 + assert data[0]["is_gap"] is True + assert data[0]["is_zero"] is False + assert data[0]["bytes_written"] == 100000000000 + + +# --------------------------------------------------------------------------- +# TPH-#63: Horizon anchoring at evidence endpoint +# --------------------------------------------------------------------------- + +class TestHorizonAnchoring: + """Scenario windows end at the latest evidence endpoint T.""" + + def test_horizon_rate_uses_evidence_endpoint(self, tmp_path): + """Horizon rates are computed from the latest evidence endpoint, + not from clock_now.""" + conn = init_store(tmp_path / "test.db") + _insert_segment(conn) + _insert_baseline(conn) + ensure_period_open(conn, datetime(2026, 9, 10, 0, 0, 0, tzinfo=timezone.utc)) + + # Insert 28 days of data ending 5 days ago with realistic writes + base = datetime(2026, 9, 10, 12, 0, 0, tzinfo=timezone.utc) + for i in range(28): + day = (base + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, day, bw=10*1024*1024*1024) # ~10GB/day + _insert_local_day(conn, day, complete=True) + + clock = datetime(2026, 10, 13, 12, 0, 0, tzinfo=timezone.utc) + proj = compute_projection(conn, clock) + + # Scenario range should exist with rates + assert proj.scenario_range is not None + assert len(proj.scenario_range.rates) > 0 + + for horizon in [7, 28]: + if horizon in proj.scenario_range.rates: + rate = proj.scenario_range.rates[horizon] + assert rate > 0, f"Horizon {horizon} should have positive rate" + + conn.close() + + +# --------------------------------------------------------------------------- +# TPH-#64: Burst/habit checks with unknown daily shares +# --------------------------------------------------------------------------- + +class TestBurstChecks: + """Burst and habit checks handle unknown daily shares correctly.""" + + def test_habit_change_detected(self, tmp_path): + """Habit change detection works with clear write rate change.""" + conn = init_store(tmp_path / "test.db") + _insert_segment(conn, opened_at="2026-08-01T00:00:00+00:00") + _insert_baseline(conn, tbw_tb=10.0) + ensure_period_open(conn, datetime(2026, 8, 1, 0, 0, 0, tzinfo=timezone.utc)) + + # 28 days of normal usage (100 MiB/day) + bw_normal = 100 * 1024 * 1024 + for i in range(28): + day = (datetime(2026, 8, 1) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, day, bw=bw_normal) + _insert_local_day(conn, day, complete=True, bw=bw_normal) + + # 10 days of high usage (300 MiB/day = 3x, triggers habit change) + bw_high = 300 * 1024 * 1024 + for i in range(10): + day = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, day, bw=bw_high) + _insert_local_day(conn, day, complete=True, bw=bw_high) + + clock = datetime(2026, 9, 8, 12, 0, 0, tzinfo=timezone.utc) + proj = compute_projection(conn, clock) + + # Should have a habit change fact (3× increase for 10 days) + assert proj.habit_change_fact is not None + assert "usage habit changed" in proj.habit_change_fact + + conn.close() + + def test_zero_delta_not_treated_as_unknown(self, tmp_path): + """A measured zero-delta interval is not treated as missing evidence.""" + conn = init_store(tmp_path / "test.db") + now = _clock() + + _insert_sample(conn, (now - timedelta(minutes=6)).isoformat(), bw=100000000000) + _insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=100000000000) + + data = _query_live_graph_data(conn, now) + assert len(data) == 1 + assert data[0]["is_zero"] is True + assert data[0]["is_gap"] is False + assert data[0]["bytes_written"] == 0 + + conn.close()