"""Complete observation day gate tests (issue #94). Verifies that the endurance projection is withheld until at least one complete local calendar day has been observed within a monitoring period. Seams: - compute_projection() → gate check via local_days table - ProjectionResult.contributing_facts → "waiting for a full local observation day" Acceptance criteria: - Gate-1: No complete local day → UNSUPPORTED with waiting fact - Gate-2: One complete local day → Limited confidence (if other conditions met) - Gate-3: Partial days don't satisfy the gate - Gate-4: CLI and TUI share the same gate via compute_projection() """ 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 from fenris.projection import ( compute_projection, ConfidenceState, BaselineTier, WARMING_COVERAGE_FLOOR, ) @pytest.fixture def store(tmp_path): conn = init_store(tmp_path / "test.db") yield conn conn.close() def _clock(year=2026, month=9, day=30, hour=12): return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc) 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_segment(conn, opened_at="2026-09-01T00:00:00+00:00"): conn.execute( "INSERT INTO controller_segments " "(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (opened_at, "nqn.test", False, "nqn.test", "SN123", "Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"), ) conn.commit() def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24): 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, 0, samples, coverage), ) conn.commit() def _insert_sample(conn, ts, pu=5): conn.execute( "INSERT INTO samples (ts, device, data_units_written, data_units_read, " "percentage_used, bytes_written, bytes_read, power_on_hours) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (ts, "/dev/nvme0n1", 1000000, 500000, pu, 512000000000, 256000000000, 8765), ) conn.commit() def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00", utc_start=None, utc_end=None, bw=1024*1024*100, br=0, coverage=0.95, samples=24, complete=True): """Insert a local_days row for testing the gate.""" if utc_start is None: utc_start = local_date + "T00:00:00+00:00" if utc_end is None: # Next day 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 _open_period(conn, start="2026-09-01T00:00:00+00:00"): ensure_period_open(conn, datetime.fromisoformat(start)) # --------------------------------------------------------------------------- # Gate-1: No complete local day → UNSUPPORTED with waiting fact # --------------------------------------------------------------------------- class TestGateNoCompleteDay: """Projection is unavailable before any complete local observation day.""" def test_no_local_days_unsupported(self, store): """With no local_days entries, projection is UNSUPPORTED.""" _insert_baseline(store) _insert_segment(store) _open_period(store) # 14 days of UTC data — enough for normal projection, but no local_days for i in range(14): d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(store, d, bw=1024*1024*100) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) result = compute_projection(store, _clock()) assert result.confidence_state == ConfidenceState.UNSUPPORTED assert any("full local observation day" in f for f in result.contributing_facts) assert result.headline_remaining_seconds is None def test_only_partial_local_days_unsupported(self, store): """Partial (incomplete) local days don't satisfy the gate.""" _insert_baseline(store) _insert_segment(store) _open_period(store) for i in range(14): d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(store, d, bw=1024*1024*100) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) # Insert only incomplete local days for i in range(5): d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_local_day(store, d, complete=False, coverage=0.3) result = compute_projection(store, _clock()) assert result.confidence_state == ConfidenceState.UNSUPPORTED assert any("full local observation day" in f for f in result.contributing_facts) def test_gate_before_warming_check(self, store): """Gate fires even when warming would also block — gate has precedence.""" _insert_baseline(store) _insert_segment(store) _open_period(store) # Only 3 days of data (below warming threshold) for i in range(3): d = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(store, d, bw=1024*1024*100) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) # Insert one complete local day — but still below warming _insert_local_day(store, "2026-09-29", complete=True) result = compute_projection(store, _clock()) # Gate is satisfied (one complete day), but warming blocks Supported # The key assertion: gate message should NOT appear when gate IS met assert not any("full local observation day" in f for f in result.contributing_facts) def test_complete_legacy_day_without_trusted_activity_does_not_open_gate( self, store, ): """A complete flag cannot make unavailable local activity qualify.""" _insert_baseline(store) _insert_segment(store) _open_period(store) for i in range(3): day = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(store, day, bw=1024 * 1024 * 100) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_local_day(store, "2026-09-29", complete=True) store.execute( "UPDATE local_days SET activity_precision = 'measured', activity_intervals = 0 " "WHERE local_date = '2026-09-29'" ) result = compute_projection(store, _clock()) assert result.confidence_state == ConfidenceState.UNSUPPORTED assert any("full local observation day" in fact for fact in result.contributing_facts) def test_partial_but_trusted_local_activity_opens_gate(self, store): """Known local intervals can coexist with an incomplete day total.""" _insert_baseline(store) _insert_segment(store) _open_period(store) for i in range(3): day = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(store, day, bw=1024 * 1024 * 100) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_local_day(store, "2026-09-29", complete=True) store.execute( "UPDATE local_days SET activity_incomplete = 1 " "WHERE local_date = '2026-09-29'" ) result = compute_projection(store, _clock()) assert not any("full local observation day" in fact for fact in result.contributing_facts) # --------------------------------------------------------------------------- # Gate-2: One complete local day → Limited confidence # --------------------------------------------------------------------------- class TestGateOneCompleteDay: """After one complete local day, projection can proceed with Limited confidence.""" def test_one_complete_day_allows_projection(self, store): """With one complete local day and valid baseline/rate, projection is Limited.""" _insert_baseline(store, tbw_tb=1.0, verified=True) _insert_segment(store) _open_period(store) # 14 days of UTC data for i in range(14): d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(store, d, bw=1024*1024*100) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) # One complete local day _insert_local_day(store, "2026-09-29", complete=True) result = compute_projection(store, _clock()) # Gate satisfied — no "waiting" fact assert not any("full local observation day" in f for f in result.contributing_facts) # With only 14 days and other Limited factors, should be Limited or Supported assert result.confidence_state in (ConfidenceState.LIMITED, ConfidenceState.SUPPORTED) # Headline should exist (rate > 0, baseline exists) assert result.headline_remaining_seconds is not None def test_gate_fact_absent_when_satisfied(self, store): """The 'waiting for full day' fact does not appear when gate is met.""" _insert_baseline(store) _insert_segment(store) _open_period(store) for i in range(20): d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(store, d, bw=1024*1024*100) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_local_day(store, "2026-09-29", complete=True) result = compute_projection(store, _clock()) assert not any("full local observation day" in f for f in result.contributing_facts) assert result.confidence_state != ConfidenceState.UNSUPPORTED # --------------------------------------------------------------------------- # Gate-3: Partial first day doesn't satisfy the gate # --------------------------------------------------------------------------- class TestGatePartialFirstDay: """Starting monitoring at noon means the partial first day doesn't count.""" def test_partial_first_day_not_enough(self, store): """A single incomplete local day (started at noon) doesn't open the gate.""" _insert_baseline(store) _insert_segment(store, opened_at="2026-09-29T12:00:00+00:00") _open_period(store, start="2026-09-29T12:00:00+00:00") # Only Sep 29 (partial) and Sep 30 (today, partial) _insert_day(store, "2026-09-29", bw=1024*1024*100, coverage=0.5, samples=12) _insert_day(store, "2026-09-30", bw=1024*1024*100, coverage=0.5, samples=12) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) # Only partial local days _insert_local_day(store, "2026-09-29", complete=False, coverage=0.5) _insert_local_day(store, "2026-09-30", complete=False, coverage=0.5) result = compute_projection(store, _clock()) assert result.confidence_state == ConfidenceState.UNSUPPORTED assert any("full local observation day" in f for f in result.contributing_facts) # --------------------------------------------------------------------------- # Gate-4: Multiple complete days also satisfy the gate # --------------------------------------------------------------------------- class TestGateMultipleCompleteDays: """Multiple complete local days satisfy the gate.""" def test_multiple_complete_days_satisfy_gate(self, store): """Several complete local days open the gate.""" _insert_baseline(store) _insert_segment(store) _open_period(store) for i in range(14): d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_day(store, d, bw=1024*1024*100) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) # 7 complete local days for i in range(7): d = (datetime(2026, 9, 23) + timedelta(days=i)).strftime("%Y-%m-%d") _insert_local_day(store, d, complete=True) result = compute_projection(store, _clock()) assert not any("full local observation day" in f for f in result.contributing_facts) assert result.headline_remaining_seconds is not None