feat(projection): show staged projection with evidence ladder
Report a projection stage, an evidence ladder and the sustained-regime write rate from compute_projection, per ADR 0012. A provisional lifespan appears after 3 observed hours and carries the hours observed and a short-horizon spread; the complete-local-day condition becomes a fact instead of a gate, and the write rate replaces the lifespan when no baseline applies. The TUI and CLI render the stage, ladder count and full ladder, and drop their own two-sample pre-gates.
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
"""Complete observation day gate tests (issue #94).
|
||||
"""Complete observation day condition tests (issues #94, #106).
|
||||
|
||||
Verifies that the endurance projection is withheld until at least one
|
||||
complete local calendar day has been observed within a monitoring period.
|
||||
Originally a gate (issue #94); ADR 0012 turned it into a contributing
|
||||
fact. The projection is no longer withheld until one complete local
|
||||
calendar day has been observed within a monitoring period, but it says
|
||||
so while that condition is unmet.
|
||||
|
||||
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-1: No complete local day → projection renders with waiting fact
|
||||
- Gate-2: One complete local day → no waiting fact
|
||||
- Gate-3: Partial days don't satisfy the condition
|
||||
- Gate-4: CLI and TUI share the same gate via compute_projection()
|
||||
"""
|
||||
import sqlite3
|
||||
@@ -111,14 +113,14 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate-1: No complete local day → UNSUPPORTED with waiting fact
|
||||
# Gate-1: No complete local day → projection renders with waiting fact
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGateNoCompleteDay:
|
||||
"""Projection is unavailable before any complete local observation day."""
|
||||
"""Projection still renders before any complete local observation day."""
|
||||
|
||||
def test_no_local_days_unsupported(self, store):
|
||||
"""With no local_days entries, projection is UNSUPPORTED."""
|
||||
def test_no_local_days_states_waiting_fact(self, store):
|
||||
"""With no local_days entries, the projection renders and says it is waiting."""
|
||||
_insert_baseline(store)
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
@@ -128,11 +130,11 @@ class TestGateNoCompleteDay:
|
||||
_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 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
|
||||
assert result.headline_remaining_seconds is not None
|
||||
|
||||
def test_only_partial_local_days_unsupported(self, store):
|
||||
def test_only_partial_local_days_state_waiting_fact(self, store):
|
||||
"""Partial (incomplete) local days don't satisfy the gate."""
|
||||
_insert_baseline(store)
|
||||
_insert_segment(store)
|
||||
@@ -146,7 +148,7 @@ class TestGateNoCompleteDay:
|
||||
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 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):
|
||||
@@ -166,7 +168,7 @@ class TestGateNoCompleteDay:
|
||||
# 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(
|
||||
def test_complete_legacy_day_without_trusted_activity_keeps_waiting_fact(
|
||||
self, store,
|
||||
):
|
||||
"""A complete flag cannot make unavailable local activity qualify."""
|
||||
@@ -183,11 +185,11 @@ class TestGateNoCompleteDay:
|
||||
"WHERE local_date = '2026-09-29'"
|
||||
)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert result.confidence_state != ConfidenceState.UNSUPPORTED
|
||||
assert any("full local observation day" in fact
|
||||
for fact in result.contributing_facts)
|
||||
|
||||
def test_day_split_by_deliberate_pause_does_not_open_gate(self, store):
|
||||
def test_day_split_by_deliberate_pause_keeps_waiting_fact(self, store):
|
||||
"""A complete-looking summary cannot span separate monitoring periods."""
|
||||
_insert_baseline(store)
|
||||
_insert_segment(store)
|
||||
@@ -216,7 +218,7 @@ class TestGateNoCompleteDay:
|
||||
|
||||
result = compute_projection(store, _clock())
|
||||
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert result.confidence_state != ConfidenceState.UNSUPPORTED
|
||||
assert any("full local observation day" in fact
|
||||
for fact in result.contributing_facts)
|
||||
|
||||
@@ -290,7 +292,7 @@ class TestGateOneCompleteDay:
|
||||
class TestGatePartialFirstDay:
|
||||
"""Starting monitoring at noon means the partial first day doesn't count."""
|
||||
|
||||
def test_partial_first_day_not_enough(self, store):
|
||||
def test_partial_first_day_keeps_waiting_fact(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")
|
||||
@@ -303,7 +305,7 @@ class TestGatePartialFirstDay:
|
||||
_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 result.confidence_state != ConfidenceState.UNSUPPORTED
|
||||
assert any("full local observation day" in f for f in result.contributing_facts)
|
||||
|
||||
|
||||
|
||||
+10
-10
@@ -398,16 +398,16 @@ class TestArithmetic:
|
||||
_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())
|
||||
if result.headline_remaining_seconds is not None:
|
||||
E_rated = 1.0 * TBW_TO_BYTES
|
||||
regime_bytes = 30 * 1024 * 1024 * 100
|
||||
# Actual wall-clock: Sep 1 00:00 -> Sep 30 12:00 = 29.5 days
|
||||
period_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
period_end = _clock()
|
||||
actual_wc = int((period_end - period_start).total_seconds())
|
||||
rate = regime_bytes / actual_wc
|
||||
expected = max(E_rated - regime_bytes, 0) / rate
|
||||
assert abs(result.headline_remaining_seconds - expected) < 1.0
|
||||
E_rated = 1.0 * TBW_TO_BYTES
|
||||
W_t = 512000000000 # lifetime bytes written of the newest published sample
|
||||
regime_bytes = 30 * 1024 * 1024 * 100
|
||||
# Actual wall-clock: Sep 1 00:00 -> Sep 30 12:00 = 29.5 days
|
||||
period_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
actual_wc = int((_clock() - period_start).total_seconds())
|
||||
rate = regime_bytes / actual_wc
|
||||
expected = max(E_rated - W_t, 0) / rate
|
||||
assert result.headline_remaining_seconds is not None
|
||||
assert abs(result.headline_remaining_seconds - expected) < 1.0
|
||||
|
||||
def test_implied_baseline_formula(self, store):
|
||||
W_t = 1024 * 1024 * 1000
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Staged projection with an evidence ladder (issue #106, ADR 0012).
|
||||
|
||||
Every gate is exercised through compute_projection() against SQLite
|
||||
fixtures built by seed_evidence(): N hours or days of complete evidence
|
||||
written as hour observations, then derived into day aggregates the same
|
||||
way a collection run does.
|
||||
"""
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.day_aggregate import derive_all_days, persist_day_aggregate
|
||||
from fenris.monitoring_periods import ensure_period_open
|
||||
from fenris.projection import (
|
||||
BaselineTier, ConfidenceState, ProjectionStage, compute_projection,
|
||||
)
|
||||
from fenris.store import init_store
|
||||
|
||||
START = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
HOUR_BYTES = 10 * 1024 ** 3
|
||||
MODEL = "Samsung SSD 970 EVO Plus 1TB"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
def seed_evidence(conn, hours, baseline="verified", start=START, degraded=False):
|
||||
"""Seed *hours* hours of complete evidence and return the clock.
|
||||
|
||||
Monitoring opens at *start*; each hour is fully sampled and active.
|
||||
The returned clock is the end of the last observed hour.
|
||||
"""
|
||||
conn.execute(
|
||||
"INSERT INTO controller_segments "
|
||||
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||
"VALUES (?, ?, ?, 'nqn.test', 'SN1', ?, 'FW1', '0x144d', '0x144d', 'pcie')",
|
||||
(start.isoformat(), "" if degraded else "nqn.test", degraded, MODEL),
|
||||
)
|
||||
if baseline:
|
||||
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 (600, ?, 'v1', '2026-01-01', ?, 1024000000000, ?, ?, "
|
||||
" '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')",
|
||||
("https://example.com/spec" if baseline == "verified" else None, MODEL,
|
||||
"machine_match" if baseline == "verified" else None,
|
||||
baseline == "verified"),
|
||||
)
|
||||
ensure_period_open(conn, start)
|
||||
for h in range(hours):
|
||||
hour = start + timedelta(hours=h)
|
||||
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 (?, 3600, 0, 0, 0, ?, 0, 1, 1.0)",
|
||||
(hour.strftime("%Y-%m-%dT%H:00:00Z"), HOUR_BYTES),
|
||||
)
|
||||
written = HOUR_BYTES * (h + 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 (?, '/dev/nvme0n1', ?, 0, 1, ?, 0, 100, 1)",
|
||||
((hour + timedelta(hours=1)).isoformat(), written // 512000, written),
|
||||
)
|
||||
for aggregate in derive_all_days(conn):
|
||||
persist_day_aggregate(conn, aggregate)
|
||||
conn.commit()
|
||||
return start + timedelta(hours=hours)
|
||||
|
||||
|
||||
class TestHourStages:
|
||||
def test_empty_store_has_no_observations(self, store):
|
||||
result = compute_projection(store, START)
|
||||
assert result.stage == ProjectionStage.NO_OBSERVATIONS
|
||||
assert result.headline_remaining_seconds is None
|
||||
|
||||
def test_two_hours_is_too_few_for_a_number(self, store):
|
||||
clock = seed_evidence(store, 2)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.stage == ProjectionStage.NO_OBSERVATIONS
|
||||
assert result.headline_remaining_seconds is None
|
||||
assert result.hours_observed == 2
|
||||
|
||||
def test_three_hours_is_provisional_with_a_number(self, store):
|
||||
clock = seed_evidence(store, 3)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.stage == ProjectionStage.PROVISIONAL
|
||||
assert result.headline_remaining_seconds is not None
|
||||
assert result.headline_remaining_seconds > 0
|
||||
assert result.hours_observed == 3
|
||||
assert "3 hours observed" in result.contributing_facts
|
||||
assert "daily cycle not yet seen" in result.contributing_facts
|
||||
|
||||
def test_three_hours_shows_short_horizon_spread(self, store):
|
||||
clock = seed_evidence(store, 3)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.scenario_range is not None
|
||||
assert set(result.scenario_range.rates) <= {1, 3}
|
||||
assert 1 in result.scenario_range.rates
|
||||
|
||||
def test_provisional_rate_is_the_observed_write_rate(self, store):
|
||||
clock = seed_evidence(store, 3)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.write_rate == pytest.approx(HOUR_BYTES / 3600, rel=0.01)
|
||||
|
||||
def test_complete_local_day_is_a_fact_not_a_gate(self, store):
|
||||
clock = seed_evidence(store, 3)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.headline_remaining_seconds is not None
|
||||
assert "waiting for a full local observation day" in result.contributing_facts
|
||||
|
||||
def test_provisional_until_24_hours_then_warming(self, store):
|
||||
clock = seed_evidence(store, 23)
|
||||
assert compute_projection(store, clock).stage == ProjectionStage.PROVISIONAL
|
||||
|
||||
def test_twenty_four_hours_is_warming(self, store):
|
||||
clock = seed_evidence(store, 24)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.stage == ProjectionStage.WARMING
|
||||
assert "daily cycle not yet seen" not in result.contributing_facts
|
||||
|
||||
|
||||
class TestDayStages:
|
||||
def test_warming_until_fourteen_qualifying_days(self, store):
|
||||
clock = seed_evidence(store, 24 * 13)
|
||||
assert compute_projection(store, clock).stage == ProjectionStage.WARMING
|
||||
|
||||
def test_fourteen_days_with_verified_baseline_is_supported(self, store):
|
||||
clock = seed_evidence(store, 24 * 14)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.stage == ProjectionStage.SUPPORTED
|
||||
assert result.confidence_state == ConfidenceState.SUPPORTED
|
||||
|
||||
def test_unverified_baseline_is_limited_after_warm_up(self, store):
|
||||
clock = seed_evidence(store, 24 * 14, baseline="unverified")
|
||||
result = compute_projection(store, clock)
|
||||
assert result.stage == ProjectionStage.LIMITED
|
||||
assert result.baseline_tier == BaselineTier.UNVERIFIED
|
||||
|
||||
def test_degraded_identity_is_limited(self, store):
|
||||
clock = seed_evidence(store, 24 * 14, degraded=True)
|
||||
assert compute_projection(store, clock).stage == ProjectionStage.LIMITED
|
||||
|
||||
def test_stale_history_is_limited(self, store):
|
||||
clock = seed_evidence(store, 24 * 14) + timedelta(days=4)
|
||||
assert compute_projection(store, clock).stage == ProjectionStage.LIMITED
|
||||
|
||||
|
||||
class TestNoBaseline:
|
||||
def test_write_rate_replaces_lifespan(self, store):
|
||||
clock = seed_evidence(store, 3, baseline=None)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.stage == ProjectionStage.UNAVAILABLE
|
||||
assert result.headline_remaining_seconds is None
|
||||
assert result.write_rate == pytest.approx(HOUR_BYTES / 3600, rel=0.01)
|
||||
|
||||
def test_no_baseline_is_not_synthesised(self, store):
|
||||
clock = seed_evidence(store, 24 * 14, baseline=None)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.baseline_tier == BaselineTier.NONE
|
||||
assert result.headline_remaining_seconds is None
|
||||
assert result.write_rate is not None
|
||||
|
||||
|
||||
class TestShortHorizons:
|
||||
def test_short_horizons_appear_once_covered(self, store):
|
||||
clock = seed_evidence(store, 24 * 2)
|
||||
rates = compute_projection(store, clock).scenario_range.rates
|
||||
assert 1 in rates
|
||||
assert 3 not in rates
|
||||
|
||||
def test_three_day_horizon_covered_at_four_days(self, store):
|
||||
clock = seed_evidence(store, 24 * 4)
|
||||
rates = compute_projection(store, clock).scenario_range.rates
|
||||
assert {1, 3} <= set(rates)
|
||||
|
||||
def test_short_horizons_drop_when_seven_day_covered(self, store):
|
||||
clock = seed_evidence(store, 24 * 9)
|
||||
rates = compute_projection(store, clock).scenario_range.rates
|
||||
assert 7 in rates
|
||||
assert 1 not in rates and 3 not in rates
|
||||
|
||||
|
||||
class TestEvidenceLadder:
|
||||
def test_ladder_lists_each_supported_condition_with_reason(self, store):
|
||||
clock = seed_evidence(store, 3)
|
||||
ladder = compute_projection(store, clock).evidence_ladder
|
||||
assert len(ladder) == 8
|
||||
assert all(c.reason for c in ladder)
|
||||
assert all(isinstance(c.met, bool) for c in ladder)
|
||||
|
||||
def test_ladder_all_met_when_supported(self, store):
|
||||
clock = seed_evidence(store, 24 * 14)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.ladder_met == result.ladder_total == 8
|
||||
|
||||
def test_ladder_count_rises_as_evidence_accrues(self, store, tmp_path):
|
||||
counts = []
|
||||
for hours in (3, 24 * 2, 24 * 9, 24 * 14):
|
||||
conn = init_store(tmp_path / ("ladder-%d.db" % hours))
|
||||
clock = seed_evidence(conn, hours)
|
||||
counts.append(compute_projection(conn, clock).ladder_met)
|
||||
conn.close()
|
||||
assert counts == sorted(counts)
|
||||
assert counts[0] < counts[-1]
|
||||
assert len(set(counts)) >= 3
|
||||
|
||||
def test_unmet_condition_names_its_reason(self, store):
|
||||
clock = seed_evidence(store, 3)
|
||||
unmet = [c for c in compute_projection(store, clock).evidence_ladder if not c.met]
|
||||
assert any("qualifying days" in c.reason for c in unmet)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""CLI and TUI render the projection stage, never pre-gate it (issue #106).
|
||||
|
||||
Seams: get_status() and the TUI headline band. Both must show what
|
||||
compute_projection() reports: the stage, the ladder count, and the full
|
||||
ladder in the outlook; no caller decides what the projection can show.
|
||||
"""
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from fenris.status import get_status
|
||||
from fenris.store import init_store
|
||||
from fenris.tui import FenrisTuiApp
|
||||
from test_staged_projection import seed_evidence
|
||||
|
||||
SERVICES = {
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}
|
||||
|
||||
|
||||
def _status(db, clock):
|
||||
with patch("fenris.status.query_service_state", return_value=SERVICES):
|
||||
return get_status(store_path=db, clock_now=clock,
|
||||
query_services=False, query_journal=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return tmp_path / "observations.db"
|
||||
|
||||
|
||||
class TestCliStatus:
|
||||
def test_provisional_stage_ladder_count_and_full_ladder(self, db):
|
||||
conn = init_store(db)
|
||||
clock = seed_evidence(conn, 3)
|
||||
conn.close()
|
||||
out = _status(db, clock)
|
||||
assert "Provisional" in out
|
||||
assert "3 hours observed" in out
|
||||
assert "daily cycle not yet seen" in out
|
||||
assert "remaining" in out
|
||||
assert "conditions met" in out
|
||||
assert "Evidence ladder" in out
|
||||
assert "[ ] 14 qualifying days" in out
|
||||
assert "[x] verified baseline" in out
|
||||
|
||||
def test_single_sample_is_not_pre_gated(self, db):
|
||||
conn = init_store(db)
|
||||
seed_evidence(conn, 1)
|
||||
conn.close()
|
||||
out = _status(db, datetime(2026, 9, 1, 1, 0, tzinfo=timezone.utc))
|
||||
assert "requires at least two samples" not in out
|
||||
assert "1 of 3 hours observed" in out
|
||||
|
||||
def test_no_baseline_shows_write_rate_not_lifespan(self, db):
|
||||
conn = init_store(db)
|
||||
clock = seed_evidence(conn, 3, baseline=None)
|
||||
conn.close()
|
||||
out = _status(db, clock)
|
||||
assert "write rate" in out.lower()
|
||||
assert "GB/day" in out
|
||||
|
||||
def test_supported_stage(self, db):
|
||||
conn = init_store(db)
|
||||
clock = seed_evidence(conn, 24 * 14)
|
||||
conn.close()
|
||||
out = _status(db, clock)
|
||||
assert "Supported" in out
|
||||
assert "8 of 8 conditions met" in out
|
||||
|
||||
|
||||
class TestTuiHeadline:
|
||||
@pytest.mark.asyncio
|
||||
async def test_provisional_headline_and_ladder(self, db):
|
||||
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
||||
conn = init_store(db)
|
||||
seed_evidence(conn, 3, start=now - timedelta(hours=3))
|
||||
conn.close()
|
||||
with patch("fenris.status.query_service_state", return_value=SERVICES):
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
async with app.run_test(size=(120, 50)):
|
||||
text = str(app.query_one("#headline-band").render())
|
||||
assert "Provisional" in text
|
||||
assert "3 hours observed" in text
|
||||
assert "conditions met" in text
|
||||
assert "Evidence ladder" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_sample_is_not_pre_gated(self, db):
|
||||
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
||||
conn = init_store(db)
|
||||
seed_evidence(conn, 1, start=now - timedelta(hours=1))
|
||||
conn.close()
|
||||
with patch("fenris.status.query_service_state", return_value=SERVICES):
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
async with app.run_test(size=(120, 50)):
|
||||
text = str(app.query_one("#headline-band").render())
|
||||
assert "at least two samples" not in text
|
||||
|
||||
|
||||
def test_callers_carry_no_projection_pre_gate():
|
||||
root = Path(__file__).parent.parent / "src" / "fenris"
|
||||
for name in ("tui.py", "status.py"):
|
||||
assert "at least two samples" not in (root / name).read_text()
|
||||
Reference in New Issue
Block a user