fix: validate complete-day monitoring coverage
This commit is contained in:
+22
-31
@@ -33,6 +33,11 @@ from itertools import pairwise
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from .monitoring_periods import (
|
||||||
|
as_utc_datetime,
|
||||||
|
interval_within_one_monitoring_period,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LocalDaySummary:
|
class LocalDaySummary:
|
||||||
@@ -308,27 +313,13 @@ def persist_local_day(
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
def _utc_datetime(value: str | datetime) -> datetime:
|
|
||||||
parsed = value if isinstance(value, datetime) else datetime.fromisoformat(value)
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _same_monitoring_period(
|
def _same_monitoring_period(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
start: datetime,
|
start: datetime,
|
||||||
end: datetime,
|
end: datetime,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return whether one recorded monitoring period contains the interval."""
|
"""Return whether one recorded monitoring period contains the interval."""
|
||||||
for started_at, ended_at in conn.execute(
|
return interval_within_one_monitoring_period(conn, start, end)
|
||||||
"SELECT started_at, ended_at FROM monitoring_periods"
|
|
||||||
):
|
|
||||||
period_start = _utc_datetime(started_at)
|
|
||||||
period_end = _utc_datetime(ended_at) if ended_at else None
|
|
||||||
if period_start <= start and (period_end is None or end <= period_end):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int:
|
def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int:
|
||||||
@@ -365,13 +356,13 @@ def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int:
|
|||||||
segment_windows = {}
|
segment_windows = {}
|
||||||
if {"monitoring_periods", "controller_segments"}.issubset(tables):
|
if {"monitoring_periods", "controller_segments"}.issubset(tables):
|
||||||
periods = [
|
periods = [
|
||||||
(_utc_datetime(start), _utc_datetime(end) if end else None)
|
(as_utc_datetime(start), as_utc_datetime(end) if end else None)
|
||||||
for start, end in conn.execute(
|
for start, end in conn.execute(
|
||||||
"SELECT started_at, ended_at FROM monitoring_periods"
|
"SELECT started_at, ended_at FROM monitoring_periods"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
segment_boundaries = [
|
segment_boundaries = [
|
||||||
(segment_id, _utc_datetime(opened_at))
|
(segment_id, as_utc_datetime(opened_at))
|
||||||
for segment_id, opened_at in conn.execute(
|
for segment_id, opened_at in conn.execute(
|
||||||
"SELECT id, opened_at FROM controller_segments ORDER BY opened_at, id"
|
"SELECT id, opened_at FROM controller_segments ORDER BY opened_at, id"
|
||||||
)
|
)
|
||||||
@@ -399,7 +390,7 @@ def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int:
|
|||||||
)
|
)
|
||||||
samples = [
|
samples = [
|
||||||
{
|
{
|
||||||
"id": row[0], "ts": _utc_datetime(row[1]),
|
"id": row[0], "ts": as_utc_datetime(row[1]),
|
||||||
"bytes_written": row[2], "bytes_read": row[3],
|
"bytes_written": row[2], "bytes_read": row[3],
|
||||||
"segment_id": row[4], "local_tz": row[5],
|
"segment_id": row[4], "local_tz": row[5],
|
||||||
}
|
}
|
||||||
@@ -453,8 +444,8 @@ def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int:
|
|||||||
"id": row[0],
|
"id": row[0],
|
||||||
"local_date": row[1],
|
"local_date": row[1],
|
||||||
"tz_name": row[2],
|
"tz_name": row[2],
|
||||||
"start": _utc_datetime(row[3]),
|
"start": as_utc_datetime(row[3]),
|
||||||
"end": _utc_datetime(row[4]),
|
"end": as_utc_datetime(row[4]),
|
||||||
"precision": row[6],
|
"precision": row[6],
|
||||||
}
|
}
|
||||||
for row in recorded_days
|
for row in recorded_days
|
||||||
@@ -482,8 +473,8 @@ def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int:
|
|||||||
local_day_id, local_date, tz_name, utc_start, utc_end,
|
local_day_id, local_date, tz_name, utc_start, utc_end,
|
||||||
prior_incomplete,
|
prior_incomplete,
|
||||||
) = row
|
) = row
|
||||||
start = _utc_datetime(utc_start)
|
start = as_utc_datetime(utc_start)
|
||||||
end = _utc_datetime(utc_end)
|
end = as_utc_datetime(utc_end)
|
||||||
matching = matching_by_day[local_day_id]
|
matching = matching_by_day[local_day_id]
|
||||||
|
|
||||||
if not matching:
|
if not matching:
|
||||||
@@ -595,8 +586,8 @@ def _record_reconstructed_shared_intervals(
|
|||||||
day_rows = [
|
day_rows = [
|
||||||
{
|
{
|
||||||
"id": row[0], "local_date": row[1], "tz_name": row[2],
|
"id": row[0], "local_date": row[1], "tz_name": row[2],
|
||||||
"utc_start": _utc_datetime(row[3]),
|
"utc_start": as_utc_datetime(row[3]),
|
||||||
"utc_end": _utc_datetime(row[4]),
|
"utc_end": as_utc_datetime(row[4]),
|
||||||
"precision": row[6],
|
"precision": row[6],
|
||||||
}
|
}
|
||||||
for row in recorded_days
|
for row in recorded_days
|
||||||
@@ -685,7 +676,7 @@ def _coarse_local_day_activity(
|
|||||||
full_hours: dict[datetime, tuple] = {}
|
full_hours: dict[datetime, tuple] = {}
|
||||||
boundary_hour_found = False
|
boundary_hour_found = False
|
||||||
other_day_bounds = [
|
other_day_bounds = [
|
||||||
(_utc_datetime(row[0]), _utc_datetime(row[1]))
|
(as_utc_datetime(row[0]), as_utc_datetime(row[1]))
|
||||||
for row in conn.execute(
|
for row in conn.execute(
|
||||||
"SELECT utc_start, utc_end FROM local_days WHERE id != ?",
|
"SELECT utc_start, utc_end FROM local_days WHERE id != ?",
|
||||||
(local_day_id,),
|
(local_day_id,),
|
||||||
@@ -698,7 +689,7 @@ def _coarse_local_day_activity(
|
|||||||
"FROM hour_observations WHERE hour >= ? AND hour < ? ORDER BY hour",
|
"FROM hour_observations WHERE hour >= ? AND hour < ? ORDER BY hour",
|
||||||
(first_hour.isoformat(), day_end.isoformat()),
|
(first_hour.isoformat(), day_end.isoformat()),
|
||||||
):
|
):
|
||||||
hour_start = _utc_datetime(row[0])
|
hour_start = as_utc_datetime(row[0])
|
||||||
hour_end = hour_start + hour
|
hour_end = hour_start + hour
|
||||||
if hour_start >= day_end or hour_end <= day_start:
|
if hour_start >= day_end or hour_end <= day_start:
|
||||||
continue
|
continue
|
||||||
@@ -856,8 +847,8 @@ def record_local_activity_interval(
|
|||||||
both samples share one monitoring period, and the interval stays inside one
|
both samples share one monitoring period, and the interval stays inside one
|
||||||
recorded local date. Every other valid difference remains one unallocated row.
|
recorded local date. Every other valid difference remains one unallocated row.
|
||||||
"""
|
"""
|
||||||
start = _utc_datetime(previous["ts"])
|
start = as_utc_datetime(previous["ts"])
|
||||||
end = _utc_datetime(current["ts"])
|
end = as_utc_datetime(current["ts"])
|
||||||
if end <= start:
|
if end <= start:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1004,12 +995,12 @@ def mark_local_activity_gap(
|
|||||||
previous: dict | None = None,
|
previous: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark dates around a controller boundary as missing local activity."""
|
"""Mark dates around a controller boundary as missing local activity."""
|
||||||
current = _utc_datetime(current_ts)
|
current = as_utc_datetime(current_ts)
|
||||||
current_date = current.astimezone(ZoneInfo(current_tz)).date().isoformat()
|
current_date = current.astimezone(ZoneInfo(current_tz)).date().isoformat()
|
||||||
_upsert_activity_day(conn, current_date, current_tz, incomplete=True)
|
_upsert_activity_day(conn, current_date, current_tz, incomplete=True)
|
||||||
if previous is None or not previous.get("local_tz"):
|
if previous is None or not previous.get("local_tz"):
|
||||||
return
|
return
|
||||||
previous_at = _utc_datetime(previous["ts"])
|
previous_at = as_utc_datetime(previous["ts"])
|
||||||
previous_tz = previous["local_tz"]
|
previous_tz = previous["local_tz"]
|
||||||
previous_date = previous_at.astimezone(ZoneInfo(previous_tz)).date().isoformat()
|
previous_date = previous_at.astimezone(ZoneInfo(previous_tz)).date().isoformat()
|
||||||
_upsert_activity_day(conn, previous_date, previous_tz, incomplete=True)
|
_upsert_activity_day(conn, previous_date, previous_tz, incomplete=True)
|
||||||
@@ -1127,7 +1118,7 @@ def query_local_day_summary(
|
|||||||
== recorded_date
|
== recorded_date
|
||||||
)
|
)
|
||||||
day_seconds = int(
|
day_seconds = int(
|
||||||
(_utc_datetime(utc_end) - _utc_datetime(utc_start)).total_seconds()
|
(as_utc_datetime(utc_end) - as_utc_datetime(utc_start)).total_seconds()
|
||||||
)
|
)
|
||||||
interval_seconds = activity_seconds or 0
|
interval_seconds = activity_seconds or 0
|
||||||
full_activity_day = interval_seconds >= day_seconds or (
|
full_activity_day = interval_seconds >= day_seconds or (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Key contracts:
|
|||||||
- End causes: user_disabled, migrated, unknown_gap
|
- End causes: user_disabled, migrated, unknown_gap
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
|
def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
|
||||||
@@ -82,6 +82,46 @@ def is_inside_period(conn: sqlite3.Connection, ts: datetime) -> bool:
|
|||||||
return cursor.fetchone() is not None
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def interval_within_one_monitoring_period(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
start: str | datetime,
|
||||||
|
end: str | datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether one monitoring period contains the full interval.
|
||||||
|
|
||||||
|
Compare timestamps as UTC instants. Malformed timestamps fail closed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
interval_start = as_utc_datetime(start)
|
||||||
|
interval_end = as_utc_datetime(end)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return False
|
||||||
|
if interval_end <= interval_start:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for started_at, ended_at in conn.execute(
|
||||||
|
"SELECT started_at, ended_at FROM monitoring_periods"
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
period_start = as_utc_datetime(started_at)
|
||||||
|
period_end = as_utc_datetime(ended_at) if ended_at is not None else None
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
continue
|
||||||
|
if period_start <= interval_start and (
|
||||||
|
period_end is None or interval_end <= period_end
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def as_utc_datetime(value: str | datetime) -> datetime:
|
||||||
|
"""Parse a timestamp and normalize it to an aware UTC datetime."""
|
||||||
|
parsed = value if isinstance(value, datetime) else datetime.fromisoformat(value)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def wall_clock_in_periods(
|
def wall_clock_in_periods(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
start: datetime,
|
start: datetime,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .monitoring_periods import interval_within_one_monitoring_period
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants (spec §6)
|
# Constants (spec §6)
|
||||||
@@ -494,18 +495,16 @@ def _has_complete_local_day(conn):
|
|||||||
within a monitoring period that has usable observation evidence.
|
within a monitoring period that has usable observation evidence.
|
||||||
This is the prerequisite for showing an endurance outlook.
|
This is the prerequisite for showing an endurance outlook.
|
||||||
"""
|
"""
|
||||||
return _count_complete_local_days(conn) > 0
|
local_days = conn.execute(
|
||||||
|
"SELECT utc_start, utc_end FROM local_days "
|
||||||
|
|
||||||
def _count_complete_local_days(conn):
|
|
||||||
"""Count the number of complete local observation days."""
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM local_days "
|
|
||||||
"WHERE complete = 1 "
|
"WHERE complete = 1 "
|
||||||
"AND activity_precision IN ('measured', 'coarse') "
|
"AND activity_precision IN ('measured', 'coarse') "
|
||||||
"AND activity_intervals > 0"
|
"AND activity_intervals > 0 ORDER BY id"
|
||||||
).fetchone()
|
).fetchall()
|
||||||
return row[0] if row else 0
|
return any(
|
||||||
|
interval_within_one_monitoring_period(conn, utc_start, utc_end)
|
||||||
|
for utc_start, utc_end in local_days
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+10
-21
@@ -4,25 +4,23 @@ The 14-day cutoff never overrides local-day preservation, shared boundary
|
|||||||
evidence, publication recovery, or the newest sample's successor-anchor role.
|
evidence, publication recovery, or the newest sample's successor-anchor role.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
from .monitoring_periods import (
|
||||||
|
as_utc_datetime,
|
||||||
|
interval_within_one_monitoring_period,
|
||||||
|
)
|
||||||
|
|
||||||
RAW_SAMPLE_RETENTION_DAYS = 14
|
RAW_SAMPLE_RETENTION_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
def _utc_datetime(value: datetime) -> datetime:
|
|
||||||
"""Normalize a clock value to aware UTC."""
|
|
||||||
if value.tzinfo is None:
|
|
||||||
value = value.replace(tzinfo=timezone.utc)
|
|
||||||
return value.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_sample_time(value: str) -> datetime | None:
|
def _parse_sample_time(value: str) -> datetime | None:
|
||||||
"""Parse old timestamps conservatively; malformed legacy values stay."""
|
"""Parse old timestamps conservatively; malformed legacy values stay."""
|
||||||
try:
|
try:
|
||||||
parsed = datetime.fromisoformat(value)
|
parsed = datetime.fromisoformat(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
return _utc_datetime(parsed)
|
return as_utc_datetime(parsed)
|
||||||
|
|
||||||
|
|
||||||
def _sample_rows(conn: sqlite3.Connection) -> list[tuple]:
|
def _sample_rows(conn: sqlite3.Connection) -> list[tuple]:
|
||||||
@@ -149,16 +147,7 @@ def _monitoring_period_covers(
|
|||||||
end: datetime,
|
end: datetime,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Require continuous monitoring before a local-day total replaces detail."""
|
"""Require continuous monitoring before a local-day total replaces detail."""
|
||||||
for started_at, ended_at in conn.execute(
|
return interval_within_one_monitoring_period(conn, start, end)
|
||||||
"SELECT started_at, ended_at FROM monitoring_periods"
|
|
||||||
):
|
|
||||||
period_start = _parse_sample_time(started_at)
|
|
||||||
period_end = _parse_sample_time(ended_at) if ended_at is not None else None
|
|
||||||
if period_start is None or period_start > start:
|
|
||||||
continue
|
|
||||||
if ended_at is None or (period_end is not None and end <= period_end):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _has_local_replacement(
|
def _has_local_replacement(
|
||||||
@@ -312,7 +301,7 @@ def needs_boundary_anchor(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return whether an old sample still carries unreplaced evidence."""
|
"""Return whether an old sample still carries unreplaced evidence."""
|
||||||
sample_time = _parse_sample_time(sample_ts)
|
sample_time = _parse_sample_time(sample_ts)
|
||||||
cutoff = _utc_datetime(now) - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
|
cutoff = as_utc_datetime(now) - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
|
||||||
if sample_time is None:
|
if sample_time is None:
|
||||||
return True
|
return True
|
||||||
if sample_time >= cutoff:
|
if sample_time >= cutoff:
|
||||||
@@ -341,7 +330,7 @@ def prune_old_samples(
|
|||||||
neighbour interval, lacks UTC or local-day replacement evidence, or has a
|
neighbour interval, lacks UTC or local-day replacement evidence, or has a
|
||||||
timestamp that cannot be safely interpreted.
|
timestamp that cannot be safely interpreted.
|
||||||
"""
|
"""
|
||||||
cutoff = _utc_datetime(now) - timedelta(days=retention_days)
|
cutoff = as_utc_datetime(now) - timedelta(days=retention_days)
|
||||||
owns_transaction = not conn.in_transaction
|
owns_transaction = not conn.in_transaction
|
||||||
savepoint = "fenris_sample_retention"
|
savepoint = "fenris_sample_retention"
|
||||||
if owns_transaction:
|
if owns_transaction:
|
||||||
|
|||||||
@@ -187,6 +187,39 @@ class TestGateNoCompleteDay:
|
|||||||
assert any("full local observation day" in fact
|
assert any("full local observation day" in fact
|
||||||
for fact in result.contributing_facts)
|
for fact in result.contributing_facts)
|
||||||
|
|
||||||
|
def test_day_split_by_deliberate_pause_does_not_open_gate(self, store):
|
||||||
|
"""A complete-looking summary cannot span separate monitoring periods."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store)
|
||||||
|
for i in range(20):
|
||||||
|
day = (datetime(2026, 9, 10) + 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)
|
||||||
|
|
||||||
|
store.executemany(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
[
|
||||||
|
("2026-09-29T00:00:00+00:00", "2026-09-29T12:00:00+00:00", "user_disabled"),
|
||||||
|
("2026-09-29T13:00:00+00:00", "2026-09-30T00:00:00+00:00", "user_disabled"),
|
||||||
|
("2026-09-30T00:00:00+00:00", None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
store.commit()
|
||||||
|
_insert_local_day(
|
||||||
|
store,
|
||||||
|
"2026-09-29",
|
||||||
|
utc_start="2026-09-29T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-30T00:00:00+00:00",
|
||||||
|
complete=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
def test_partial_but_trusted_local_activity_opens_gate(self, store):
|
||||||
"""Known local intervals can coexist with an incomplete day total."""
|
"""Known local intervals can coexist with an incomplete day total."""
|
||||||
_insert_baseline(store)
|
_insert_baseline(store)
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ class TestStalenessFact:
|
|||||||
_insert_baseline(conn)
|
_insert_baseline(conn)
|
||||||
|
|
||||||
base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
ensure_period_open(conn, base)
|
||||||
|
conn.commit()
|
||||||
for i in range(14):
|
for i in range(14):
|
||||||
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, day, bw=10*1024*1024*1024)
|
_insert_day(conn, day, bw=10*1024*1024*1024)
|
||||||
@@ -234,6 +236,8 @@ class TestStalenessFact:
|
|||||||
_insert_baseline(conn)
|
_insert_baseline(conn)
|
||||||
|
|
||||||
base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc)
|
base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
ensure_period_open(conn, base)
|
||||||
|
conn.commit()
|
||||||
for i in range(14):
|
for i in range(14):
|
||||||
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, day, bw=10*1024*1024*1024)
|
_insert_day(conn, day, bw=10*1024*1024*1024)
|
||||||
|
|||||||
+11
-2
@@ -60,6 +60,13 @@ def _clock(year=2026, month=9, day=30, hour=12):
|
|||||||
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class _FrozenTuiDateTime(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
instant = datetime(2026, 9, 28, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
return instant.replace(tzinfo=None) if tz is None else instant.astimezone(tz)
|
||||||
|
|
||||||
|
|
||||||
def _insert_baseline(conn, tbw_tb=1.0, verified=True,
|
def _insert_baseline(conn, tbw_tb=1.0, verified=True,
|
||||||
model="Samsung SSD 970 EVO Plus 1TB"):
|
model="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1022,7 +1029,7 @@ class TestBarGraphTUI:
|
|||||||
assert len(graph._day_data) == 90
|
assert len(graph._day_data) == 90
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_readout_updates_on_selection(self, tmp_path):
|
async def test_readout_updates_on_selection(self, tmp_path, monkeypatch):
|
||||||
"""Readout shows selected day info."""
|
"""Readout shows selected day info."""
|
||||||
conn = init_store(tmp_path / "test.db")
|
conn = init_store(tmp_path / "test.db")
|
||||||
_insert_segment(conn)
|
_insert_segment(conn)
|
||||||
@@ -1034,6 +1041,7 @@ class TestBarGraphTUI:
|
|||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime)
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test(size=(80, 24)) as pilot:
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
await pilot.press("v", "v")
|
await pilot.press("v", "v")
|
||||||
@@ -1056,7 +1064,7 @@ class TestBarGraphTUI:
|
|||||||
assert "W 0.105 GB known" in readout
|
assert "W 0.105 GB known" in readout
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_hourly_drill_down_and_back(self, tmp_path):
|
async def test_hourly_drill_down_and_back(self, tmp_path, monkeypatch):
|
||||||
"""Enter drills into hourly view, Esc returns to daily."""
|
"""Enter drills into hourly view, Esc returns to daily."""
|
||||||
conn = init_store(tmp_path / "test.db")
|
conn = init_store(tmp_path / "test.db")
|
||||||
_insert_segment(conn)
|
_insert_segment(conn)
|
||||||
@@ -1073,6 +1081,7 @@ class TestBarGraphTUI:
|
|||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime)
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test(size=(80, 24)) as pilot:
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
await pilot.press("v", "v")
|
await pilot.press("v", "v")
|
||||||
|
|||||||
Reference in New Issue
Block a user