381 lines
15 KiB
Python
381 lines
15 KiB
Python
"""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, activity_intervals) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||
(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)
|
||
ensure_period_open(conn, base)
|
||
conn.commit()
|
||
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)
|
||
ensure_period_open(conn, base)
|
||
conn.commit()
|
||
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()
|