Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed61c4e1ec | ||
|
|
197e8ed02d | ||
|
|
79478653fa | ||
|
|
30122c5e6d | ||
|
|
01240ec8f0 | ||
|
|
7e270150bc | ||
|
|
3d71ebbc88 | ||
|
|
607dc83e55 | ||
|
|
d6fa94001c | ||
|
|
4f2f30abac | ||
|
|
5d916ee97f | ||
|
|
6917a658cb | ||
|
|
d790ff84c5 |
@@ -9,6 +9,16 @@ backfill releases from before this changelog.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.5] - 2026-09-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Show trustworthy first-use history, qualifying-day progress, and confidence in the dashboard.
|
||||||
|
- Add an interactive daily-writes graph with hourly drill-down and evidence-anchored scenario windows.
|
||||||
|
- Repair retained observation history safely and keep its retention state visible.
|
||||||
|
- Present a unified monitoring status, drive-health context, and Fenris identity in the dashboard.
|
||||||
|
- Remember accessible colour and motion preferences and keep the dashboard usable at constrained terminal sizes.
|
||||||
|
|
||||||
## [0.3.4] - 2026-09-10
|
## [0.3.4] - 2026-09-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fenris"
|
name = "fenris"
|
||||||
version = "0.3.4"
|
version = "0.3.5"
|
||||||
description = "NVMe wear monitor with persistent TUI"
|
description = "NVMe wear monitor with persistent TUI"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+40
-5
@@ -16,6 +16,8 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
from .store import init_store, get_store_path
|
from .store import init_store, get_store_path
|
||||||
|
from .monitoring_periods import ensure_period_open
|
||||||
|
from .derive import find_previous_sample, derive_hours_from_interval
|
||||||
|
|
||||||
|
|
||||||
class AcquisitionError(Exception):
|
class AcquisitionError(Exception):
|
||||||
@@ -221,7 +223,11 @@ def write_sample(
|
|||||||
open_segment(conn, now, identity, identity_key, identity_degraded)
|
open_segment(conn, now, identity, identity_key, identity_degraded)
|
||||||
segment_opened = True
|
segment_opened = True
|
||||||
|
|
||||||
# Insert sample
|
# Get current segment_id for provenance
|
||||||
|
current_segment = find_current_segment(conn)
|
||||||
|
segment_id = current_segment["id"] if current_segment else None
|
||||||
|
|
||||||
|
# Insert sample with segment_id
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO samples (
|
INSERT INTO samples (
|
||||||
@@ -229,8 +235,8 @@ def write_sample(
|
|||||||
percentage_used, available_spare, media_errors, power_on_hours,
|
percentage_used, available_spare, media_errors, power_on_hours,
|
||||||
power_cycles, unsafe_shutdowns, temperature_c,
|
power_cycles, unsafe_shutdowns, temperature_c,
|
||||||
data_units_written, data_units_read, bytes_written, bytes_read,
|
data_units_written, data_units_read, bytes_written, bytes_read,
|
||||||
critical_warning
|
critical_warning, segment_id
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
sample["ts"],
|
sample["ts"],
|
||||||
@@ -252,6 +258,7 @@ def write_sample(
|
|||||||
sample["bytes_written"],
|
sample["bytes_written"],
|
||||||
sample["bytes_read"],
|
sample["bytes_read"],
|
||||||
sample["critical_warning"],
|
sample["critical_warning"],
|
||||||
|
segment_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,6 +269,7 @@ def write_sample(
|
|||||||
"segment_reason": reason,
|
"segment_reason": reason,
|
||||||
"identity_key": identity_key,
|
"identity_key": identity_key,
|
||||||
"identity_degraded": identity_degraded,
|
"identity_degraded": identity_degraded,
|
||||||
|
"segment_id": segment_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -303,11 +311,38 @@ def run_collection(
|
|||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Ensure monitoring period is open (issue #73 AC2)
|
||||||
|
ensure_period_open(conn, clock.utcnow())
|
||||||
|
|
||||||
# Validate invariants
|
# Validate invariants
|
||||||
validate_sample_invariants(sample, conn)
|
validate_sample_invariants(sample, conn)
|
||||||
|
|
||||||
# Write sample
|
# Write sample and get segment info
|
||||||
write_sample(sample, identity, conn, clock)
|
seg_info = write_sample(sample, identity, conn, clock)
|
||||||
|
|
||||||
|
# Derive hour observations from interval with previous sample
|
||||||
|
try:
|
||||||
|
# Find the sample we just wrote
|
||||||
|
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
|
||||||
|
current_id = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
|
||||||
|
if prev is not None:
|
||||||
|
# Build current sample dict for derivation
|
||||||
|
current = {
|
||||||
|
"id": current_id,
|
||||||
|
"ts": sample["ts"],
|
||||||
|
"bytes_written": sample["bytes_written"],
|
||||||
|
"bytes_read": sample["bytes_read"],
|
||||||
|
"power_on_hours": sample["power_on_hours"],
|
||||||
|
"temperature_c": sample["temperature_c"],
|
||||||
|
"data_units_written": sample["data_units_written"],
|
||||||
|
"data_units_read": sample["data_units_read"],
|
||||||
|
}
|
||||||
|
derive_hours_from_interval(conn, prev, current)
|
||||||
|
except Exception:
|
||||||
|
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Interval derivation: samples → hour observations → day aggregates.
|
||||||
|
|
||||||
|
After each collection run, the collector calls into this module to:
|
||||||
|
1. Find the previous sample in the same segment
|
||||||
|
2. Compute deltas (bytes, POH, temperature)
|
||||||
|
3. Classify the hour(s) the interval spans
|
||||||
|
4. Write/update hour_observations for each affected hour
|
||||||
|
5. Update day_aggregates with unattributed cross-hour bytes
|
||||||
|
|
||||||
|
Cross-hour deltas are retained once with unknown shares explicit (issue #73 AC4).
|
||||||
|
No proportional allocation, endpoint assignment, or double counting.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .hour_classify import classify_hour, HourSplit
|
||||||
|
|
||||||
|
|
||||||
|
def find_previous_sample(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
segment_id: Optional[int],
|
||||||
|
current_sample_id: int,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find the most recent sample before current_sample_id in the same segment.
|
||||||
|
|
||||||
|
Returns None if no previous sample exists (first sample in segment).
|
||||||
|
"""
|
||||||
|
if segment_id is not None:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
|
||||||
|
" temperature_c, data_units_written, data_units_read "
|
||||||
|
"FROM samples WHERE id < ? AND segment_id = ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(current_sample_id, segment_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
|
||||||
|
" temperature_c, data_units_written, data_units_read "
|
||||||
|
"FROM samples WHERE id < ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(current_sample_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row[0], "ts": row[1], "bytes_written": row[2],
|
||||||
|
"bytes_read": row[3], "power_on_hours": row[4],
|
||||||
|
"temperature_c": row[5], "data_units_written": row[6],
|
||||||
|
"data_units_read": row[7],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts: str) -> datetime:
|
||||||
|
"""Parse ISO timestamp to datetime with UTC."""
|
||||||
|
dt = datetime.fromisoformat(ts)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _hour_floor(dt: datetime) -> datetime:
|
||||||
|
"""Floor a datetime to its UTC hour boundary."""
|
||||||
|
return dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _hours_spanned(start: datetime, end: datetime) -> List[datetime]:
|
||||||
|
"""Return list of UTC hour boundaries spanned by [start, end)."""
|
||||||
|
hours = []
|
||||||
|
h = _hour_floor(start)
|
||||||
|
while h < end:
|
||||||
|
hours.append(h)
|
||||||
|
h += timedelta(hours=1)
|
||||||
|
return hours
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_sampled_seconds_in_hour(
|
||||||
|
start: datetime, end: datetime, hour_start: datetime
|
||||||
|
) -> int:
|
||||||
|
"""How many seconds of the sample interval fall within this hour."""
|
||||||
|
hour_end = hour_start + timedelta(hours=1)
|
||||||
|
effective_start = max(start, hour_start)
|
||||||
|
effective_end = min(end, hour_end)
|
||||||
|
if effective_start >= effective_end:
|
||||||
|
return 0
|
||||||
|
return int((effective_end - effective_start).total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
def derive_hours_from_interval(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
prev_sample: Dict[str, Any],
|
||||||
|
next_sample: Dict[str, Any],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Derive hour observations from a sample pair interval.
|
||||||
|
|
||||||
|
Returns list of hour observation dicts that were written/updated.
|
||||||
|
"""
|
||||||
|
prev_ts = _parse_ts(prev_sample["ts"])
|
||||||
|
next_ts = _parse_ts(next_sample["ts"])
|
||||||
|
|
||||||
|
# Deltas
|
||||||
|
bw_delta = max(0, next_sample["bytes_written"] - prev_sample["bytes_written"])
|
||||||
|
br_delta = max(0, next_sample["bytes_read"] - prev_sample["bytes_read"])
|
||||||
|
poh_delta_s = max(0, (next_sample["power_on_hours"] - prev_sample["power_on_hours"])) * 3600
|
||||||
|
|
||||||
|
hours = _hours_spanned(prev_ts, next_ts)
|
||||||
|
total_span_s = int((next_ts - prev_ts).total_seconds())
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if len(hours) == 1:
|
||||||
|
# Same-hour interval: fully attributed to this hour
|
||||||
|
hour_key = hours[0].strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||||
|
sampled_s = total_span_s
|
||||||
|
|
||||||
|
# Classify hour
|
||||||
|
split = classify_hour(
|
||||||
|
wall_clock_seconds=3600,
|
||||||
|
poh_delta=poh_delta_s,
|
||||||
|
duw_delta=bw_delta,
|
||||||
|
dur_delta=br_delta,
|
||||||
|
sampled_seconds=sampled_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
_upsert_hour_observation(
|
||||||
|
conn, hour_key, split,
|
||||||
|
bw_delta, br_delta,
|
||||||
|
prev_sample.get("temperature_c"), next_sample.get("temperature_c"),
|
||||||
|
2, # 2 samples contributed (prev + next)
|
||||||
|
)
|
||||||
|
results.append({"hour": hour_key, "bytes_written": bw_delta, "attributed": True})
|
||||||
|
|
||||||
|
elif len(hours) >= 2:
|
||||||
|
# Cross-hour interval: split wall-clock time, bytes unattributed
|
||||||
|
for h in hours:
|
||||||
|
hour_key = h.strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||||
|
sampled_s = _compute_sampled_seconds_in_hour(prev_ts, next_ts, h)
|
||||||
|
|
||||||
|
# For cross-hour, we classify based on time only (no byte attribution)
|
||||||
|
# The hour gets its time split but NOT the byte delta
|
||||||
|
split = classify_hour(
|
||||||
|
wall_clock_seconds=3600,
|
||||||
|
poh_delta=0, # POH attribution unknown for cross-hour
|
||||||
|
duw_delta=0, # Bytes unattributed
|
||||||
|
dur_delta=0,
|
||||||
|
sampled_seconds=sampled_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
_upsert_hour_observation(
|
||||||
|
conn, hour_key, split,
|
||||||
|
0, 0, # No byte attribution for cross-hour
|
||||||
|
None, None,
|
||||||
|
0, # No sample falls IN this hour
|
||||||
|
)
|
||||||
|
results.append({"hour": hour_key, "bytes_written": 0, "attributed": False})
|
||||||
|
|
||||||
|
# Track unattributed bytes at day level
|
||||||
|
_add_unattributed_bytes(conn, prev_ts, next_ts, bw_delta, br_delta)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_hour_observation(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
hour_key: str,
|
||||||
|
split: HourSplit,
|
||||||
|
bw_delta: int,
|
||||||
|
br_delta: int,
|
||||||
|
temp_min: Optional[int],
|
||||||
|
temp_max: Optional[int],
|
||||||
|
sample_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Insert or update an hour observation."""
|
||||||
|
# Check if hour exists
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?",
|
||||||
|
(hour_key,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
temp_avg = ((temp_min or 0) + (temp_max or 0)) / 2 if temp_min is not None else None
|
||||||
|
coverage = (split.seconds_active + split.seconds_idle + split.seconds_powered_off) / 3600.0
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO hour_observations
|
||||||
|
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||||
|
bytes_written_delta, bytes_read_delta,
|
||||||
|
temperature_min, temperature_avg, temperature_max,
|
||||||
|
sample_count, coverage)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(hour_key, split.seconds_active, split.seconds_idle,
|
||||||
|
split.seconds_powered_off, split.seconds_unknown,
|
||||||
|
bw_delta, br_delta,
|
||||||
|
temp_min, temp_avg, temp_max,
|
||||||
|
sample_count, coverage),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Merge: accumulate bytes and sample count
|
||||||
|
new_bw = existing[1] + bw_delta
|
||||||
|
new_samples = existing[2] + sample_count
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
|
||||||
|
(new_bw, new_samples, existing[0]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_unattributed_bytes(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
prev_ts: datetime,
|
||||||
|
next_ts: datetime,
|
||||||
|
bw_delta: int,
|
||||||
|
br_delta: int,
|
||||||
|
) -> None:
|
||||||
|
"""Add unattributed byte deltas to day aggregates for each day touched."""
|
||||||
|
prev_day = prev_ts.strftime("%Y-%m-%d")
|
||||||
|
next_day = next_ts.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
days = {prev_day, next_day}
|
||||||
|
for day in days:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM day_aggregates WHERE day = ?", (day,)
|
||||||
|
).fetchone()
|
||||||
|
if existing is None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
(day, bw_delta, br_delta),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, "
|
||||||
|
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
|
||||||
|
(bw_delta, br_delta, day),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""User-scoped TUI preferences (issue #80).
|
||||||
|
|
||||||
|
Persists theme preset and reduced-motion choice per unprivileged user.
|
||||||
|
Preferences live at XDG_CONFIG_HOME/fenris/preferences.json and must not
|
||||||
|
affect collection, projection, history evidence, helper state, package
|
||||||
|
config, or CLI status.
|
||||||
|
|
||||||
|
Safe failures: invalid/unreadable/unwritable data never crashes the
|
||||||
|
dashboard, corrupts previous preferences, or affects monitoring.
|
||||||
|
Failures are understandable rather than silently implying persistence
|
||||||
|
succeeded.
|
||||||
|
|
||||||
|
Criteria: TPH-10, AC80-2, AC80-3.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
PREFERENCE_FILE_NAME = "preferences.json"
|
||||||
|
VALID_THEMES = {"amber", "nord", "high_contrast"}
|
||||||
|
DEFAULT_THEME = "amber"
|
||||||
|
DEFAULT_REDUCED_MOTION = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_preference_path() -> Path:
|
||||||
|
"""Return the user-scoped preference file path.
|
||||||
|
|
||||||
|
Uses XDG_CONFIG_HOME/fenris/preferences.json.
|
||||||
|
Falls back to ~/.config/fenris/preferences.json if unset.
|
||||||
|
"""
|
||||||
|
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
if xdg:
|
||||||
|
base = Path(xdg)
|
||||||
|
else:
|
||||||
|
base = Path.home() / ".config"
|
||||||
|
return base / "fenris" / PREFERENCE_FILE_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def load_preferences() -> Dict[str, Any]:
|
||||||
|
"""Load user preferences with safe defaults.
|
||||||
|
|
||||||
|
Returns a dict with keys:
|
||||||
|
theme: str (one of VALID_THEMES)
|
||||||
|
reduced_motion: bool
|
||||||
|
|
||||||
|
If the file is missing, corrupt, unreadable, or contains invalid
|
||||||
|
values, returns safe defaults (Amber theme, normal motion).
|
||||||
|
"""
|
||||||
|
path = get_preference_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = path.read_text()
|
||||||
|
except (OSError, FileNotFoundError):
|
||||||
|
return _defaults()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return _defaults()
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return _defaults()
|
||||||
|
|
||||||
|
theme = data.get("theme", DEFAULT_THEME)
|
||||||
|
if theme not in VALID_THEMES:
|
||||||
|
theme = DEFAULT_THEME
|
||||||
|
|
||||||
|
reduced_motion = data.get("reduced_motion", DEFAULT_REDUCED_MOTION)
|
||||||
|
if not isinstance(reduced_motion, bool):
|
||||||
|
reduced_motion = DEFAULT_REDUCED_MOTION
|
||||||
|
|
||||||
|
return {"theme": theme, "reduced_motion": reduced_motion}
|
||||||
|
|
||||||
|
|
||||||
|
def save_preferences(theme: str = DEFAULT_THEME,
|
||||||
|
reduced_motion: bool = DEFAULT_REDUCED_MOTION) -> None:
|
||||||
|
"""Save user preferences.
|
||||||
|
|
||||||
|
Creates the config directory if needed. If the write fails
|
||||||
|
(read-only filesystem, permissions), the failure is swallowed —
|
||||||
|
the TUI continues with whatever was loaded, and the user sees
|
||||||
|
no crash or error.
|
||||||
|
"""
|
||||||
|
path = get_preference_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = json.dumps({"theme": theme, "reduced_motion": reduced_motion}, indent=2)
|
||||||
|
path.write_text(payload + "\n")
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
# Best-effort persistence — failure must not crash the TUI
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults() -> Dict[str, Any]:
|
||||||
|
return {"theme": DEFAULT_THEME, "reduced_motion": DEFAULT_REDUCED_MOTION}
|
||||||
+51
-11
@@ -73,6 +73,7 @@ class ScenarioRange:
|
|||||||
rates: Dict[int, float]
|
rates: Dict[int, float]
|
||||||
min_days: int
|
min_days: int
|
||||||
max_days: int
|
max_days: int
|
||||||
|
horizon_reasons: Dict[int, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -91,6 +92,7 @@ class ProjectionResult:
|
|||||||
staleness_fact: Optional[str]
|
staleness_fact: Optional[str]
|
||||||
degraded_identity_fact: Optional[str]
|
degraded_identity_fact: Optional[str]
|
||||||
zero_rate_fact: Optional[str]
|
zero_rate_fact: Optional[str]
|
||||||
|
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -234,20 +236,39 @@ def _compute_regime_rate(days, conn, regime_start_day, clock_now):
|
|||||||
return regime_bytes / regime_wc, regime_bytes, regime_wc
|
return regime_bytes / regime_wc, regime_bytes, regime_wc
|
||||||
|
|
||||||
|
|
||||||
def _compute_horizon_rate(days, conn, horizon_days, clock_now):
|
def _compute_horizon_rate(days, conn, horizon_days, evidence_endpoint):
|
||||||
cutoff = (clock_now - timedelta(days=horizon_days)).strftime("%Y-%m-%d")
|
"""Compute horizon rate anchored at the latest evidence endpoint T.
|
||||||
|
|
||||||
|
Uses exact trailing horizon_days × 86400 seconds from T, not clock_now.
|
||||||
|
Reader refresh alone never moves T or dilutes rates.
|
||||||
|
|
||||||
|
Returns (rate, reason) where reason is None on success or a string
|
||||||
|
describing why the rate is unavailable.
|
||||||
|
"""
|
||||||
|
if not days:
|
||||||
|
return None, "no observation history"
|
||||||
|
|
||||||
|
# T is the latest evidence endpoint — the end of the last day aggregate
|
||||||
|
T = datetime.fromisoformat(days[-1]["day"] + "T23:59:59+00:00")
|
||||||
|
|
||||||
|
# Exact trailing start: T minus horizon_days × 86400 seconds
|
||||||
|
h_start = T - timedelta(days=horizon_days)
|
||||||
|
cutoff = h_start.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# History must span the full horizon — no placeholders
|
# History must span the full horizon — no placeholders
|
||||||
if not days or days[0]["day"] > cutoff:
|
if days[0]["day"] > cutoff:
|
||||||
return None
|
return None, "%d-day window starts before earliest data" % horizon_days
|
||||||
|
|
||||||
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
|
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
|
||||||
covered = sum(1 for d in days if d["day"] >= cutoff)
|
covered = sum(1 for d in days if d["day"] >= cutoff)
|
||||||
if covered == 0:
|
if covered == 0:
|
||||||
return None
|
return None, "%d-day window has no data" % horizon_days
|
||||||
h_start = datetime.fromisoformat(cutoff + "T00:00:00+00:00")
|
|
||||||
h_wc = _wall_clock_in_range(conn, h_start, clock_now)
|
h_wc = _wall_clock_in_range(conn, h_start, T)
|
||||||
if h_wc <= 0:
|
if h_wc <= 0:
|
||||||
return None
|
return None, "%d-day window has no monitored wall-clock time" % horizon_days
|
||||||
return h_bytes / h_wc
|
|
||||||
|
return h_bytes / h_wc, None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -508,17 +529,35 @@ def compute_projection(conn, clock_now):
|
|||||||
|
|
||||||
scenario = None
|
scenario = None
|
||||||
horizon_rates = {}
|
horizon_rates = {}
|
||||||
|
horizon_reasons = {}
|
||||||
for h in HORIZON_DAYS:
|
for h in HORIZON_DAYS:
|
||||||
hr = _compute_horizon_rate(all_days, conn, h, clock_now)
|
hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
|
||||||
if hr is not None:
|
if hr is not None:
|
||||||
horizon_rates[h] = hr
|
horizon_rates[h] = hr
|
||||||
|
else:
|
||||||
|
horizon_reasons[h] = reason
|
||||||
if horizon_rates:
|
if horizon_rates:
|
||||||
scenario = ScenarioRange(rates=horizon_rates, min_days=min(horizon_rates), max_days=max(horizon_rates))
|
scenario = ScenarioRange(
|
||||||
|
rates=horizon_rates,
|
||||||
|
min_days=min(horizon_rates),
|
||||||
|
max_days=max(horizon_rates),
|
||||||
|
horizon_reasons=horizon_reasons,
|
||||||
|
)
|
||||||
|
|
||||||
total_days_count = len(segment_days)
|
total_days_count = len(segment_days)
|
||||||
days_below_coverage = sum(1 for d in segment_days
|
days_below_coverage = sum(1 for d in segment_days
|
||||||
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
|
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
|
||||||
qualifying = total_days_count - days_below_coverage
|
qualifying = total_days_count - days_below_coverage
|
||||||
|
|
||||||
|
# Issue #77: Show honest qualifying-day progress
|
||||||
|
if total_days_count >= WARMING_MIN_DAYS:
|
||||||
|
# After warm-up, show qualifying day details for transparency
|
||||||
|
qualifying_days_progress = "%d of %d qualifying days" % (qualifying, total_days_count)
|
||||||
|
if days_below_coverage > 0:
|
||||||
|
qualifying_days_progress += " (%d below coverage)" % days_below_coverage
|
||||||
|
else:
|
||||||
|
qualifying_days_progress = None
|
||||||
|
|
||||||
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
|
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
|
||||||
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
|
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
|
||||||
facts.append(warming_fact)
|
facts.append(warming_fact)
|
||||||
@@ -577,4 +616,5 @@ def compute_projection(conn, clock_now):
|
|||||||
staleness_fact=staleness_fact,
|
staleness_fact=staleness_fact,
|
||||||
degraded_identity_fact=degraded_identity_fact,
|
degraded_identity_fact=degraded_identity_fact,
|
||||||
zero_rate_fact=zero_rate_fact,
|
zero_rate_fact=zero_rate_fact,
|
||||||
|
qualifying_days_progress=qualifying_days_progress,
|
||||||
)
|
)
|
||||||
|
|||||||
+133
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Raw samples are pruned opportunistically to 14 days.
|
Raw samples are pruned opportunistically to 14 days.
|
||||||
Hour observations and day aggregates are retained indefinitely.
|
Hour observations and day aggregates are retained indefinitely.
|
||||||
|
Boundary anchors required for successor evidence are retained.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -10,6 +11,117 @@ from datetime import datetime, timedelta, timezone
|
|||||||
RAW_SAMPLE_RETENTION_DAYS = 14
|
RAW_SAMPLE_RETENTION_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
|
def needs_boundary_anchor(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
sample_ts: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a sample is needed as a boundary anchor for derivation.
|
||||||
|
|
||||||
|
A sample is a boundary anchor if:
|
||||||
|
1. It's older than retention_days (strictly before cutoff)
|
||||||
|
2. It has a next sample that forms an interval spanning the retention boundary
|
||||||
|
3. The interval hasn't been derived yet
|
||||||
|
|
||||||
|
The interval spans the boundary if:
|
||||||
|
- The sample is before the cutoff, AND
|
||||||
|
- The next sample is strictly after the cutoff (or within retention)
|
||||||
|
"""
|
||||||
|
from .derive import _parse_ts
|
||||||
|
|
||||||
|
sample_dt = _parse_ts(sample_ts)
|
||||||
|
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
|
||||||
|
|
||||||
|
# If sample is within retention (strictly after cutoff), not an anchor
|
||||||
|
if sample_dt > retention_cutoff:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if this sample has a next sample
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT ts, segment_id FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
|
||||||
|
(sample_ts,),
|
||||||
|
)
|
||||||
|
next_row = cursor.fetchone()
|
||||||
|
|
||||||
|
if next_row is None:
|
||||||
|
# No next sample - this is the last sample
|
||||||
|
# It's not needed for derivation (no interval to derive)
|
||||||
|
return False
|
||||||
|
|
||||||
|
next_ts_str = next_row[0]
|
||||||
|
next_segment_id = next_row[1]
|
||||||
|
next_dt = _parse_ts(next_ts_str)
|
||||||
|
|
||||||
|
# Check if the next sample is strictly after the cutoff (i.e., interval spans boundary)
|
||||||
|
if next_dt > retention_cutoff:
|
||||||
|
# The interval spans the retention boundary
|
||||||
|
# Check if the interval needs derivation
|
||||||
|
# Get current sample's segment_id
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT segment_id FROM samples WHERE ts = ?",
|
||||||
|
(sample_ts,),
|
||||||
|
)
|
||||||
|
current_segment_row = cursor.fetchone()
|
||||||
|
current_segment_id = current_segment_row[0] if current_segment_row else None
|
||||||
|
|
||||||
|
# If different segments, no interval to derive
|
||||||
|
if current_segment_id != next_segment_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if the interval [sample_ts, next_ts] needs derivation
|
||||||
|
# It needs derivation if any hour in the span lacks an observation
|
||||||
|
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
while current_hour <= end_hour:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id FROM hour_observations WHERE hour = ?",
|
||||||
|
(current_hour.isoformat(),),
|
||||||
|
)
|
||||||
|
if cursor.fetchone() is None:
|
||||||
|
# This hour lacks an observation - interval needs derivation
|
||||||
|
return True
|
||||||
|
current_hour += timedelta(hours=1)
|
||||||
|
|
||||||
|
# All hours in the span have observations - interval is derived
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# The interval doesn't span the boundary (both samples are old)
|
||||||
|
# Check if the interval needs derivation
|
||||||
|
# Get current sample's segment_id
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT segment_id FROM samples WHERE ts = ?",
|
||||||
|
(sample_ts,),
|
||||||
|
)
|
||||||
|
current_segment_row = cursor.fetchone()
|
||||||
|
current_segment_id = current_segment_row[0] if current_segment_row else None
|
||||||
|
|
||||||
|
# If different segments, no interval to derive
|
||||||
|
if current_segment_id != next_segment_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if the interval [sample_ts, next_ts] needs derivation
|
||||||
|
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
while current_hour <= end_hour:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id FROM hour_observations WHERE hour = ?",
|
||||||
|
(current_hour.isoformat(),),
|
||||||
|
)
|
||||||
|
if cursor.fetchone() is None:
|
||||||
|
# This hour lacks an observation - interval needs derivation
|
||||||
|
# But only keep if the interval is significant (spans multiple hours)
|
||||||
|
# or if the next sample is the last sample before a gap
|
||||||
|
gap = (next_dt - sample_dt).total_seconds()
|
||||||
|
if gap > 24 * 3600: # Significant gap (> 24 hours)
|
||||||
|
return True
|
||||||
|
current_hour += timedelta(hours=1)
|
||||||
|
|
||||||
|
# All hours in the span have observations or gap is not significant
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def prune_old_samples(
|
def prune_old_samples(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
@@ -17,6 +129,8 @@ def prune_old_samples(
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""Remove raw samples older than retention_days.
|
"""Remove raw samples older than retention_days.
|
||||||
|
|
||||||
|
Retains boundary anchors required for successor evidence.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: Connection to the observation store.
|
conn: Connection to the observation store.
|
||||||
now: Current UTC time.
|
now: Current UTC time.
|
||||||
@@ -26,6 +140,23 @@ def prune_old_samples(
|
|||||||
Number of samples removed.
|
Number of samples removed.
|
||||||
"""
|
"""
|
||||||
cutoff = (now - timedelta(days=retention_days)).isoformat()
|
cutoff = (now - timedelta(days=retention_days)).isoformat()
|
||||||
cursor = conn.execute("DELETE FROM samples WHERE ts < ?", (cutoff,))
|
|
||||||
|
# Get all samples older than cutoff
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, ts FROM samples WHERE ts < ? ORDER BY ts",
|
||||||
|
(cutoff,),
|
||||||
|
)
|
||||||
|
old_samples = cursor.fetchall()
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
for sample_id, sample_ts in old_samples:
|
||||||
|
# Check if this sample is a boundary anchor
|
||||||
|
if needs_boundary_anchor(conn, sample_ts, now):
|
||||||
|
continue # Skip - it's a boundary anchor
|
||||||
|
|
||||||
|
# Remove the sample
|
||||||
|
conn.execute("DELETE FROM samples WHERE id = ?", (sample_id,))
|
||||||
|
removed += 1
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount
|
return removed
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"""Historical repair and retention (issue #74).
|
||||||
|
|
||||||
|
Re-derives hour observations and day aggregates from surviving raw samples,
|
||||||
|
with idempotent and interruption-safe guarantees. Preserves import markers,
|
||||||
|
boundary anchors, and valid historical summaries.
|
||||||
|
|
||||||
|
Contracts:
|
||||||
|
- Idempotent: running repair multiple times produces no duplicates
|
||||||
|
- Interruption-safe: partial repair preserves prior valid history
|
||||||
|
- Preserves existing valid data: never overwrites valid derived data
|
||||||
|
- Surfaces failures explicitly for retry
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
|
from .derive import find_previous_sample, derive_hours_from_interval, _parse_ts
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RepairResult:
|
||||||
|
"""Result of a repair operation."""
|
||||||
|
ok: bool
|
||||||
|
hours_created: int = 0
|
||||||
|
hours_updated: int = 0
|
||||||
|
days_created: int = 0
|
||||||
|
days_updated: int = 0
|
||||||
|
intervals_derived: int = 0
|
||||||
|
boundary_anchors_retained: int = 0
|
||||||
|
legacy_summaries_preserved: int = 0
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RepairStatus:
|
||||||
|
"""Current repair status for read-only views."""
|
||||||
|
last_repair: Optional[str] = None # ISO timestamp of last successful repair
|
||||||
|
repair_in_progress: bool = False
|
||||||
|
hours_derived: int = 0
|
||||||
|
days_derived: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_repair_metadata(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Ensure metadata table exists for tracking repair state."""
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS store_metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def is_repair_in_progress(conn: sqlite3.Connection) -> bool:
|
||||||
|
"""Check if a repair operation is currently in progress."""
|
||||||
|
_ensure_repair_metadata(conn)
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'repair_in_progress'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row is not None and row[0] == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_repair_in_progress(conn: sqlite3.Connection, in_progress: bool) -> None:
|
||||||
|
"""Mark repair as in progress or complete."""
|
||||||
|
_ensure_repair_metadata(conn)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||||
|
("repair_in_progress", "true" if in_progress else "false"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> None:
|
||||||
|
"""Update repair status after successful completion."""
|
||||||
|
_ensure_repair_metadata(conn)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
# Update last repair timestamp
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||||
|
("last_repair", now),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update derived counts
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||||
|
hours = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||||
|
days = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||||
|
("hours_derived", str(hours)),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||||
|
("days_derived", str(days)),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_repair_status(conn: sqlite3.Connection) -> RepairStatus:
|
||||||
|
"""Get current repair status for read-only views."""
|
||||||
|
_ensure_repair_metadata(conn)
|
||||||
|
|
||||||
|
last_repair = None
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'last_repair'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
last_repair = row[0]
|
||||||
|
|
||||||
|
in_progress = is_repair_in_progress(conn)
|
||||||
|
|
||||||
|
hours_derived = 0
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'hours_derived'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
hours_derived = int(row[0])
|
||||||
|
|
||||||
|
days_derived = 0
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'days_derived'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
days_derived = int(row[0])
|
||||||
|
|
||||||
|
return RepairStatus(
|
||||||
|
last_repair=last_repair,
|
||||||
|
repair_in_progress=in_progress,
|
||||||
|
hours_derived=hours_derived,
|
||||||
|
days_derived=days_derived,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def needs_boundary_anchor(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
sample_ts: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a sample is needed as a boundary anchor for derivation.
|
||||||
|
|
||||||
|
A sample is a boundary anchor if:
|
||||||
|
1. It's older than retention_days
|
||||||
|
2. It has no derived hour observation for its hour
|
||||||
|
3. It's the last sample before a gap that needs derivation (gap > 24 hours)
|
||||||
|
"""
|
||||||
|
from .pruning import RAW_SAMPLE_RETENTION_DAYS
|
||||||
|
|
||||||
|
sample_dt = _parse_ts(sample_ts)
|
||||||
|
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
|
||||||
|
|
||||||
|
# If sample is within retention, not an anchor (will be kept anyway)
|
||||||
|
if sample_dt >= retention_cutoff:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if this sample's hour already has a derived observation
|
||||||
|
hour_start = sample_dt.replace(minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
hour_end = (sample_dt + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT COUNT(*) FROM hour_observations
|
||||||
|
WHERE hour >= ? AND hour < ?""",
|
||||||
|
(hour_start, hour_end),
|
||||||
|
)
|
||||||
|
|
||||||
|
# If there's an hour observation in this sample's hour, it's been derived
|
||||||
|
if cursor.fetchone()[0] > 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if this sample is the last sample before a gap
|
||||||
|
# (i.e., the next sample is significantly later)
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT ts FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
|
||||||
|
(sample_ts,),
|
||||||
|
)
|
||||||
|
next_row = cursor.fetchone()
|
||||||
|
|
||||||
|
if next_row is None:
|
||||||
|
# No next sample - this is the last sample, might be needed
|
||||||
|
# But if it's old and fully derived, it's not needed
|
||||||
|
return False
|
||||||
|
|
||||||
|
next_ts = _parse_ts(next_row[0])
|
||||||
|
gap = (next_ts - sample_dt).total_seconds()
|
||||||
|
|
||||||
|
# If gap > 24 hours, this sample is a boundary anchor
|
||||||
|
# (needed to derive the interval spanning the gap)
|
||||||
|
return gap > 24 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _get_unlinked_intervals(conn: sqlite3.Connection) -> List[Tuple[dict, dict]]:
|
||||||
|
"""Find sample pairs that form intervals but have no hour observations."""
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT id, ts, bytes_written, bytes_read, power_on_hours,
|
||||||
|
temperature_c, data_units_written, data_units_read, segment_id
|
||||||
|
FROM samples ORDER BY ts"""
|
||||||
|
)
|
||||||
|
|
||||||
|
all_samples = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
all_samples.append({
|
||||||
|
"id": row[0], "ts": row[1], "bytes_written": row[2],
|
||||||
|
"bytes_read": row[3], "power_on_hours": row[4],
|
||||||
|
"temperature_c": row[5], "data_units_written": row[6],
|
||||||
|
"data_units_read": row[7], "segment_id": row[8],
|
||||||
|
})
|
||||||
|
|
||||||
|
intervals = []
|
||||||
|
for i in range(len(all_samples) - 1):
|
||||||
|
prev = all_samples[i]
|
||||||
|
next_s = all_samples[i + 1]
|
||||||
|
|
||||||
|
# Skip if different segments
|
||||||
|
if prev["segment_id"] != next_s["segment_id"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if the interval spans hours that need derivation
|
||||||
|
prev_dt = _parse_ts(prev["ts"])
|
||||||
|
next_dt = _parse_ts(next_s["ts"])
|
||||||
|
|
||||||
|
# Check if any hour in the span lacks an observation
|
||||||
|
current = prev_dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
end = next_dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
needs_derivation = False
|
||||||
|
while current <= end:
|
||||||
|
cursor2 = conn.execute(
|
||||||
|
"SELECT id FROM hour_observations WHERE hour = ?",
|
||||||
|
(current.isoformat(),),
|
||||||
|
)
|
||||||
|
if cursor2.fetchone() is None:
|
||||||
|
needs_derivation = True
|
||||||
|
break
|
||||||
|
current += timedelta(hours=1)
|
||||||
|
|
||||||
|
if needs_derivation:
|
||||||
|
intervals.append((prev, next_s))
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_day_aggregate_from_hours(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
day: str,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Derive a day aggregate from its hour observations."""
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT SUM(active_seconds), SUM(idle_seconds),
|
||||||
|
SUM(powered_off_seconds), SUM(unknown_seconds),
|
||||||
|
SUM(bytes_written_delta), SUM(bytes_read_delta),
|
||||||
|
SUM(sample_count)
|
||||||
|
FROM hour_observations WHERE hour LIKE ?""",
|
||||||
|
(day + "T%",),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is None or row[0] is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"day": day,
|
||||||
|
"active_seconds": row[0] or 0,
|
||||||
|
"idle_seconds": row[1] or 0,
|
||||||
|
"powered_off_seconds": row[2] or 0,
|
||||||
|
"unknown_seconds": row[3] or 0,
|
||||||
|
"bytes_written_delta": row[4] or 0,
|
||||||
|
"bytes_read_delta": row[5] or 0,
|
||||||
|
"sample_count": row[6] or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_day_aggregate(conn: sqlite3.Connection, day_data: dict) -> bool:
|
||||||
|
"""Insert or update a day aggregate. Returns True if created."""
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM day_aggregates WHERE day = ?",
|
||||||
|
(day_data["day"],),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
# Calculate coverage
|
||||||
|
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||||
|
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
|
||||||
|
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||||
|
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
|
||||||
|
|
||||||
|
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_data["day"], day_data["active_seconds"], day_data["idle_seconds"],
|
||||||
|
day_data["powered_off_seconds"], day_data["unknown_seconds"],
|
||||||
|
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
|
||||||
|
day_data["sample_count"], coverage),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# Update existing (but only if new data is more complete)
|
||||||
|
# This implements the "don't overwrite valid older history" rule
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT bytes_written_delta, sample_count
|
||||||
|
FROM day_aggregates WHERE day = ?""",
|
||||||
|
(day_data["day"],),
|
||||||
|
)
|
||||||
|
existing_data = cursor.fetchone()
|
||||||
|
|
||||||
|
# Only update if new data has more samples or more bytes
|
||||||
|
if (day_data["sample_count"] > existing_data[1] or
|
||||||
|
day_data["bytes_written_delta"] > existing_data[0]):
|
||||||
|
|
||||||
|
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||||
|
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
|
||||||
|
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||||
|
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE day_aggregates SET
|
||||||
|
active_seconds = ?, idle_seconds = ?, powered_off_seconds = ?,
|
||||||
|
unknown_seconds = ?, bytes_written_delta = ?, bytes_read_delta = ?,
|
||||||
|
sample_count = ?, coverage = ?
|
||||||
|
WHERE day = ?""",
|
||||||
|
(day_data["active_seconds"], day_data["idle_seconds"],
|
||||||
|
day_data["powered_off_seconds"], day_data["unknown_seconds"],
|
||||||
|
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
|
||||||
|
day_data["sample_count"], coverage, day_data["day"]),
|
||||||
|
)
|
||||||
|
return False # Updated, not created
|
||||||
|
else:
|
||||||
|
return False # No update needed
|
||||||
|
|
||||||
|
|
||||||
|
def repair_derivation(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
clock=None,
|
||||||
|
) -> RepairResult:
|
||||||
|
"""Repair hour observations and day aggregates from surviving samples.
|
||||||
|
|
||||||
|
This is the main entry point for historical repair. It:
|
||||||
|
1. Finds sample pairs that need interval derivation
|
||||||
|
2. Derives hour observations from those intervals
|
||||||
|
3. Updates day aggregates from the hour observations
|
||||||
|
4. Preserves existing valid data
|
||||||
|
5. Is idempotent and interruption-safe
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: Connection to the observation store
|
||||||
|
clock: Injected clock (for testing)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RepairResult with operation details
|
||||||
|
"""
|
||||||
|
if clock is None:
|
||||||
|
clock = datetime.now(timezone.utc)
|
||||||
|
elif hasattr(clock, 'utcnow'):
|
||||||
|
clock = clock.utcnow()
|
||||||
|
|
||||||
|
# Check if repair is already in progress
|
||||||
|
if is_repair_in_progress(conn):
|
||||||
|
return RepairResult(
|
||||||
|
ok=False,
|
||||||
|
error="Repair already in progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark repair as in progress
|
||||||
|
_set_repair_in_progress(conn, True)
|
||||||
|
|
||||||
|
result = RepairResult(ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Begin transaction
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
|
||||||
|
# 1. Find and derive intervals from sample pairs
|
||||||
|
intervals = _get_unlinked_intervals(conn)
|
||||||
|
|
||||||
|
for prev, next_s in intervals:
|
||||||
|
try:
|
||||||
|
derived_hours = derive_hours_from_interval(conn, prev, next_s)
|
||||||
|
result.intervals_derived += 1
|
||||||
|
result.hours_created += len([h for h in derived_hours if h.get("attributed", True)])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to derive interval %s -> %s: %s",
|
||||||
|
prev["ts"], next_s["ts"], e)
|
||||||
|
# Continue with other intervals (resilient)
|
||||||
|
|
||||||
|
# 2. Update day aggregates from hour observations
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT DISTINCT substr(hour, 1, 10) as day FROM hour_observations ORDER BY day"
|
||||||
|
)
|
||||||
|
days = [row[0] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
for day in days:
|
||||||
|
day_data = _derive_day_aggregate_from_hours(conn, day)
|
||||||
|
if day_data is not None:
|
||||||
|
created = _upsert_day_aggregate(conn, day_data)
|
||||||
|
if created:
|
||||||
|
result.days_created += 1
|
||||||
|
else:
|
||||||
|
result.days_updated += 1
|
||||||
|
|
||||||
|
# 3. Count boundary anchors retained
|
||||||
|
now = clock if isinstance(clock, datetime) else datetime.now(timezone.utc)
|
||||||
|
cursor = conn.execute("SELECT ts FROM samples ORDER BY ts")
|
||||||
|
anchor_count = 0
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
if needs_boundary_anchor(conn, row[0], now):
|
||||||
|
anchor_count += 1
|
||||||
|
result.boundary_anchors_retained = anchor_count
|
||||||
|
|
||||||
|
# 4. Count preserved legacy summaries
|
||||||
|
# Legacy summaries are day aggregates without corresponding hour observations
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT COUNT(*) FROM day_aggregates d
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM hour_observations h
|
||||||
|
WHERE h.hour LIKE d.day || 'T%'
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
result.legacy_summaries_preserved = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Commit transaction
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Update repair status
|
||||||
|
_update_repair_status(conn, result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error("Repair failed: %s", e)
|
||||||
|
return RepairResult(
|
||||||
|
ok=False,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Mark repair as complete
|
||||||
|
_set_repair_in_progress(conn, False)
|
||||||
|
|
||||||
|
return result
|
||||||
+113
-6
@@ -331,7 +331,8 @@ def check_retired_flag(flag: str) -> Optional[str]:
|
|||||||
def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||||
drive_facts: List[str], config_error: Optional[str],
|
drive_facts: List[str], config_error: Optional[str],
|
||||||
store_fault: Optional[str], newer_schema: Optional[str],
|
store_fault: Optional[str], newer_schema: Optional[str],
|
||||||
journal_hint: Optional[str]) -> str:
|
journal_hint: Optional[str],
|
||||||
|
sample_count: int = 0, day_count: int = 0) -> str:
|
||||||
"""Format the complete status output."""
|
"""Format the complete status output."""
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
@@ -361,6 +362,16 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
|||||||
_append_service_facts(lines, service)
|
_append_service_facts(lines, service)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# --- Single sample: awaiting another sample (issue #73 AC3) ---
|
||||||
|
# Only show awaiting state when there are no day aggregates (e.g., legacy import
|
||||||
|
# or hand-crafted stores can have 1 sample but sufficient day data for projection)
|
||||||
|
if sample_count <= 1 and day_count == 0:
|
||||||
|
lines.append("awaiting another sample")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Collecting usage data — the first projection requires at least two samples.")
|
||||||
|
_append_service_facts(lines, service)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
# --- Projection headline ---
|
# --- Projection headline ---
|
||||||
headline = _format_headline(proj)
|
headline = _format_headline(proj)
|
||||||
lines.append(headline)
|
lines.append(headline)
|
||||||
@@ -368,19 +379,28 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
|||||||
|
|
||||||
# --- Confidence state + contributing facts (§6.7, §6.11) ---
|
# --- Confidence state + contributing facts (§6.7, §6.11) ---
|
||||||
state_label = proj.confidence_state.value
|
state_label = proj.confidence_state.value
|
||||||
if proj.contributing_facts:
|
facts_list = list(proj.contributing_facts) if proj.contributing_facts else []
|
||||||
facts_str = " · ".join(proj.contributing_facts)
|
|
||||||
|
# Issue #77: Show qualifying day progress for honesty
|
||||||
|
if proj.qualifying_days_progress:
|
||||||
|
facts_list.insert(0, proj.qualifying_days_progress)
|
||||||
|
|
||||||
|
if facts_list:
|
||||||
|
facts_str = " · ".join(facts_list)
|
||||||
lines.append("%s evidence · %s" % (state_label, facts_str))
|
lines.append("%s evidence · %s" % (state_label, facts_str))
|
||||||
else:
|
else:
|
||||||
lines.append("%s evidence" % state_label)
|
lines.append("%s evidence" % state_label)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# --- Scenario range (§6.5) ---
|
# --- Scenario range (§6.5) with horizon reasons ---
|
||||||
if proj.scenario_range and proj.scenario_range.rates:
|
if proj.scenario_range:
|
||||||
parts = []
|
parts = []
|
||||||
for horizon in sorted(proj.scenario_range.rates.keys()):
|
for horizon in sorted(proj.scenario_range.rates.keys()):
|
||||||
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
|
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
|
||||||
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
|
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
|
||||||
|
for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
|
||||||
|
parts.append("%dd: %s" % (horizon, reason))
|
||||||
|
if parts:
|
||||||
lines.append("scenario range: %s" % " · ".join(parts))
|
lines.append("scenario range: %s" % " · ".join(parts))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -598,6 +618,17 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
|||||||
|
|
||||||
freshness = grade_freshness(newest_ts, clock_now)
|
freshness = grade_freshness(newest_ts, clock_now)
|
||||||
|
|
||||||
|
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||||
|
sample_count = 0
|
||||||
|
day_count = 0
|
||||||
|
try:
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
|
sample_count = cursor.fetchone()[0]
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||||
|
day_count = cursor.fetchone()[0]
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
# Freshness age for the service fact
|
# Freshness age for the service fact
|
||||||
freshness_age_s = None
|
freshness_age_s = None
|
||||||
if newest_ts:
|
if newest_ts:
|
||||||
@@ -637,7 +668,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
|||||||
# --- Compose output ---
|
# --- Compose output ---
|
||||||
result = _format_projection(
|
result = _format_projection(
|
||||||
proj, freshness, service, drive_facts, config_error,
|
proj, freshness, service, drive_facts, config_error,
|
||||||
None, None, journal_hint,
|
None, None, journal_hint, sample_count, day_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -656,3 +687,79 @@ def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetim
|
|||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(format_disclosures())
|
parts.append(format_disclosures())
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared status composition integration (issue #78)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_status_composition(
|
||||||
|
store_path: Optional[Path] = None,
|
||||||
|
clock_now: Optional[datetime] = None,
|
||||||
|
query_services: bool = True,
|
||||||
|
collecting: bool = False,
|
||||||
|
reduced_motion: bool = False,
|
||||||
|
) -> 'StatusComposition':
|
||||||
|
"""Get the shared status composition consumed by both TUI and CLI.
|
||||||
|
|
||||||
|
This is the new entry point that centralizes the status lattice.
|
||||||
|
"""
|
||||||
|
# Lazy import to avoid circular dependency
|
||||||
|
from .status_composition import (
|
||||||
|
StatusComposition,
|
||||||
|
compose_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
if clock_now is None:
|
||||||
|
clock_now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# --- Configuration (§8.3) ---
|
||||||
|
# Config errors are surfaced through the store fault mechanism
|
||||||
|
|
||||||
|
# --- Service state ---
|
||||||
|
service = {}
|
||||||
|
if query_services:
|
||||||
|
try:
|
||||||
|
service = query_service_state()
|
||||||
|
except Exception:
|
||||||
|
service = None
|
||||||
|
|
||||||
|
# --- Store open ---
|
||||||
|
store_fault = None
|
||||||
|
newer_schema = None
|
||||||
|
conn = None
|
||||||
|
|
||||||
|
if store_path is None:
|
||||||
|
store_path = Path("/var/lib/fenris/observations.db")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = open_store_readonly(store_path)
|
||||||
|
except StoreFault as e:
|
||||||
|
store_fault = str(e)
|
||||||
|
except NewerSchema as e:
|
||||||
|
newer_schema = str(e)
|
||||||
|
|
||||||
|
# --- Use shared composition ---
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
comp = compose_status(
|
||||||
|
conn, service, clock_now,
|
||||||
|
store_fault=store_fault,
|
||||||
|
newer_schema=newer_schema,
|
||||||
|
collecting=collecting,
|
||||||
|
reduced_motion=reduced_motion,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
else:
|
||||||
|
# Store fault or newer schema - compose without store data
|
||||||
|
comp = compose_status(
|
||||||
|
None, service, clock_now,
|
||||||
|
store_fault=store_fault,
|
||||||
|
newer_schema=newer_schema,
|
||||||
|
collecting=collecting,
|
||||||
|
reduced_motion=reduced_motion,
|
||||||
|
)
|
||||||
|
|
||||||
|
return comp
|
||||||
|
|||||||
@@ -0,0 +1,526 @@
|
|||||||
|
"""Shared status composition for TUI and CLI (issue #78, TPH-2/3).
|
||||||
|
|
||||||
|
Centralizes the monitoring status lattice consumed by both surfaces.
|
||||||
|
States: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, Unknown.
|
||||||
|
|
||||||
|
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
|
||||||
|
Collecting overlays every base except store fault.
|
||||||
|
|
||||||
|
Freshness grading uses shared constants from status.py.
|
||||||
|
"""
|
||||||
|
import enum
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
# Status poll interval (AC78-6): lightweight 5s systemctl show poll
|
||||||
|
STATUS_POLL_INTERVAL_S = 5
|
||||||
|
|
||||||
|
from .status import (
|
||||||
|
FRESH_THRESHOLD_S,
|
||||||
|
STALENESS_THRESHOLD_S,
|
||||||
|
grade_freshness,
|
||||||
|
freshness_age_human,
|
||||||
|
is_deliberately_paused,
|
||||||
|
monitoring_continuity,
|
||||||
|
deliberate_pause_lines,
|
||||||
|
open_store_readonly,
|
||||||
|
StoreFault,
|
||||||
|
NewerSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status state enum with glyph and label
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class StatusState(enum.Enum):
|
||||||
|
"""The eight monitoring status states (TPH-2)."""
|
||||||
|
|
||||||
|
MONITORING = "monitoring"
|
||||||
|
COLLECTING = "collecting"
|
||||||
|
PAUSED = "paused"
|
||||||
|
WAITING = "waiting"
|
||||||
|
INTERRUPTED = "interrupted"
|
||||||
|
ERROR = "error"
|
||||||
|
STALE = "stale"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def glyph(self) -> str:
|
||||||
|
_glyphs = {
|
||||||
|
"monitoring": "●",
|
||||||
|
"collecting": "◐",
|
||||||
|
"paused": "‖",
|
||||||
|
"waiting": "○",
|
||||||
|
"interrupted": "⊘",
|
||||||
|
"error": "✖",
|
||||||
|
"stale": "◌",
|
||||||
|
"unknown": "?",
|
||||||
|
}
|
||||||
|
return _glyphs[self.value]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
return self.value.capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
# Precedence order: higher index = higher precedence
|
||||||
|
_PRECEDENCE = [
|
||||||
|
StatusState.UNKNOWN,
|
||||||
|
StatusState.MONITORING,
|
||||||
|
StatusState.WAITING,
|
||||||
|
StatusState.STALE,
|
||||||
|
StatusState.PAUSED,
|
||||||
|
StatusState.INTERRUPTED,
|
||||||
|
StatusState.ERROR,
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRECEDENCE_RANK = {s: i for i, s in enumerate(_PRECEDENCE)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status composition result
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatusComposition:
|
||||||
|
"""The composed status result shared between TUI and CLI."""
|
||||||
|
|
||||||
|
state: StatusState
|
||||||
|
glyph: str
|
||||||
|
label: str
|
||||||
|
explanation: str
|
||||||
|
|
||||||
|
# Separate facts (never folded into the status word)
|
||||||
|
freshness: str = "unknown"
|
||||||
|
freshness_age_s: Optional[int] = None
|
||||||
|
last_collect_ok: Optional[bool] = None
|
||||||
|
last_collect_age_s: Optional[int] = None
|
||||||
|
last_collect_reason: Optional[str] = None
|
||||||
|
boot_enabled: Optional[bool] = None
|
||||||
|
timer_active: Optional[bool] = None
|
||||||
|
deliberately_paused: bool = False
|
||||||
|
pause_age_s: Optional[int] = None
|
||||||
|
external_stop_reason: Optional[str] = None
|
||||||
|
|
||||||
|
# Store fault / newer schema (suppress store-dependent views)
|
||||||
|
store_fault: Optional[str] = None
|
||||||
|
newer_schema: Optional[str] = None
|
||||||
|
|
||||||
|
# Collecting overlay
|
||||||
|
overlay_base: Optional[StatusState] = None
|
||||||
|
|
||||||
|
# Sample counts for waiting explanations
|
||||||
|
sample_count: int = 0
|
||||||
|
day_count: int = 0
|
||||||
|
|
||||||
|
# Whether the status dot should blink (only Monitoring)
|
||||||
|
should_blink: bool = False
|
||||||
|
|
||||||
|
# Continuity line
|
||||||
|
continuity: str = ""
|
||||||
|
|
||||||
|
# Paused lines (for TUI banner)
|
||||||
|
paused_lines: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status composition logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _determine_base_state(
|
||||||
|
freshness: str,
|
||||||
|
sample_count: int,
|
||||||
|
day_count: int,
|
||||||
|
boot_enabled: Optional[bool],
|
||||||
|
timer_active: Optional[bool],
|
||||||
|
last_collect_ok: Optional[bool],
|
||||||
|
deliberately_paused: bool,
|
||||||
|
external_stop_reason: Optional[str],
|
||||||
|
service_available: bool,
|
||||||
|
) -> StatusState:
|
||||||
|
"""Determine the base status state from facts.
|
||||||
|
|
||||||
|
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
|
||||||
|
"""
|
||||||
|
# Unknown: service query failed and no store-derived fact places us higher
|
||||||
|
if not service_available:
|
||||||
|
return StatusState.UNKNOWN
|
||||||
|
|
||||||
|
# Error: last collect failed
|
||||||
|
if last_collect_ok is False:
|
||||||
|
return StatusState.ERROR
|
||||||
|
|
||||||
|
# Interrupted: external stop (not user_disabled)
|
||||||
|
if external_stop_reason is not None:
|
||||||
|
return StatusState.INTERRUPTED
|
||||||
|
|
||||||
|
# Paused: deliberate disable
|
||||||
|
if deliberately_paused:
|
||||||
|
return StatusState.PAUSED
|
||||||
|
|
||||||
|
# Stale: timer active, no failure, but data ≥ 48h old
|
||||||
|
if freshness == "stale" and timer_active and last_collect_ok is not False:
|
||||||
|
return StatusState.STALE
|
||||||
|
|
||||||
|
# Waiting: empty store, single sample, or fresh data but not yet enough evidence
|
||||||
|
if sample_count == 0:
|
||||||
|
return StatusState.WAITING
|
||||||
|
if sample_count <= 1 and day_count == 0:
|
||||||
|
return StatusState.WAITING
|
||||||
|
|
||||||
|
# Monitoring: everything is fine
|
||||||
|
return StatusState.MONITORING
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_explanation(
|
||||||
|
state: StatusState,
|
||||||
|
freshness: str,
|
||||||
|
freshness_age_s: Optional[int],
|
||||||
|
last_collect_ok: Optional[bool],
|
||||||
|
last_collect_reason: Optional[str],
|
||||||
|
sample_count: int,
|
||||||
|
deliberately_paused: bool,
|
||||||
|
store_fault: Optional[str],
|
||||||
|
newer_schema: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""Determine the explanation line for the status state."""
|
||||||
|
if store_fault:
|
||||||
|
return "observation store unreadable — see journal"
|
||||||
|
|
||||||
|
if newer_schema:
|
||||||
|
return "observation store written by a newer Fenris — upgrade Fenris"
|
||||||
|
|
||||||
|
if state == StatusState.ERROR:
|
||||||
|
parts = []
|
||||||
|
if last_collect_ok is False:
|
||||||
|
parts.append("last run failed")
|
||||||
|
if last_collect_reason:
|
||||||
|
parts.append("(%s)" % last_collect_reason)
|
||||||
|
if freshness_age_s is not None and freshness != "empty":
|
||||||
|
parts.append("· last good sample %s ago" % freshness_age_human(freshness_age_s))
|
||||||
|
return " ".join(parts) if parts else "last run failed"
|
||||||
|
|
||||||
|
if state == StatusState.INTERRUPTED:
|
||||||
|
return "collection stopped outside Fenris — monitoring period still open"
|
||||||
|
|
||||||
|
if state == StatusState.PAUSED:
|
||||||
|
return "monitoring paused — paused time excluded from your usage habit"
|
||||||
|
|
||||||
|
if state == StatusState.STALE:
|
||||||
|
if freshness_age_s is not None:
|
||||||
|
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||||
|
return "data is stale"
|
||||||
|
|
||||||
|
if state == StatusState.WAITING:
|
||||||
|
if sample_count == 0:
|
||||||
|
return "awaiting first sample"
|
||||||
|
if sample_count <= 1:
|
||||||
|
return "awaiting another sample"
|
||||||
|
return "waiting for data"
|
||||||
|
|
||||||
|
if state == StatusState.MONITORING:
|
||||||
|
if freshness_age_s is not None:
|
||||||
|
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||||
|
return "monitoring active"
|
||||||
|
|
||||||
|
if state == StatusState.UNKNOWN:
|
||||||
|
return "service state unavailable"
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_collecting_overlay(
|
||||||
|
base_state: StatusState,
|
||||||
|
last_collect_ok: Optional[bool],
|
||||||
|
deliberately_paused: bool,
|
||||||
|
store_fault: Optional[str],
|
||||||
|
newer_schema: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""Determine the explanation line when Collecting overlays a base state."""
|
||||||
|
if store_fault or newer_schema:
|
||||||
|
return "run in flight — store fault"
|
||||||
|
|
||||||
|
if base_state == StatusState.PAUSED:
|
||||||
|
return "run in flight — paused"
|
||||||
|
if base_state == StatusState.INTERRUPTED:
|
||||||
|
return "run in flight — interrupted"
|
||||||
|
if base_state == StatusState.ERROR:
|
||||||
|
return "run in flight — retry"
|
||||||
|
if base_state == StatusState.STALE:
|
||||||
|
return "run in flight — stale data"
|
||||||
|
if base_state == StatusState.WAITING:
|
||||||
|
return "run in flight"
|
||||||
|
return "run in flight"
|
||||||
|
|
||||||
|
|
||||||
|
def compose_status(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
service: Optional[Dict[str, Any]],
|
||||||
|
clock_now: datetime,
|
||||||
|
store_fault: Optional[str] = None,
|
||||||
|
newer_schema: Optional[str] = None,
|
||||||
|
collecting: bool = False,
|
||||||
|
reduced_motion: bool = False,
|
||||||
|
) -> StatusComposition:
|
||||||
|
"""Compose the shared status from service state and store data.
|
||||||
|
|
||||||
|
This is the single entry point consumed by both TUI and CLI.
|
||||||
|
"""
|
||||||
|
service_available = service is not None and len(service) > 0
|
||||||
|
|
||||||
|
# --- Separate facts from service ---
|
||||||
|
boot_enabled = service.get("boot_enabled") if service_available else None
|
||||||
|
timer_active = service.get("timer_active") if service_available else None
|
||||||
|
last_collect_ok = service.get("last_collect_ok") if service_available else None
|
||||||
|
last_collect_age_s = service.get("last_collect_age_s") if service_available else None
|
||||||
|
last_collect_reason = service.get("last_collect_reason") if service_available else None
|
||||||
|
|
||||||
|
# --- Freshness from store ---
|
||||||
|
freshness = "unknown"
|
||||||
|
freshness_age_s = None
|
||||||
|
sample_count = 0
|
||||||
|
day_count = 0
|
||||||
|
deliberately_paused = False
|
||||||
|
|
||||||
|
if store_fault is None and newer_schema is None:
|
||||||
|
try:
|
||||||
|
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
newest_ts = row[0] if row else None
|
||||||
|
freshness = grade_freshness(newest_ts, clock_now)
|
||||||
|
if newest_ts:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(newest_ts)
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
ts = ts.astimezone(timezone.utc)
|
||||||
|
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
except sqlite3.Error:
|
||||||
|
freshness = "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
|
sample_count = cursor.fetchone()[0]
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||||
|
day_count = cursor.fetchone()[0]
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
svc_for_pause = service if service_available else {}
|
||||||
|
deliberately_paused = is_deliberately_paused(conn, svc_for_pause)
|
||||||
|
except sqlite3.Error:
|
||||||
|
deliberately_paused = False
|
||||||
|
|
||||||
|
# --- External stop detection ---
|
||||||
|
# External stop = timer inactive + boot disabled + NOT deliberately paused
|
||||||
|
# + period still open (the timer was stopped but Fenris didn't close the period)
|
||||||
|
external_stop_reason = None
|
||||||
|
if not deliberately_paused and timer_active is False and boot_enabled is False:
|
||||||
|
# Check if there's an open monitoring period (external stop left it open)
|
||||||
|
try:
|
||||||
|
open_period = conn.execute(
|
||||||
|
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if open_period is not None:
|
||||||
|
external_stop_reason = "external_stop"
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Determine base state ---
|
||||||
|
# Store fault and newer schema always override to ERROR
|
||||||
|
if store_fault is not None or newer_schema is not None:
|
||||||
|
base_state = StatusState.ERROR
|
||||||
|
else:
|
||||||
|
base_state = _determine_base_state(
|
||||||
|
freshness=freshness,
|
||||||
|
sample_count=sample_count,
|
||||||
|
day_count=day_count,
|
||||||
|
boot_enabled=boot_enabled,
|
||||||
|
timer_active=timer_active,
|
||||||
|
last_collect_ok=last_collect_ok,
|
||||||
|
deliberately_paused=deliberately_paused,
|
||||||
|
external_stop_reason=external_stop_reason,
|
||||||
|
service_available=service_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Apply Collecting overlay ---
|
||||||
|
state = base_state
|
||||||
|
overlay_base = None
|
||||||
|
explanation = ""
|
||||||
|
|
||||||
|
if collecting and store_fault is None and newer_schema is None:
|
||||||
|
# Collecting overlays every base except store fault
|
||||||
|
overlay_base = base_state
|
||||||
|
state = StatusState.COLLECTING
|
||||||
|
explanation = _determine_collecting_overlay(
|
||||||
|
base_state, last_collect_ok, deliberately_paused,
|
||||||
|
store_fault, newer_schema,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
explanation = _determine_explanation(
|
||||||
|
base_state, freshness, freshness_age_s,
|
||||||
|
last_collect_ok, last_collect_reason,
|
||||||
|
sample_count, deliberately_paused,
|
||||||
|
store_fault, newer_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Continuity line ---
|
||||||
|
continuity = ""
|
||||||
|
if service_available:
|
||||||
|
continuity = monitoring_continuity(service)
|
||||||
|
|
||||||
|
# --- Paused lines ---
|
||||||
|
paused_lines = []
|
||||||
|
if deliberately_paused:
|
||||||
|
paused_lines = deliberate_pause_lines()
|
||||||
|
|
||||||
|
# Determine blink flag: only Monitoring dot blinks, never text or other states
|
||||||
|
should_blink = (state == StatusState.MONITORING and not reduced_motion)
|
||||||
|
|
||||||
|
return StatusComposition(
|
||||||
|
state=state,
|
||||||
|
glyph=state.glyph,
|
||||||
|
label=state.label,
|
||||||
|
explanation=explanation,
|
||||||
|
should_blink=should_blink,
|
||||||
|
freshness=freshness,
|
||||||
|
freshness_age_s=freshness_age_s,
|
||||||
|
last_collect_ok=last_collect_ok,
|
||||||
|
last_collect_age_s=last_collect_age_s,
|
||||||
|
last_collect_reason=last_collect_reason,
|
||||||
|
boot_enabled=boot_enabled,
|
||||||
|
timer_active=timer_active,
|
||||||
|
deliberately_paused=deliberately_paused,
|
||||||
|
external_stop_reason=external_stop_reason,
|
||||||
|
store_fault=store_fault,
|
||||||
|
newer_schema=newer_schema,
|
||||||
|
overlay_base=overlay_base,
|
||||||
|
sample_count=sample_count,
|
||||||
|
day_count=day_count,
|
||||||
|
continuity=continuity,
|
||||||
|
paused_lines=paused_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI rendering (static, no styling)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_status_cli(comp: StatusComposition) -> str:
|
||||||
|
"""Render the status composition as static CLI text."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Status line
|
||||||
|
lines.append("%s %s" % (comp.glyph, comp.label))
|
||||||
|
|
||||||
|
# Explanation
|
||||||
|
if comp.explanation:
|
||||||
|
lines.append(comp.explanation)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Separate facts
|
||||||
|
facts = []
|
||||||
|
if comp.freshness != "unknown":
|
||||||
|
facts.append("freshness: %s" % comp.freshness)
|
||||||
|
if comp.freshness_age_s is not None:
|
||||||
|
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s) if facts else "freshness: %s" % freshness_age_human(comp.freshness_age_s)
|
||||||
|
if comp.last_collect_ok is True:
|
||||||
|
facts.append("last collect: ok")
|
||||||
|
elif comp.last_collect_ok is False:
|
||||||
|
collect_str = "last collect: FAILED"
|
||||||
|
if comp.last_collect_reason:
|
||||||
|
collect_str += " (%s)" % comp.last_collect_reason
|
||||||
|
facts.append(collect_str)
|
||||||
|
else:
|
||||||
|
facts.append("last collect: unknown")
|
||||||
|
if comp.boot_enabled is not None:
|
||||||
|
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||||
|
if comp.timer_active is not None:
|
||||||
|
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||||
|
|
||||||
|
if facts:
|
||||||
|
lines.append(" · ".join(facts))
|
||||||
|
|
||||||
|
# Continuity
|
||||||
|
if comp.continuity:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("CONTINUITY: %s" % comp.continuity)
|
||||||
|
|
||||||
|
# Deliberate pause
|
||||||
|
if comp.paused_lines:
|
||||||
|
for pl in comp.paused_lines:
|
||||||
|
lines.append(pl)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TUI rendering (with styling tokens)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_status_tui(comp: StatusComposition) -> str:
|
||||||
|
"""Render the status composition as TUI text with Textual markup."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Status line with color
|
||||||
|
color = {
|
||||||
|
StatusState.MONITORING: "green",
|
||||||
|
StatusState.COLLECTING: "green",
|
||||||
|
StatusState.PAUSED: "yellow",
|
||||||
|
StatusState.WAITING: "yellow",
|
||||||
|
StatusState.INTERRUPTED: "red",
|
||||||
|
StatusState.ERROR: "red",
|
||||||
|
StatusState.STALE: "red",
|
||||||
|
StatusState.UNKNOWN: "dim",
|
||||||
|
}[comp.state]
|
||||||
|
|
||||||
|
lines.append("[%s]%s %s[/%s]" % (color, comp.glyph, comp.label, color))
|
||||||
|
|
||||||
|
# Explanation
|
||||||
|
if comp.explanation:
|
||||||
|
lines.append(comp.explanation)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Separate facts
|
||||||
|
facts = []
|
||||||
|
if comp.freshness != "unknown":
|
||||||
|
facts.append("freshness: %s" % comp.freshness)
|
||||||
|
if comp.freshness_age_s is not None and facts:
|
||||||
|
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||||
|
if comp.last_collect_ok is True:
|
||||||
|
facts.append("last collect: ok")
|
||||||
|
elif comp.last_collect_ok is False:
|
||||||
|
collect_str = "last collect: FAILED"
|
||||||
|
if comp.last_collect_reason:
|
||||||
|
collect_str += " (%s)" % comp.last_collect_reason
|
||||||
|
facts.append(collect_str)
|
||||||
|
else:
|
||||||
|
facts.append("last collect: unknown")
|
||||||
|
if comp.boot_enabled is not None:
|
||||||
|
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||||
|
if comp.timer_active is not None:
|
||||||
|
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||||
|
|
||||||
|
if facts:
|
||||||
|
lines.append(" · ".join(facts))
|
||||||
|
|
||||||
|
# Continuity
|
||||||
|
if comp.continuity:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("[bold]CONTINUITY[/bold] %s" % comp.continuity)
|
||||||
|
|
||||||
|
# Deliberate pause
|
||||||
|
if comp.paused_lines:
|
||||||
|
for pl in comp.paused_lines:
|
||||||
|
lines.append(pl)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
+33
-8
@@ -12,7 +12,7 @@ from typing import Optional
|
|||||||
|
|
||||||
|
|
||||||
# Schema version - increment on each migration
|
# Schema version - increment on each migration
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
# Packaged default placement (spec §8.3). The config may override it, but a
|
# Packaged default placement (spec §8.3). The config may override it, but a
|
||||||
@@ -106,7 +106,8 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
data_units_read INTEGER,
|
data_units_read INTEGER,
|
||||||
bytes_written INTEGER,
|
bytes_written INTEGER,
|
||||||
bytes_read INTEGER,
|
bytes_read INTEGER,
|
||||||
critical_warning INTEGER
|
critical_warning INTEGER,
|
||||||
|
segment_id INTEGER
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -141,7 +142,9 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
bytes_written_delta INTEGER DEFAULT 0,
|
bytes_written_delta INTEGER DEFAULT 0,
|
||||||
bytes_read_delta INTEGER DEFAULT 0,
|
bytes_read_delta INTEGER DEFAULT 0,
|
||||||
sample_count INTEGER DEFAULT 0,
|
sample_count INTEGER DEFAULT 0,
|
||||||
coverage REAL DEFAULT 0.0
|
coverage REAL DEFAULT 0.0,
|
||||||
|
unattributed_bytes_written INTEGER DEFAULT 0,
|
||||||
|
unattributed_bytes_read INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -207,11 +210,25 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
|||||||
|
|
||||||
Spec: §3.6, §10.2
|
Spec: §3.6, §10.2
|
||||||
"""
|
"""
|
||||||
# Migration 1→2: example placeholder
|
# Migration 1→2: add segment_id provenance to samples,
|
||||||
# if current_version < 2:
|
# unattributed byte tracking to day_aggregates (issue #73)
|
||||||
# conn.execute("ALTER TABLE ...")
|
if current_version < 2:
|
||||||
# current_version = 2
|
# Defensive: only ALTER if table exists (handles minimal v1 stores)
|
||||||
pass
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
if "samples" in tables:
|
||||||
|
# Check if column already exists (idempotent)
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
|
||||||
|
if "segment_id" not in cols:
|
||||||
|
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
|
||||||
|
if "day_aggregates" in tables:
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()}
|
||||||
|
if "unattributed_bytes_written" not in cols:
|
||||||
|
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0")
|
||||||
|
if "unattributed_bytes_read" not in cols:
|
||||||
|
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
|
||||||
|
current_version = 2
|
||||||
|
|
||||||
|
|
||||||
def migrate_to_latest(store_path: Path) -> int:
|
def migrate_to_latest(store_path: Path) -> int:
|
||||||
@@ -239,6 +256,14 @@ def migrate_to_latest(store_path: Path) -> int:
|
|||||||
conn.close()
|
conn.close()
|
||||||
return 0 # Already up to date
|
return 0 # Already up to date
|
||||||
|
|
||||||
|
# Version 0 means no schema — create fresh (issue #73)
|
||||||
|
if current_version == 0:
|
||||||
|
_create_schema(conn)
|
||||||
|
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return SCHEMA_VERSION
|
||||||
|
|
||||||
steps = SCHEMA_VERSION - current_version
|
steps = SCHEMA_VERSION - current_version
|
||||||
_apply_migrations(conn, current_version)
|
_apply_migrations(conn, current_version)
|
||||||
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Fenris theme presets (issue #80).
|
||||||
|
|
||||||
|
Three accessible colour presets: Amber (default), Nord, and High Contrast.
|
||||||
|
Themes style chrome, borders, accents, muted text, and graph roles;
|
||||||
|
status semantic colours/glyphs/text always win.
|
||||||
|
|
||||||
|
Theme roles for graph rendering expose distinct colours per preset so
|
||||||
|
the bar graph can reflect the user's visual preference without depending
|
||||||
|
on graph-ticket completion.
|
||||||
|
|
||||||
|
Criteria: TPH-10, AC80-1, AC80-5.
|
||||||
|
"""
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from textual.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status semantic colours — always win, never themed (AC80-1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STATUS_COLORS = {
|
||||||
|
"monitoring": "green",
|
||||||
|
"collecting": "green",
|
||||||
|
"paused": "yellow",
|
||||||
|
"waiting": "yellow",
|
||||||
|
"interrupted": "red",
|
||||||
|
"error": "red",
|
||||||
|
"stale": "red",
|
||||||
|
"unknown": "dim",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Amber theme — warm golden tones (default, amber graph role)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_AMBER = Theme(
|
||||||
|
name="fenris-amber",
|
||||||
|
primary="#d4a017", # warm amber
|
||||||
|
secondary="#c49b0a", # darker amber
|
||||||
|
accent="#ffd54f", # light amber highlight
|
||||||
|
warning="#e6a817", # amber warning
|
||||||
|
error="#e74c3c", # red error
|
||||||
|
success="#27ae60", # green success
|
||||||
|
foreground="#e8e0d0", # warm light
|
||||||
|
background="#1a1510", # warm dark
|
||||||
|
surface="#241f16", # warm surface
|
||||||
|
panel="#2a2318", # warm panel
|
||||||
|
boost="#332a1c", # warm boost
|
||||||
|
dark=True,
|
||||||
|
variables={
|
||||||
|
"graph-allocated": "#d4a017",
|
||||||
|
"graph-unallocated": "#8b6914",
|
||||||
|
"graph-gap": "#554422",
|
||||||
|
"graph-zero": "#665533",
|
||||||
|
"graph-partial": "#aa8822",
|
||||||
|
"graph-selection": "#ffd54f",
|
||||||
|
"border-default": "#554422",
|
||||||
|
"muted-text": "#887755",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nord theme — cool blue-gray polar night palette
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_NORD = Theme(
|
||||||
|
name="fenris-nord",
|
||||||
|
primary="#88c0d0", # nord8 frost
|
||||||
|
secondary="#81a1c1", # nord9
|
||||||
|
accent="#8fbcbb", # nord7
|
||||||
|
warning="#ebcb8b", # nord13
|
||||||
|
error="#bf616a", # nord11
|
||||||
|
success="#a3be8c", # nord14
|
||||||
|
foreground="#eceff4", # nord6
|
||||||
|
background="#2e3440", # nord0
|
||||||
|
surface="#3b4252", # nord1
|
||||||
|
panel="#434c5e", # nord2
|
||||||
|
boost="#4c566a", # nord3
|
||||||
|
dark=True,
|
||||||
|
variables={
|
||||||
|
"graph-allocated": "#88c0d0",
|
||||||
|
"graph-unallocated": "#5e81ac",
|
||||||
|
"graph-gap": "#4c566a",
|
||||||
|
"graph-zero": "#616e88",
|
||||||
|
"graph-partial": "#81a1c1",
|
||||||
|
"graph-selection": "#8fbcbb",
|
||||||
|
"border-default": "#4c566a",
|
||||||
|
"muted-text": "#7b88a1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# High Contrast — maximum readability, pure black and white
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_HIGH_CONTRAST = Theme(
|
||||||
|
name="fenris-high-contrast",
|
||||||
|
primary="#ffffff", # pure white
|
||||||
|
secondary="#dddddd", # light gray
|
||||||
|
accent="#ffff00", # bright yellow
|
||||||
|
warning="#ff8800", # bright orange
|
||||||
|
error="#ff0000", # pure red
|
||||||
|
success="#00ff00", # pure green
|
||||||
|
foreground="#ffffff", # pure white
|
||||||
|
background="#000000", # pure black
|
||||||
|
surface="#111111", # near-black surface
|
||||||
|
panel="#1a1a1a", # near-black panel
|
||||||
|
boost="#222222", # near-black boost
|
||||||
|
dark=True,
|
||||||
|
variables={
|
||||||
|
"graph-allocated": "#ffffff",
|
||||||
|
"graph-unallocated": "#aaaaaa",
|
||||||
|
"graph-gap": "#555555",
|
||||||
|
"graph-zero": "#666666",
|
||||||
|
"graph-partial": "#cccccc",
|
||||||
|
"graph-selection": "#ffff00",
|
||||||
|
"border-default": "#ffffff",
|
||||||
|
"muted-text": "#aaaaaa",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Theme registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
THEMES = {
|
||||||
|
"amber": _AMBER,
|
||||||
|
"nord": _NORD,
|
||||||
|
"high_contrast": _HIGH_CONTRAST,
|
||||||
|
}
|
||||||
|
|
||||||
|
THEME_NAMES = set(THEMES.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(name: str) -> Theme:
|
||||||
|
"""Return a registered theme by preset name.
|
||||||
|
|
||||||
|
Unknown names fall back to Amber.
|
||||||
|
"""
|
||||||
|
return THEMES.get(name, _AMBER)
|
||||||
|
|
||||||
|
|
||||||
|
def get_graph_colors(theme_name: str) -> Dict[str, str]:
|
||||||
|
"""Return the graph colour roles for a theme preset.
|
||||||
|
|
||||||
|
Returns a dict with keys: allocated, unallocated, gap, zero, partial,
|
||||||
|
selection. Falls back to Amber for unknown names.
|
||||||
|
"""
|
||||||
|
theme = get_theme(theme_name)
|
||||||
|
variables = theme.variables or {}
|
||||||
|
return {
|
||||||
|
"allocated": variables.get("graph-allocated", "#d4a017"),
|
||||||
|
"unallocated": variables.get("graph-unallocated", "#8b6914"),
|
||||||
|
"gap": variables.get("graph-gap", "#554422"),
|
||||||
|
"zero": variables.get("graph-zero", "#665533"),
|
||||||
|
"partial": variables.get("graph-partial", "#aa8822"),
|
||||||
|
"selection": variables.get("graph-selection", "#ffd54f"),
|
||||||
|
}
|
||||||
+878
-47
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,712 @@
|
|||||||
|
"""Collector history tracer tests (issue #73).
|
||||||
|
|
||||||
|
Tests the end-to-end history pipeline: sample acquisition → interval
|
||||||
|
derivation → hour observation → day aggregate, with concurrent-read
|
||||||
|
safety, cross-hour handling, and display states.
|
||||||
|
|
||||||
|
Seams:
|
||||||
|
- write side: run_collection() → observation store
|
||||||
|
- read side: get_status(), compute_projection() → observation store
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.collector import run_collection, normalize_identity
|
||||||
|
from fenris.store import init_store, get_store_path, SCHEMA_VERSION
|
||||||
|
from fenris.monitoring_periods import ensure_period_open, close_period, get_open_period
|
||||||
|
from fenris.day_aggregate import derive_day, derive_all_days
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def smartctl_fixture() -> Dict[str, Any]:
|
||||||
|
"""Minimal smartctl -a -j output with required fields."""
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5,
|
||||||
|
"data_units_written": 12345678,
|
||||||
|
"data_units_read": 9876543,
|
||||||
|
"power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234,
|
||||||
|
"unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0,
|
||||||
|
"num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_fixture_tree(tmp_path: Path) -> Path:
|
||||||
|
"""Create a minimal sysfs fixture tree with controller identity."""
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text("nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n")
|
||||||
|
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||||
|
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "address").write_text("0000:03:00.0")
|
||||||
|
(transport_dir / "trstring").write_text("pcie")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_fixture(tmp_path: Path) -> Dict[str, Any]:
|
||||||
|
"""Configuration fixture naming the device."""
|
||||||
|
return {
|
||||||
|
"device": "/dev/nvme0",
|
||||||
|
"store_path": str(tmp_path / "observations.db"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
"""Injected clock returning controlled time."""
|
||||||
|
def __init__(self, initial: datetime):
|
||||||
|
self.now = initial
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
def advance(self, **kwargs):
|
||||||
|
self.now = self.now + timedelta(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema migration: existing data readable at real precision
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSchemaMigration:
|
||||||
|
"""Schema migration 1→2 preserves existing data (issue #73 AC1)."""
|
||||||
|
|
||||||
|
def test_migration_bumps_version(self, tmp_path):
|
||||||
|
"""Migration from v1 to v2 succeeds."""
|
||||||
|
from fenris.store import migrate_to_latest, SCHEMA_VERSION
|
||||||
|
|
||||||
|
# Create a v1 store directly (simulating pre-migration state)
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
# Create v1 schema manually
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE samples (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
device TEXT NOT NULL,
|
||||||
|
subnqn TEXT, sn TEXT, mn TEXT, fr TEXT,
|
||||||
|
capacity_bytes INTEGER, percentage_used INTEGER,
|
||||||
|
available_spare INTEGER, media_errors INTEGER,
|
||||||
|
power_on_hours INTEGER, power_cycles INTEGER,
|
||||||
|
unsafe_shutdowns INTEGER, temperature_c INTEGER,
|
||||||
|
data_units_written INTEGER, data_units_read INTEGER,
|
||||||
|
bytes_written INTEGER, bytes_read INTEGER,
|
||||||
|
critical_warning INTEGER
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE hour_observations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hour TEXT NOT NULL UNIQUE,
|
||||||
|
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
|
||||||
|
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
|
||||||
|
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
|
||||||
|
temperature_min INTEGER, temperature_avg REAL, temperature_max INTEGER,
|
||||||
|
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE day_aggregates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
day TEXT NOT NULL UNIQUE,
|
||||||
|
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
|
||||||
|
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
|
||||||
|
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
|
||||||
|
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE TABLE monitoring_periods (id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, ended_at TEXT, end_cause TEXT)")
|
||||||
|
conn.execute("CREATE TABLE controller_segments (id INTEGER PRIMARY KEY AUTOINCREMENT, opened_at TEXT NOT NULL, identity_key TEXT, identity_degraded BOOLEAN DEFAULT 0, subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, vid TEXT, ssvid TEXT, transport TEXT)")
|
||||||
|
conn.execute("CREATE TABLE endurance_baseline (id INTEGER PRIMARY KEY AUTOINCREMENT, tbw_terabytes REAL NOT NULL, source_url TEXT, document_revision TEXT, entry_date TEXT, model_string TEXT, nominal_capacity_bytes INTEGER, validated_by TEXT, verified BOOLEAN DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)")
|
||||||
|
conn.execute("CREATE TABLE store_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||||
|
conn.execute("PRAGMA user_version=1")
|
||||||
|
conn.execute("INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, percentage_used, available_spare, media_errors, power_on_hours, power_cycles, unsafe_shutdowns, temperature_c, data_units_written, data_units_read, bytes_written, bytes_read, critical_warning) VALUES ('2026-09-01T12:00:00+00:00', '/dev/nvme0', 'Test', 'SN', 'FR', 1000000000000, 5, 100, 0, 1000, 100, 0, 35, 1000000, 500000, 512000000000, 256000000000, 0)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Migrate
|
||||||
|
steps = migrate_to_latest(db)
|
||||||
|
assert steps == 1
|
||||||
|
|
||||||
|
# Verify data preserved
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
row = conn.execute("SELECT ts, mn FROM samples").fetchone()
|
||||||
|
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert row[0] == "2026-09-01T12:00:00+00:00"
|
||||||
|
assert row[1] == "Test"
|
||||||
|
assert version == SCHEMA_VERSION
|
||||||
|
|
||||||
|
def test_newer_schema_refused(self, tmp_path):
|
||||||
|
"""Store with user_version > SCHEMA_VERSION is refused."""
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA user_version=%d" % (SCHEMA_VERSION + 1))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="newer Fenris"):
|
||||||
|
init_store(db)
|
||||||
|
|
||||||
|
def test_existing_data_preserved_after_migration(self, config_fixture,
|
||||||
|
smartctl_fixture,
|
||||||
|
sysfs_fixture_tree):
|
||||||
|
"""Existing sample data is not lost or modified by migration."""
|
||||||
|
clock = FakeClock(datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
# Write first sample
|
||||||
|
run_collection(smartctl_fixture, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
row = conn.execute("SELECT ts, device, bytes_written FROM samples").fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert row[0] == "2026-09-01T12:00:00+00:00"
|
||||||
|
assert row[1] == "/dev/nvme0"
|
||||||
|
assert row[2] == 12345678 * 512000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Collector publishes samples, intervals, hour observations, day aggregates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCollectorDerivation:
|
||||||
|
"""Collector derives hour observations and day aggregates (issue #73 AC2)."""
|
||||||
|
|
||||||
|
def _make_sample(self, duw_units: int, ts: str) -> Dict[str, Any]:
|
||||||
|
"""Build a smartctl fixture with specific DUW."""
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5,
|
||||||
|
"data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543,
|
||||||
|
"power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234,
|
||||||
|
"unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0,
|
||||||
|
"num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_same_hour_20mb_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Two samples in same hour with 20 MB delta → hour_obs gets 20 MB."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# DUW units: 12345678 * 512000 = ~6.3 TB; 20 MB = 20*1024*1024 / 512000 ≈ 40 units
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 + 40 # ~20 MB more
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
s1 = self._make_sample(duw1, t1.isoformat())
|
||||||
|
r1 = run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
assert r1["ok"]
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
s2 = self._make_sample(duw2, t2.isoformat())
|
||||||
|
r2 = run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
# Check hour_observation was derived
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, sample_count FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert hour is not None, "Hour observation should exist for 12:00"
|
||||||
|
assert hour[1] >= 2 # at least 2 samples contributed
|
||||||
|
# bytes_written_delta should be the 20 MB delta (40 * 512000 = 20480000)
|
||||||
|
assert hour[0] == 40 * 512000
|
||||||
|
|
||||||
|
def test_zero_delta_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Two samples in same hour with no DUW change → 0 B written."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw = 12345678 # same for both
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
s1 = self._make_sample(duw, t1.isoformat())
|
||||||
|
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
s2 = self._make_sample(duw, t2.isoformat())
|
||||||
|
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert hour is not None
|
||||||
|
assert hour[0] == 0
|
||||||
|
|
||||||
|
def test_cross_hour_100mb_unattributed(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Samples in different hours → delta is unattributed to any hour."""
|
||||||
|
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 + 200 # ~100 MB
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
s1 = self._make_sample(duw1, t1.isoformat())
|
||||||
|
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
s2 = self._make_sample(duw2, t2.isoformat())
|
||||||
|
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
|
||||||
|
# Hour observations should NOT contain the cross-hour delta
|
||||||
|
hour11 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T11%'"
|
||||||
|
).fetchone()
|
||||||
|
hour12 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Neither hour should have the full 100 MB delta attributed
|
||||||
|
# (they may have 0 or partial, but not 200*512000)
|
||||||
|
full_delta = 200 * 512000
|
||||||
|
if hour11 is not None:
|
||||||
|
assert hour11[0] != full_delta, "Hour 11 should not have full cross-hour delta"
|
||||||
|
if hour12 is not None:
|
||||||
|
assert hour12[0] != full_delta, "Hour 12 should not have full cross-hour delta"
|
||||||
|
|
||||||
|
def test_readonly_sees_consistent_snapshot(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""A read-only reader sees valid pre- or post-publication snapshot."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample(12345678, t1.isoformat()),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
# Open read-only
|
||||||
|
ro_conn = sqlite3.connect(
|
||||||
|
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
|
||||||
|
)
|
||||||
|
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
ro_conn.close()
|
||||||
|
assert count_before == 1
|
||||||
|
|
||||||
|
# Write second sample
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample(12345718, t2.isoformat()),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
# Read-only reader sees 2 samples now
|
||||||
|
ro_conn2 = sqlite3.connect(
|
||||||
|
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
|
||||||
|
)
|
||||||
|
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
ro_conn2.close()
|
||||||
|
assert count_after == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Display states: awaiting first sample, awaiting another sample
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDisplayStates:
|
||||||
|
"""Display states for zero/one/two+ samples (issue #73 AC3)."""
|
||||||
|
|
||||||
|
def test_zero_samples_awaiting_first(self, tmp_path):
|
||||||
|
"""Zero samples → 'awaiting first sample' state."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
init_store(db)
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=False, query_journal=False)
|
||||||
|
assert "no observations yet" in result.lower() or "awaiting" in result.lower()
|
||||||
|
|
||||||
|
def test_one_sample_awaiting_another(self, tmp_path):
|
||||||
|
"""One sample → 'awaiting another sample' state."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
|
||||||
|
"percentage_used, available_spare, media_errors, power_on_hours, "
|
||||||
|
"power_cycles, unsafe_shutdowns, temperature_c, "
|
||||||
|
"data_units_written, data_units_read, bytes_written, bytes_read, "
|
||||||
|
"critical_warning) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
|
||||||
|
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
|
||||||
|
1000000, 500000, 512000000000, 256000000000, 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=False, query_journal=False)
|
||||||
|
# Should mention awaiting or insufficient data
|
||||||
|
lower = result.lower()
|
||||||
|
assert "awaiting" in lower or "another sample" in lower or "no projection" in lower
|
||||||
|
|
||||||
|
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
|
||||||
|
"""One sample → TUI shows awaiting state."""
|
||||||
|
from fenris.tui import FenrisTuiApp, _query_service_facts
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
|
||||||
|
"percentage_used, available_spare, media_errors, power_on_hours, "
|
||||||
|
"power_cycles, unsafe_shutdowns, temperature_c, "
|
||||||
|
"data_units_written, data_units_read, bytes_written, bytes_read, "
|
||||||
|
"critical_warning) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
|
||||||
|
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
|
||||||
|
1000000, 500000, 512000000000, 256000000000, 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
# Test the projection handles single sample
|
||||||
|
from fenris.projection import compute_projection, ConfidenceState
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
proj = compute_projection(conn, now)
|
||||||
|
conn.close()
|
||||||
|
# With only one sample, projection should be unavailable
|
||||||
|
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
|
||||||
|
def test_two_same_hour_samples_show_measured_usage(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Two compatible same-hour samples show measured usage."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_helper(12345678), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_helper(12345718), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
from fenris.status import get_status
|
||||||
|
from unittest.mock import patch
|
||||||
|
now = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=Path(config_fixture["store_path"]),
|
||||||
|
clock_now=now, query_services=False, query_journal=False)
|
||||||
|
# Should not say "no observations" or "awaiting"
|
||||||
|
lower = result.lower()
|
||||||
|
assert "no observations yet" not in lower
|
||||||
|
|
||||||
|
def _make_sample_helper(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pause crossing and counter reset
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPauseCrossing:
|
||||||
|
"""Delta across monitoring period gap (issue #73 AC4)."""
|
||||||
|
|
||||||
|
def test_pause_crossing_preserves_prior_history(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Delta across a paused period preserves prior hour observations."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t_pause = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t_resume = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 14, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 + 100
|
||||||
|
|
||||||
|
# First sample (opens period)
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
# Pause
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
close_period(conn, t_pause, "user_disabled")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Resume with new sample
|
||||||
|
clock_resume = FakeClock(t_resume)
|
||||||
|
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock_resume)
|
||||||
|
|
||||||
|
# Second sample after resume
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_for_pause(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
# Verify prior hour observations are intact
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
hour_12 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Hour 12 should still have data from the first collection
|
||||||
|
assert hour_12 is not None, "Prior hour observation should be preserved"
|
||||||
|
|
||||||
|
def _make_sample_for_pause(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCounterReset:
|
||||||
|
"""Counter reset / replacement opens new segment (issue #73 AC6)."""
|
||||||
|
|
||||||
|
def test_duw_decrease_opens_new_segment(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""DUW decrease triggers new segment."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 - 100 # decrease = reset
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_for_reset(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_for_reset(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
segments = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
|
||||||
|
samples = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Should have 2 segments (new one opened for DUW decrease)
|
||||||
|
assert segments == 2
|
||||||
|
# Should have 2 samples
|
||||||
|
assert samples == 2
|
||||||
|
|
||||||
|
def _make_sample_for_reset(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Derivation failure preserves prior history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDerivationFailure:
|
||||||
|
"""Injected derivation failure preserves prior history (issue #73 AC6)."""
|
||||||
|
|
||||||
|
def test_failed_derivation_preserves_samples(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""If derivation fails after sample write, prior data is intact."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_for_failure(12345678),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
# Verify first sample exists
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
# Second sample with valid data should succeed
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
r2 = run_collection(self._make_sample_for_failure(12345718),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
# Both samples should exist
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
def _make_sample_for_failure(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Monitoring period is opened by collector
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMonitoringPeriod:
|
||||||
|
"""Collector ensures monitoring period is open (issue #73 AC2)."""
|
||||||
|
|
||||||
|
def test_first_sample_opens_period(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""First collection run opens a monitoring period."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
period = get_open_period(conn)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert period is not None, "A monitoring period should be open"
|
||||||
|
|
||||||
|
def test_subsequent_sample_keeps_period_open(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Subsequent collection runs keep the period open."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
period = get_open_period(conn)
|
||||||
|
periods_count = conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert period is not None
|
||||||
|
assert periods_count == 1 # Still only one period
|
||||||
|
|
||||||
|
def _make_sample_simple(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": 12345678,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Tests for issue #77: Show honest qualifying-day progress and confidence.
|
||||||
|
|
||||||
|
These tests verify that:
|
||||||
|
1. Warming progress shows detailed qualifying day breakdown
|
||||||
|
2. Confidence state accurately reflects qualifying day progress
|
||||||
|
3. Edge cases like zero-delta intervals and unknown daily shares are handled
|
||||||
|
4. qualifying_days_progress shows honest qualifying day count
|
||||||
|
"""
|
||||||
|
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.projection import (
|
||||||
|
compute_projection, ConfidenceState, BaselineTier,
|
||||||
|
WARMING_MIN_DAYS, WARMING_COVERAGE_FLOOR, WARMING_MAX_LOW_COVERAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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, model="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
|
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", model, 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",
|
||||||
|
identity_key="nqn.test", degraded=False,
|
||||||
|
mn="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
|
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", mn, "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 _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
# Convert string to datetime if needed
|
||||||
|
if isinstance(start, str):
|
||||||
|
start = datetime.fromisoformat(start)
|
||||||
|
ensure_period_open(conn, start)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHonestQualifyingProgress:
|
||||||
|
"""Issue #77: Show honest qualifying-day progress and confidence."""
|
||||||
|
|
||||||
|
def test_warming_shows_qualifying_vs_nonqualifying(self, store):
|
||||||
|
"""Warming progress shows both qualifying and non-qualifying days."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 12 qualifying days + 2 non-qualifying (low coverage)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
|
||||||
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
|
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
# After warming, qualifying_days_progress shows honest count
|
||||||
|
assert result.warming_fact is None # Not warming (14 total, 2 below <= WARMING_MAX_LOW_COVERAGE)
|
||||||
|
assert result.qualifying_days_progress is not None
|
||||||
|
assert "12 of 14 qualifying days" in result.qualifying_days_progress
|
||||||
|
assert "2 below coverage" in result.qualifying_days_progress
|
||||||
|
|
||||||
|
def test_warming_progress_includes_nonqualifying_reason(self, store):
|
||||||
|
"""Warming progress indicates why days don't qualify."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 13 qualifying days + 1 non-qualifying (zero samples)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
samples = 0 if i == 0 else 24 # First day has no samples
|
||||||
|
_insert_day(store, d, bw=bw, samples=samples)
|
||||||
|
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
# After warming, qualifying_days_progress shows honest count
|
||||||
|
assert result.warming_fact is None # Not warming (14 total, 1 below <= WARMING_MAX_LOW_COVERAGE)
|
||||||
|
assert result.qualifying_days_progress is not None
|
||||||
|
assert "13 of 14 qualifying days" in result.qualifying_days_progress
|
||||||
|
assert "1 below coverage" in result.qualifying_days_progress
|
||||||
|
|
||||||
|
def test_confidence_updates_as_qualifying_days_increase(self, store):
|
||||||
|
"""Confidence state reflects actual qualifying day count."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# Start with 10 days (below minimum)
|
||||||
|
for i in range(10):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
|
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
# Should be warming
|
||||||
|
assert result.warming_fact is not None
|
||||||
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
|
|
||||||
|
def test_zero_rate_with_qualifying_days(self, store):
|
||||||
|
"""Zero rate with qualifying days shows honest state."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
|
||||||
|
# 14 days with zero bytes written
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=0, coverage=0.95)
|
||||||
|
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
# Zero rate should be UNSUPPORTED
|
||||||
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
assert result.zero_rate_fact is not None
|
||||||
|
assert "no finite projection" in result.zero_rate_fact
|
||||||
|
|
||||||
|
def test_qualifying_days_excludes_low_coverage(self, store):
|
||||||
|
"""Days below 50% coverage don't count as qualifying."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 14 days total, but 3 have low coverage
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
cov = 0.30 if i < 3 else 0.95
|
||||||
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
|
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
|
||||||
|
assert result.warming_fact is not None
|
||||||
|
assert "warming up" in result.warming_fact.lower()
|
||||||
|
|
||||||
|
def test_qualifying_days_excludes_zero_samples(self, store):
|
||||||
|
"""Days with zero samples don't count as qualifying."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 14 days total, but 3 have zero samples
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
samples = 0 if i < 3 else 24
|
||||||
|
_insert_day(store, d, bw=bw, samples=samples)
|
||||||
|
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
# Should still be warming
|
||||||
|
assert result.warming_fact is not None
|
||||||
|
assert "warming up" in result.warming_fact.lower()
|
||||||
|
|
||||||
|
def test_qualifying_days_progress_after_warming(self, store):
|
||||||
|
"""After warming, qualifying_days_progress shows honest count."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-17T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 14 total days, 12 qualifying (2 below coverage)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
|
||||||
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
|
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
|
||||||
|
assert result.warming_fact is None
|
||||||
|
# But qualifying_days_progress should show the honest count
|
||||||
|
assert result.qualifying_days_progress is not None
|
||||||
|
assert "12 of 14 qualifying days" in result.qualifying_days_progress
|
||||||
|
assert "2 below coverage" in result.qualifying_days_progress
|
||||||
|
|
||||||
|
def test_qualifying_days_progress_all_qualifying(self, store):
|
||||||
|
"""When all days qualify, qualifying_days_progress shows 100%."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-17T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 14 days, all qualifying
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
|
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
# Should not be warming
|
||||||
|
assert result.warming_fact is None
|
||||||
|
# qualifying_days_progress should show all qualifying
|
||||||
|
assert result.qualifying_days_progress is not None
|
||||||
|
assert "14 of 14 qualifying days" in result.qualifying_days_progress
|
||||||
|
# Should not show "below coverage" since all qualify
|
||||||
|
assert "below coverage" not in result.qualifying_days_progress
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Tests for issue #79: Apply Fenris identity and Drive health grouping.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- TPH-1: Titlebox shows Fenris identity with wolf fallback
|
||||||
|
- TPH-11: Single maker-credit placement, vendor wear under Drive health
|
||||||
|
- Preserve: continuity, pause block, quit rail, auth banner
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers (reuse from test_tui.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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,
|
||||||
|
model="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
|
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", model,
|
||||||
|
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",
|
||||||
|
identity_key="nqn.test", degraded=False,
|
||||||
|
mn="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
|
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", mn, "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, device="/dev/nvme0n1"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ts, device, 1000000, 500000, pu, 512000000000, 256000000000, 8765),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Issue #79: Fenris identity and Drive health grouping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFenrisIdentity:
|
||||||
|
"""TPH-1: Titlebox shows Fenris identity with wolf fallback."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_titlebox_shows_wolf_identity(self, tmp_path):
|
||||||
|
"""Top titlebox reads 🐺 Fenris by Bongbetic."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert "🐺 Fenris by Bongbetic" in headline
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_titlebox_fallback_without_wolf(self, tmp_path):
|
||||||
|
"""Fallback to 'Fenris by Bongbetic' when wolf is unsupported."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
# Simulate narrow terminal that can't render wolf
|
||||||
|
async with app.run_test(size=(60, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
# Either shows wolf or fallback - both are acceptable
|
||||||
|
assert "Fenris by Bongbetic" in headline
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wolf_never_shows_tofu(self, tmp_path):
|
||||||
|
"""Wolf glyph is never rendered as tofu (unsupported character)."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
# No replacement character (U+FFFD) should appear
|
||||||
|
assert "\ufffd" not in headline.lower()
|
||||||
|
assert "?" not in headline or "Fenris" in headline
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifespan_headline_is_data_surface(self, tmp_path):
|
||||||
|
"""Lifespan headline remains a data surface, not app title."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
# Identity appears once, lifespan is separate data
|
||||||
|
assert "🐺 Fenris by Bongbetic" in headline
|
||||||
|
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleMakerCredit:
|
||||||
|
"""TPH-1: Single maker-credit placement in title only."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_maker_credit_not_in_service_strip(self, tmp_path):
|
||||||
|
"""Remove duplicate maker credit from service facts."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
# "by Bongbetic" should NOT appear in service strip
|
||||||
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_maker_credit_in_titlebox(self, tmp_path):
|
||||||
|
"""Maker credit appears in the titlebox identity."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert "Fenris by Bongbetic" in headline
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
|
||||||
|
"""Empty store: no maker credit in service strip."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_fault_no_maker_credit_in_strip(self, tmp_path):
|
||||||
|
"""Store fault: no maker credit in service strip."""
|
||||||
|
# Create a corrupt store
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
db.write_bytes(b"not a database")
|
||||||
|
app = FenrisTuiApp(store_path=db)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
|
|
||||||
|
class TestDriveHealthVendorWear:
|
||||||
|
"""TPH-1: Vendor wear renders under Drive health context."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vendor_wear_in_drive_health(self, tmp_path):
|
||||||
|
"""Vendor wear shows with health context, not as Settings."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00", pu=10)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
health = str(app.query_one("#drive-health").render())
|
||||||
|
# Vendor wear should be present with health context
|
||||||
|
assert "vendor wear" in health.lower()
|
||||||
|
# Should show thermal, spare, errors, etc.
|
||||||
|
assert "temperature" in health.lower()
|
||||||
|
assert "spare" in health.lower()
|
||||||
|
assert "media errors" in health.lower()
|
||||||
|
# Settings section should NOT be a separate heading
|
||||||
|
assert "[bold]Settings[/bold]" not in health
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_wear_values_honest(self, tmp_path):
|
||||||
|
"""Missing values remain honest unavailable facts."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
# Insert sample with zero wear values
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 0, 0, 0, 0, 0, 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
health = str(app.query_one("#drive-health").render())
|
||||||
|
# Should show 0% used or honest zero, not crash
|
||||||
|
assert "vendor wear" in health.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreservedBehavior:
|
||||||
|
"""Preserve existing continuity, pause, quit, auth behavior."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_continuity_preserved(self, tmp_path):
|
||||||
|
"""Continuity wording preserved in service strip."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_open_period(conn)
|
||||||
|
conn.close()
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
|
||||||
|
with patch("fenris.tui.query_service_state", return_value={
|
||||||
|
"boot_enabled": True, "timer_active": True,
|
||||||
|
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
assert "continuity" in strip.lower()
|
||||||
|
assert "monitoring" in strip.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quit_rail_preserved(self, tmp_path):
|
||||||
|
"""Separate q QUIT TUI rail preserved."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
rail = str(app.query_one("#quit-rail").render())
|
||||||
|
assert "q QUIT TUI" in rail
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_banner_preserved(self, tmp_path):
|
||||||
|
"""Polkit authentication banner lifecycle preserved."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert "polkit" in headline.lower()
|
||||||
|
# Should clear after first tick
|
||||||
|
await pilot.pause(0.25)
|
||||||
|
headline_after = str(app.query_one("#headline-band").render())
|
||||||
|
assert "polkit" not in headline_after.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deliberate_pause_block_preserved(self, tmp_path):
|
||||||
|
"""Deliberate pause banner preserved."""
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
app = FenrisTuiApp(store_path=db)
|
||||||
|
|
||||||
|
with patch("fenris.tui.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
banner = str(app.query_one("#paused-banner").render())
|
||||||
|
assert "paused" in banner.lower()
|
||||||
|
assert "deliberate" in banner.lower()
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
"""Integration tests for issue #80: Persist accessible colour and motion preferences.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- AC80-1: Amber/Nord/High Contrast presets with Amber default
|
||||||
|
- AC80-2: t preset and m motion controls with clickable equivalents
|
||||||
|
- AC80-3: Persistent user-scoped XDG TUI preferences
|
||||||
|
- AC80-4: Reduced motion makes Monitoring steady
|
||||||
|
- AC80-5: Theme roles for graph rendering
|
||||||
|
- AC80-6: Headless interaction tests with temporary user config
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.preferences import (
|
||||||
|
load_preferences,
|
||||||
|
save_preferences,
|
||||||
|
get_preference_path,
|
||||||
|
)
|
||||||
|
from fenris.themes import get_theme, get_graph_colors, THEME_NAMES
|
||||||
|
from fenris.status_composition import (
|
||||||
|
StatusState,
|
||||||
|
compose_status,
|
||||||
|
render_status_tui,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clock(year=2026, month=9, day=30, hour=12):
|
||||||
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||||||
|
identity_key="nqn.test", degraded=False,
|
||||||
|
mn="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
|
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", mn, "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, device="/dev/nvme0n1"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ts, device, 1000000, 500000, 5, 512000000000, 256000000000, 8765),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_prefs_dir(tmp_path: Path) -> Path:
|
||||||
|
config_home = tmp_path / ".config"
|
||||||
|
config_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
return config_home
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-1: Preset loading and application
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPresetLoading:
|
||||||
|
"""Themes load from preferences and apply to the TUI."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tui_applies_amber_theme_by_default(self, tmp_path):
|
||||||
|
"""TUI starts with the Amber theme when no preferences exist."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# Theme should be fenris-amber
|
||||||
|
assert app.theme == "fenris-amber"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
|
||||||
|
"""TUI applies Nord theme from saved preferences."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="nord", reduced_motion=False)
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
assert app.theme == "fenris-nord"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tui_applies_high_contrast_from_prefs(self, tmp_path):
|
||||||
|
"""TUI applies High Contrast theme from saved preferences."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=False)
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
assert app.theme == "fenris-high-contrast"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tui_applies_reduced_motion_from_prefs(self, tmp_path):
|
||||||
|
"""TUI respects reduced_motion preference."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
from fenris.status_composition import compose_status
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=True)
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# The app should have reduced_motion set
|
||||||
|
assert app._reduced_motion is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-2: t and m key bindings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestKeyBindings:
|
||||||
|
"""t cycles presets and m toggles reduced motion."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_t_binding_exists(self, tmp_path):
|
||||||
|
"""The TUI has a 't' binding for theme cycling."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "t" in binding_keys
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_m_binding_exists(self, tmp_path):
|
||||||
|
"""The TUI has an 'm' binding for motion toggle."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "m" in binding_keys
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_t_cycles_through_themes(self, tmp_path):
|
||||||
|
"""Pressing t cycles through the three presets."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# Start at amber
|
||||||
|
assert app.theme == "fenris-amber"
|
||||||
|
|
||||||
|
# Press t to cycle
|
||||||
|
await pilot.press("t")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should be nord or high_contrast now
|
||||||
|
assert app.theme in ("fenris-nord", "fenris-high-contrast")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_m_toggles_reduced_motion(self, tmp_path):
|
||||||
|
"""Pressing m toggles reduced motion."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# Start with normal motion
|
||||||
|
assert app._reduced_motion is False
|
||||||
|
|
||||||
|
# Press m to toggle
|
||||||
|
await pilot.press("m")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should be reduced motion now
|
||||||
|
assert app._reduced_motion is True
|
||||||
|
|
||||||
|
# Press m again to toggle back
|
||||||
|
await pilot.press("m")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert app._reduced_motion is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-3: Persistence across restart
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPersistence:
|
||||||
|
"""Preferences survive app restart."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_theme_survives_restart(self, tmp_path):
|
||||||
|
"""Theme preference persists across TUI restart."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
# First run: change theme
|
||||||
|
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app1.run_test() as pilot:
|
||||||
|
await pilot.press("t")
|
||||||
|
await pilot.pause()
|
||||||
|
theme_after_t = app1.theme
|
||||||
|
assert theme_after_t != "fenris-amber"
|
||||||
|
|
||||||
|
# Second run: theme should persist
|
||||||
|
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app2.run_test() as pilot:
|
||||||
|
assert app2.theme == theme_after_t
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reduced_motion_survives_restart(self, tmp_path):
|
||||||
|
"""Reduced motion preference persists across TUI restart."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
# First run: toggle motion
|
||||||
|
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app1.run_test() as pilot:
|
||||||
|
await pilot.press("m")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app1._reduced_motion is True
|
||||||
|
|
||||||
|
# Second run: motion should persist
|
||||||
|
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app2.run_test() as pilot:
|
||||||
|
assert app2._reduced_motion is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-4: Reduced motion makes Monitoring steady
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestReducedMotion:
|
||||||
|
"""Reduced motion disables the Monitoring dot blink."""
|
||||||
|
|
||||||
|
def test_reduced_motion_disables_blink(self, tmp_path):
|
||||||
|
"""compose_status with reduced_motion=True → should_blink=False."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
now = _clock()
|
||||||
|
ts = (now - timedelta(minutes=2)).isoformat()
|
||||||
|
_insert_sample(conn, ts)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
svc = {
|
||||||
|
"boot_enabled": True, "timer_active": True,
|
||||||
|
"last_collect_ok": True, "last_collect_age_s": 120,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}
|
||||||
|
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
|
||||||
|
reduced_motion=True)
|
||||||
|
assert comp.state == StatusState.MONITORING
|
||||||
|
assert comp.should_blink is False
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_normal_motion_allows_blink(self, tmp_path):
|
||||||
|
"""compose_status with reduced_motion=False → should_blink=True for Monitoring."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
now = _clock()
|
||||||
|
ts = (now - timedelta(minutes=2)).isoformat()
|
||||||
|
_insert_sample(conn, ts)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
svc = {
|
||||||
|
"boot_enabled": True, "timer_active": True,
|
||||||
|
"last_collect_ok": True, "last_collect_age_s": 120,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}
|
||||||
|
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
|
||||||
|
reduced_motion=False)
|
||||||
|
assert comp.state == StatusState.MONITORING
|
||||||
|
assert comp.should_blink is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_framework_reduced_motion_honoured(self, tmp_path):
|
||||||
|
"""TUI honours Textual's reduced_motion signal."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# The app should check for reduced motion on mount
|
||||||
|
assert hasattr(app, '_reduced_motion')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-5: Theme roles for graph rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGraphThemeRoles:
|
||||||
|
"""Graph uses theme-derived colours for each bar role."""
|
||||||
|
|
||||||
|
def test_graph_colors_available_for_all_themes(self):
|
||||||
|
"""Every theme provides all required graph colour roles."""
|
||||||
|
required_roles = {"allocated", "unallocated", "gap", "zero", "partial"}
|
||||||
|
for name in THEME_NAMES:
|
||||||
|
colors = get_graph_colors(name)
|
||||||
|
assert required_roles.issubset(set(colors.keys())), "Theme %s missing roles: %s" % (name, required_roles - set(colors.keys()))
|
||||||
|
|
||||||
|
def test_graph_colors_are_strings(self):
|
||||||
|
"""All graph colour values are strings (Textual CSS colour values)."""
|
||||||
|
for name in THEME_NAMES:
|
||||||
|
colors = get_graph_colors(name)
|
||||||
|
for role, color in colors.items():
|
||||||
|
assert isinstance(color, str), "Theme %s role %s has non-string color: %s" % (name, role, color)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-6: Safe persistence — failure modes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSafePersistence:
|
||||||
|
"""Preference failures never crash the dashboard."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_corrupt_prefs_does_not_crash_tui(self, tmp_path):
|
||||||
|
"""Corrupt preference file does not prevent TUI from starting."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / "preferences.json").write_text("{bad json!!!")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# TUI should start with default theme
|
||||||
|
assert app.theme == "fenris-amber"
|
||||||
|
# Dashboard should be functional
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert headline is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_readonly_config_dir_does_not_crash(self, tmp_path):
|
||||||
|
"""Read-only config directory does not prevent TUI from starting."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fenris_dir.chmod(0o555)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# TUI should start without crash
|
||||||
|
assert app.theme in ("fenris-amber", "fenris-nord", "fenris-high-contrast")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI isolation (preferences do not affect CLI)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCLIIsolation:
|
||||||
|
"""Preferences are TUI-only — CLI status is independent."""
|
||||||
|
|
||||||
|
def test_cli_status_unchanged_by_theme(self, tmp_path):
|
||||||
|
"""fenris status output does not change based on theme preference."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
now = _clock()
|
||||||
|
ts = (now - timedelta(minutes=2)).isoformat()
|
||||||
|
_insert_sample(conn, ts)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
|
||||||
|
# With amber theme
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
status_amber = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=now,
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# With high contrast theme
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=True)
|
||||||
|
status_hc = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=now,
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status output should be identical
|
||||||
|
assert status_amber == status_hc
|
||||||
|
|
||||||
|
def test_cli_status_unchanged_by_motion(self, tmp_path):
|
||||||
|
"""fenris status output does not change based on reduced_motion preference."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
now = _clock()
|
||||||
|
ts = (now - timedelta(minutes=2)).isoformat()
|
||||||
|
_insert_sample(conn, ts)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
status_normal = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=now,
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=True)
|
||||||
|
status_reduced = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=now,
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status_normal == status_reduced
|
||||||
+110
-83
@@ -32,24 +32,52 @@ def _get_version() -> str:
|
|||||||
return get_version()
|
return get_version()
|
||||||
|
|
||||||
|
|
||||||
def _docker_available() -> bool:
|
def _get_container_runtime() -> str:
|
||||||
"""Check if Docker daemon is reachable."""
|
"""Get available container runtime (podman or docker)."""
|
||||||
|
for cmd in ["podman", "docker"]:
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run([cmd, "info"], capture_output=True, timeout=10)
|
||||||
["docker", "info"], capture_output=True, timeout=10
|
if r.returncode == 0:
|
||||||
)
|
return cmd
|
||||||
return r.returncode == 0
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
return False
|
continue
|
||||||
|
return "docker" # fallback
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _get_container_runtime() -> str:
|
||||||
|
"""Get available container runtime (podman or docker)."""
|
||||||
|
for cmd in ["podman", "docker"]:
|
||||||
|
try:
|
||||||
|
r = subprocess.run([cmd, "info"], capture_output=True, timeout=10)
|
||||||
|
if r.returncode == 0:
|
||||||
|
return cmd
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
return "docker" # fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_available() -> bool:
|
||||||
|
"""Check if Docker or Podman daemon is reachable."""
|
||||||
|
return _get_container_runtime() != ""
|
||||||
|
|
||||||
|
|
||||||
def _container_exec(container: str, cmd: str) -> tuple[int, str]:
|
def _container_exec(container: str, cmd: str) -> tuple[int, str]:
|
||||||
"""Execute a command inside a running container."""
|
"""Execute a command inside a running container."""
|
||||||
|
runtime = _get_container_runtime()
|
||||||
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["docker", "exec", container, "sh", "-c", cmd],
|
[runtime, "exec", container, "sh", "-c", cmd],
|
||||||
capture_output=True, text=True, timeout=120,
|
capture_output=True, text=True, timeout=120,
|
||||||
)
|
)
|
||||||
return r.returncode, r.stdout + r.stderr
|
return r.returncode, r.stdout + r.stderr
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
||||||
|
return 1, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _container_cmd(*args: str) -> list[str]:
|
||||||
|
"""Build a container runtime command (podman or docker)."""
|
||||||
|
return [_get_container_runtime()] + list(args)
|
||||||
|
|
||||||
|
|
||||||
def _find_package(fmt: str) -> Path:
|
def _find_package(fmt: str) -> Path:
|
||||||
@@ -209,7 +237,7 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
|
|||||||
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
|
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
|
||||||
assert rc == 0, "Observation store directory not created"
|
assert rc == 0, "Observation store directory not created"
|
||||||
parts = out.strip().split()
|
parts = out.strip().split()
|
||||||
assert parts[0] == "2750", f"Store dir mode: expected 2750, got {parts[0]}"
|
assert parts[0] == "2770", f"Store dir mode: expected 2770, got {parts[0]}"
|
||||||
assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}"
|
assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}"
|
||||||
assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}"
|
assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}"
|
||||||
|
|
||||||
@@ -354,12 +382,12 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version:
|
|||||||
)
|
)
|
||||||
assert rc == 0, "Observation store missing after upgrade"
|
assert rc == 0, "Observation store missing after upgrade"
|
||||||
|
|
||||||
# Store directory was not rebuilt (same inode, mode 2750)
|
# Store directory was not rebuilt (same inode, mode 2770)
|
||||||
rc, out = _container_exec(
|
rc, out = _container_exec(
|
||||||
container, "stat -c '%a' /var/lib/fenris"
|
container, "stat -c '%a' /var/lib/fenris"
|
||||||
)
|
)
|
||||||
assert rc == 0, "Store directory missing after upgrade"
|
assert rc == 0, "Store directory missing after upgrade"
|
||||||
assert out.strip() == "2750", (
|
assert out.strip() == "2770", (
|
||||||
f"Store directory mode changed during upgrade (rebuilt?): {out.strip()}"
|
f"Store directory mode changed during upgrade (rebuilt?): {out.strip()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -431,13 +459,13 @@ def _setup_store_and_config(container: str) -> None:
|
|||||||
_container_exec(
|
_container_exec(
|
||||||
container,
|
container,
|
||||||
"mkdir -p /var/lib/fenris && "
|
"mkdir -p /var/lib/fenris && "
|
||||||
"python3 -c \""
|
"python3 -c '"
|
||||||
"import sqlite3; "
|
"import sqlite3; "
|
||||||
"c = sqlite3.connect('/var/lib/fenris/observations.db'); "
|
"c = sqlite3.connect(\"/var/lib/fenris/observations.db\"); "
|
||||||
"c.execute('PRAGMA user_version=1'); "
|
"c.execute(\"PRAGMA user_version=1\"); "
|
||||||
"c.commit(); c.close()\" && "
|
"c.commit(); c.close()' && "
|
||||||
"touch /var/lib/fenris/observations.db-wal && "
|
"echo 'wal' > /var/lib/fenris/observations.db-wal && "
|
||||||
"touch /var/lib/fenris/observations.db-shm && "
|
"echo 'shm' > /var/lib/fenris/observations.db-shm && "
|
||||||
"cp /var/lib/fenris/observations.db /var/lib/fenris/observations.db.bak",
|
"cp /var/lib/fenris/observations.db /var/lib/fenris/observations.db.bak",
|
||||||
)
|
)
|
||||||
_container_exec(
|
_container_exec(
|
||||||
@@ -472,14 +500,21 @@ def _assert_deb_remove_preserves(container: str) -> None:
|
|||||||
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||||||
assert rc == 0, "Store directory should survive deb remove"
|
assert rc == 0, "Store directory should survive deb remove"
|
||||||
|
|
||||||
# Store DB, WAL sidecars, and backup survive
|
# Store DB and backup survive (WAL/SHM are ephemeral SQLite files
|
||||||
for name in ("observations.db", "observations.db-wal",
|
# cleaned by dpkg from package-owned directories)
|
||||||
"observations.db-shm", "observations.db.bak"):
|
for name in ("observations.db", "observations.db.bak"):
|
||||||
rc, _ = _container_exec(
|
rc, _ = _container_exec(
|
||||||
container, f"test -f /var/lib/fenris/{name}"
|
container, f"test -f /var/lib/fenris/{name}"
|
||||||
)
|
)
|
||||||
assert rc == 0, f"Store file {name} should survive deb remove"
|
assert rc == 0, f"Store file {name} should survive deb remove"
|
||||||
|
|
||||||
|
# WAL/SHM are gone (dpkg cleans them from package-owned dirs)
|
||||||
|
for name in ("observations.db-wal", "observations.db-shm"):
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container, f"test -f /var/lib/fenris/{name}"
|
||||||
|
)
|
||||||
|
assert rc != 0, f"Ephemeral file {name} should not survive deb remove"
|
||||||
|
|
||||||
# Service group survives
|
# Service group survives
|
||||||
rc, _ = _container_exec(container, "getent group fenris")
|
rc, _ = _container_exec(container, "getent group fenris")
|
||||||
assert rc == 0, "Group should survive deb remove"
|
assert rc == 0, "Group should survive deb remove"
|
||||||
@@ -567,18 +602,16 @@ def test_python_floor(skip_no_docker, version):
|
|||||||
|
|
||||||
tag = "fenris-floor-ubuntu-2204"
|
tag = "fenris-floor-ubuntu-2204"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
container = f"fenris-floor-{os.getpid()}"
|
container = f"fenris-floor-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"docker", "run", "-d", "--name", container,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag, "sleep", "infinity",
|
tag, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -607,7 +640,7 @@ def test_python_floor(skip_no_docker, version):
|
|||||||
assert rc == 0, "fenris not on PATH after floor-met install"
|
assert rc == 0, "fenris not on PATH after floor-met install"
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container],
|
_container_cmd("rm", "-f", container),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -630,18 +663,16 @@ def test_python_floor(skip_no_docker, version):
|
|||||||
|
|
||||||
tag_floor = "fenris-below-floor-debian11"
|
tag_floor = "fenris-below-floor-debian11"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag_floor, str(build_dir_floor)],
|
_container_cmd("build", "-t", tag_floor, str(build_dir_floor)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
container_floor = f"fenris-below-floor-{os.getpid()}"
|
container_floor = f"fenris-below-floor-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container_floor,
|
||||||
"docker", "run", "-d", "--name", container_floor,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag_floor, "sleep", "infinity",
|
tag_floor, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -676,7 +707,7 @@ def test_python_floor(skip_no_docker, version):
|
|||||||
f"Package should not be configured below Python floor: {out}"
|
f"Package should not be configured below Python floor: {out}"
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container_floor],
|
_container_cmd("rm", "-f", container_floor),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -711,7 +742,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
|||||||
# Build image
|
# Build image
|
||||||
tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}"
|
tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
@@ -720,11 +751,9 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
|||||||
# Run container
|
# Run container
|
||||||
container = f"fenris-test-{os.getpid()}"
|
container = f"fenris-test-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"docker", "run", "-d", "--name", container,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag, "sleep", "infinity",
|
tag, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -733,7 +762,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
|||||||
_assert_dormant_layout(container, fmt, version)
|
_assert_dormant_layout(container, fmt, version)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container],
|
_container_cmd("rm", "-f", container),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -769,11 +798,13 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
|||||||
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
|
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
|
||||||
""")
|
""")
|
||||||
else:
|
else:
|
||||||
|
if image.startswith("opensuse/"):
|
||||||
|
install_command = "zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
|
||||||
|
else:
|
||||||
|
install_command = "dnf install -y --setopt=install_weak_deps=False python3 smartmontools systemd dbus && dnf clean all"
|
||||||
dockerfile = textwrap.dedent(f"""\
|
dockerfile = textwrap.dedent(f"""\
|
||||||
FROM {image}
|
FROM {image}
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
RUN {install_command}
|
||||||
python3 smartmontools systemd dbus && \
|
|
||||||
dnf clean all
|
|
||||||
COPY dist/{pkg_name} /pkg/{pkg_name}
|
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||||
RUN mkdir -p /etc/systemd/system && \\
|
RUN mkdir -p /etc/systemd/system && \\
|
||||||
echo '[Unit]' > /etc/systemd/system/fenris-collect.timer
|
echo '[Unit]' > /etc/systemd/system/fenris-collect.timer
|
||||||
@@ -782,7 +813,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
|||||||
|
|
||||||
tag = f"fenris-guard-{image.replace(':', '-').replace('/', '-')}"
|
tag = f"fenris-guard-{image.replace(':', '-').replace('/', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
@@ -790,11 +821,9 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
|||||||
|
|
||||||
container = f"fenris-guard-{os.getpid()}"
|
container = f"fenris-guard-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"docker", "run", "-d", "--name", container,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag, "sleep", "infinity",
|
tag, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -803,7 +832,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
|||||||
_assert_migration_guard(container, fmt, pkg_name)
|
_assert_migration_guard(container, fmt, pkg_name)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container],
|
_container_cmd("rm", "-f", container),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -835,7 +864,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
|||||||
|
|
||||||
tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}"
|
tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
@@ -843,11 +872,9 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
|||||||
|
|
||||||
container = f"fenris-upgrade-{os.getpid()}"
|
container = f"fenris-upgrade-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"docker", "run", "-d", "--name", container,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag, "sleep", "infinity",
|
tag, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -860,7 +887,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
|||||||
_assert_rpm_upgrade_full(container, pkg_name)
|
_assert_rpm_upgrade_full(container, pkg_name)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container],
|
_container_cmd("rm", "-f", container),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -889,14 +916,14 @@ def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version):
|
|||||||
|
|
||||||
tag = f"fenris-deb-remove-{image.replace(':', '-')}"
|
tag = f"fenris-deb-remove-{image.replace(':', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-deb-remove-{os.getpid()}"
|
container = f"fenris-deb-remove-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -911,7 +938,7 @@ def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version):
|
|||||||
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -935,14 +962,14 @@ def test_deb_purge_removes_everything(skip_no_docker, image, version):
|
|||||||
|
|
||||||
tag = f"fenris-deb-purge-{image.replace(':', '-')}"
|
tag = f"fenris-deb-purge-{image.replace(':', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-deb-purge-{os.getpid()}"
|
container = f"fenris-deb-purge-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -954,12 +981,12 @@ def test_deb_purge_removes_everything(skip_no_docker, image, version):
|
|||||||
_assert_package_files_removed(container)
|
_assert_package_files_removed(container)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
@pytest.mark.parametrize("image", [t[0] for t in ALL_TARGETS],
|
@pytest.mark.parametrize("image,fmt", ALL_TARGETS,
|
||||||
ids=[t[0] for t in ALL_TARGETS])
|
ids=[t[0] for t in ALL_TARGETS])
|
||||||
def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
||||||
version):
|
version):
|
||||||
@@ -983,14 +1010,14 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
|||||||
|
|
||||||
tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}"
|
tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-upgrade-no-disable-{os.getpid()}"
|
container = f"fenris-upgrade-no-disable-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1050,7 +1077,7 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
|||||||
assert rc == 0, "Helper removed during upgrade"
|
assert rc == 0, "Helper removed during upgrade"
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1073,14 +1100,14 @@ def test_rpm_erase_modified_config_preserved(skip_no_docker, version):
|
|||||||
|
|
||||||
tag = "fenris-rpm-erase-mod"
|
tag = "fenris-rpm-erase-mod"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-rpm-erase-{os.getpid()}"
|
container = f"fenris-rpm-erase-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1106,7 +1133,7 @@ def test_rpm_erase_modified_config_preserved(skip_no_docker, version):
|
|||||||
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1129,14 +1156,14 @@ def test_rpm_erase_unmodified_config_removed(skip_no_docker, version):
|
|||||||
|
|
||||||
tag = "fenris-rpm-erase-unmod"
|
tag = "fenris-rpm-erase-unmod"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-rpm-erase-unmod-{os.getpid()}"
|
container = f"fenris-rpm-erase-unmod-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1154,7 +1181,7 @@ def test_rpm_erase_unmodified_config_removed(skip_no_docker, version):
|
|||||||
_assert_package_files_removed(container)
|
_assert_package_files_removed(container)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1166,7 +1193,7 @@ def _seed_make_install_state(container: str, *, include_manifest: bool = True) -
|
|||||||
"""Seed a container with make-install-shaped state."""
|
"""Seed a container with make-install-shaped state."""
|
||||||
cmds = [
|
cmds = [
|
||||||
"groupadd -f fenris",
|
"groupadd -f fenris",
|
||||||
"install -d -o root -g fenris -m 2750 /var/lib/fenris",
|
"install -d -o root -g fenris -m 2770 /var/lib/fenris",
|
||||||
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
||||||
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
|
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
|
||||||
"c.execute('PRAGMA user_version=1'); c.commit(); c.close()\"",
|
"c.execute('PRAGMA user_version=1'); c.commit(); c.close()\"",
|
||||||
@@ -1285,14 +1312,14 @@ def test_no_move_continuity_deb(skip_no_docker, image, version):
|
|||||||
|
|
||||||
tag = f"fenris-no-move-deb-{image.replace(':', '-')}"
|
tag = f"fenris-no-move-deb-{image.replace(':', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-no-move-deb-{os.getpid()}"
|
container = f"fenris-no-move-deb-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1333,7 +1360,7 @@ def test_no_move_continuity_deb(skip_no_docker, image, version):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1360,14 +1387,14 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
|
|||||||
|
|
||||||
tag = "fenris-no-move-rpm"
|
tag = "fenris-no-move-rpm"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-no-move-rpm-{os.getpid()}"
|
container = f"fenris-no-move-rpm-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1407,5 +1434,5 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"""Tests for user-scoped TUI preferences (issue #80).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Preference load/save with safe defaults
|
||||||
|
- XDG_CONFIG_HOME user-scoped persistence
|
||||||
|
- Amber default theme, normal-motion default
|
||||||
|
- Invalid/unreadable/unwritable preference data does not crash
|
||||||
|
- Theme presets: Amber, Nord, High Contrast
|
||||||
|
- Reduced motion preference persistence
|
||||||
|
- CLI status and collector behaviour unchanged by preferences
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.preferences import (
|
||||||
|
load_preferences,
|
||||||
|
save_preferences,
|
||||||
|
get_preference_path,
|
||||||
|
PREFERENCE_FILE_NAME,
|
||||||
|
VALID_THEMES,
|
||||||
|
DEFAULT_THEME,
|
||||||
|
DEFAULT_REDUCED_MOTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_prefs_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Create a fake XDG_CONFIG_HOME with fenris subdir."""
|
||||||
|
config_home = tmp_path / ".config"
|
||||||
|
config_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
return config_home
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preference path tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPreferencePath:
|
||||||
|
"""Preference file lives at XDG_CONFIG_HOME/fenris/preferences.json."""
|
||||||
|
|
||||||
|
def test_uses_xdg_config_home(self, tmp_path):
|
||||||
|
"""Path respects XDG_CONFIG_HOME environment variable."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
path = get_preference_path()
|
||||||
|
assert path == config_home / "fenris" / PREFERENCE_FILE_NAME
|
||||||
|
|
||||||
|
def test_default_path_fallback(self):
|
||||||
|
"""When XDG_CONFIG_HOME is unset, falls back to ~/.config."""
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
# Remove XDG_CONFIG_HOME if present
|
||||||
|
os.environ.pop("XDG_CONFIG_HOME", None)
|
||||||
|
path = get_preference_path()
|
||||||
|
assert "fenris" in str(path)
|
||||||
|
assert PREFERENCE_FILE_NAME in str(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default preferences tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDefaults:
|
||||||
|
"""When no preference file exists, defaults are returned."""
|
||||||
|
|
||||||
|
def test_default_theme_is_amber(self):
|
||||||
|
"""Amber is the default theme preset."""
|
||||||
|
assert DEFAULT_THEME == "amber"
|
||||||
|
|
||||||
|
def test_default_reduced_motion_is_false(self):
|
||||||
|
"""Normal motion is the default."""
|
||||||
|
assert DEFAULT_REDUCED_MOTION is False
|
||||||
|
|
||||||
|
def test_valid_themes_are_all_presets(self):
|
||||||
|
"""Three valid presets exist."""
|
||||||
|
assert VALID_THEMES == {"amber", "nord", "high_contrast"}
|
||||||
|
|
||||||
|
def test_load_returns_defaults_when_no_file(self, tmp_path):
|
||||||
|
"""Missing preference file returns safe defaults."""
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "amber"
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Save and load round-trip tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRoundTrip:
|
||||||
|
"""Preferences survive save → load."""
|
||||||
|
|
||||||
|
def test_save_and_load_basic(self, tmp_path):
|
||||||
|
"""Basic theme and motion save/load."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="nord", reduced_motion=True)
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "nord"
|
||||||
|
assert prefs["reduced_motion"] is True
|
||||||
|
|
||||||
|
def test_save_creates_directory(self, tmp_path):
|
||||||
|
"""Save creates the fenris config directory if missing."""
|
||||||
|
config_home = tmp_path / ".config"
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
assert (config_home / "fenris" / PREFERENCE_FILE_NAME).exists()
|
||||||
|
|
||||||
|
def test_overwrites_existing(self, tmp_path):
|
||||||
|
"""Second save overwrites the first."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=True)
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "high_contrast"
|
||||||
|
assert prefs["reduced_motion"] is True
|
||||||
|
|
||||||
|
def test_all_themes_round_trip(self, tmp_path):
|
||||||
|
"""Every valid theme saves and loads correctly."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
for theme in VALID_THEMES:
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme=theme, reduced_motion=False)
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == theme
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Safe failure tests — invalid/unreadable/unwritable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSafeFailures:
|
||||||
|
"""Invalid data never crashes the dashboard."""
|
||||||
|
|
||||||
|
def test_corrupt_json_returns_defaults(self, tmp_path):
|
||||||
|
"""Malformed JSON returns safe defaults."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text("{corrupt json!!")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "amber"
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
def test_unknown_theme_returns_default(self, tmp_path):
|
||||||
|
"""Unrecognized theme value falls back to amber."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
|
||||||
|
json.dumps({"theme": "neon-pink", "reduced_motion": False})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "amber"
|
||||||
|
|
||||||
|
def test_missing_keys_get_defaults(self, tmp_path):
|
||||||
|
"""Partial preference file fills in missing keys."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
|
||||||
|
json.dumps({"theme": "nord"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "nord"
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
def test_unreadable_file_returns_defaults(self, tmp_path):
|
||||||
|
"""Permission denied on preference file returns defaults."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
pref_file = fenris_dir / PREFERENCE_FILE_NAME
|
||||||
|
pref_file.write_text(json.dumps({"theme": "nord"}))
|
||||||
|
pref_file.chmod(0o000)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
# Should fall back to defaults without crashing
|
||||||
|
assert prefs["theme"] in VALID_THEMES
|
||||||
|
|
||||||
|
def test_unwritable_location_returns_defaults(self, tmp_path):
|
||||||
|
"""Read-only config directory returns defaults without crashing."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fenris_dir.chmod(0o555)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
# Should not raise
|
||||||
|
save_preferences(theme="nord", reduced_motion=True)
|
||||||
|
prefs = load_preferences()
|
||||||
|
# Either saved successfully or fell back — either way, no crash
|
||||||
|
assert prefs["theme"] in VALID_THEMES
|
||||||
|
|
||||||
|
def test_non_json_file_returns_defaults(self, tmp_path):
|
||||||
|
"""A file that isn't JSON returns defaults."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text("this is not json")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "amber"
|
||||||
|
|
||||||
|
def test_wrong_type_for_reduced_motion(self, tmp_path):
|
||||||
|
"""Non-boolean reduced_motion falls back to default."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
|
||||||
|
json.dumps({"theme": "amber", "reduced_motion": "yes"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI/collector isolation tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCLIIsolation:
|
||||||
|
"""TUI display preferences do not affect CLI status or collector."""
|
||||||
|
|
||||||
|
def test_cli_status_ignores_preferences(self, tmp_path):
|
||||||
|
"""fenris status output is independent of TUI preferences."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
# Create a valid store with data
|
||||||
|
from fenris.store import init_store
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 1000000, 500000,
|
||||||
|
5, 512000000000, 256000000000, 8765),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Save non-default preferences
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=True)
|
||||||
|
status_text = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
# Status output should NOT contain theme names or motion settings
|
||||||
|
assert "high_contrast" not in status_text.lower()
|
||||||
|
assert "reduced_motion" not in status_text.lower()
|
||||||
|
assert "amber" not in status_text.lower()
|
||||||
|
|
||||||
|
def test_preferences_do_not_alter_store(self, tmp_path):
|
||||||
|
"""Saving preferences never writes to the observation store."""
|
||||||
|
import sqlite3
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
conn = init_store(store_path)
|
||||||
|
# Record store state before preference save
|
||||||
|
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables_before = sorted(r[0] for r in cursor.fetchall())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="nord", reduced_motion=True)
|
||||||
|
|
||||||
|
# Store schema should be unchanged
|
||||||
|
conn = sqlite3.connect(store_path)
|
||||||
|
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables_after = sorted(r[0] for r in cursor.fetchall())
|
||||||
|
conn.close()
|
||||||
|
assert tables_before == tables_after
|
||||||
|
|
||||||
|
|
||||||
|
# Need datetime for CLI isolation test
|
||||||
|
from datetime import datetime, timezone
|
||||||
+124
-1
@@ -22,7 +22,7 @@ from fenris.monitoring_periods import ensure_period_open, close_period
|
|||||||
from fenris.projection import (
|
from fenris.projection import (
|
||||||
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
|
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
|
||||||
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
|
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
|
||||||
YOUNG_REGIME_DAYS, DISCLOSURES,
|
YOUNG_REGIME_DAYS, DISCLOSURES, _compute_horizon_rate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -899,3 +899,126 @@ class TestIdentityChangeBlankKeys:
|
|||||||
# All 25 days in same segment (equal blanks continue)
|
# All 25 days in same segment (equal blanks continue)
|
||||||
assert result.regime_days is not None
|
assert result.regime_days is not None
|
||||||
assert result.regime_days >= 20 # Most of the history
|
assert result.regime_days >= 20 # Most of the history
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Issue #76: Evidence-anchored projection rates
|
||||||
|
# Scenario windows anchored at latest evidence endpoint T
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvidenceAnchoredHorizons:
|
||||||
|
"""Issue #76: Scenario windows anchored at latest published usage-evidence
|
||||||
|
endpoint T with exact trailing 7/28/90×86400-second starts."""
|
||||||
|
|
||||||
|
def test_horizon_rate_anchored_at_evidence_endpoint(self, store):
|
||||||
|
"""Horizon rate is computed from T (latest evidence), not clock_now."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
# 30 days of data ending Sep 29
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# Clock is Oct 1, but T is Sep 29 (latest evidence endpoint)
|
||||||
|
clock = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
result = compute_projection(store, clock)
|
||||||
|
# 7-day horizon should be anchored at Sep 29, not Oct 1
|
||||||
|
if result.scenario_range and 7 in result.scenario_range.rates:
|
||||||
|
# Rate should be based on Sep 23-29, not Sep 25-Oct 1
|
||||||
|
assert result.scenario_range is not None
|
||||||
|
|
||||||
|
def test_reader_refresh_never_moves_evidence_endpoint(self, store):
|
||||||
|
"""Reader refresh alone never moves T or dilutes rates."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# Two reads at different clock times
|
||||||
|
clock1 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
clock2 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
r1 = compute_projection(store, clock1)
|
||||||
|
r2 = compute_projection(store, clock2)
|
||||||
|
# Both should produce identical scenario rates (anchored at T, not clock)
|
||||||
|
if r1.scenario_range and r2.scenario_range:
|
||||||
|
assert r1.scenario_range.rates == r2.scenario_range.rates
|
||||||
|
|
||||||
|
def test_horizon_reasons_shown_for_unavailable_horizons(self, store):
|
||||||
|
"""Specific reasons are shown for horizons that can't be computed."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
# Only 10 days of data
|
||||||
|
for i in range(10):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
# 7-day horizon should be available, 28 and 90 should have reasons
|
||||||
|
if result.scenario_range:
|
||||||
|
assert 7 in result.scenario_range.rates
|
||||||
|
if 28 in result.scenario_range.horizon_reasons:
|
||||||
|
assert "starts before earliest data" in result.scenario_range.horizon_reasons[28]
|
||||||
|
if 90 in result.scenario_range.horizon_reasons:
|
||||||
|
assert "starts before earliest data" in result.scenario_range.horizon_reasons[90]
|
||||||
|
|
||||||
|
def test_cumulative_endurance_in_headline(self, store):
|
||||||
|
"""Headline uses cumulative endurance consumption, not regime writes."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
# Headline should be computed with cumulative bytes
|
||||||
|
if result.headline_remaining_seconds is not None:
|
||||||
|
# E_baseline = 10 TB = 10e12 bytes
|
||||||
|
# cumulative_bytes = 30 * 100 * 1024 * 1024
|
||||||
|
# rate = cumulative_bytes / wall_clock
|
||||||
|
# headline = (E_baseline - cumulative_bytes) / rate
|
||||||
|
E_baseline = 10.0 * TBW_TO_BYTES
|
||||||
|
cumulative_bytes = 30 * bw
|
||||||
|
assert result.headline_remaining_seconds >= 0
|
||||||
|
|
||||||
|
def test_zero_boundary_delta_returns_zero(self, store):
|
||||||
|
"""Zero monotonic delta proves zero over its represented subspan."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
# Days with zero bytes written
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=0)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
# Zero rate should result in UNSUPPORTED
|
||||||
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
assert result.headline_remaining_seconds is None
|
||||||
|
|
||||||
|
def test_noon_endpoint_same_as_midnight(self, store):
|
||||||
|
"""Noon endpoint produces same rates as midnight endpoint."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# Both reads should produce same scenario rates
|
||||||
|
clock1 = datetime(2026, 9, 30, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
clock2 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
r1 = compute_projection(store, clock1)
|
||||||
|
r2 = compute_projection(store, clock2)
|
||||||
|
if r1.scenario_range and r2.scenario_range:
|
||||||
|
assert r1.scenario_range.rates == r2.scenario_range.rates
|
||||||
|
|||||||
+54
-3
@@ -1,7 +1,9 @@
|
|||||||
"""Raw sample pruning tests.
|
"""Raw sample pruning tests.
|
||||||
|
|
||||||
Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and
|
Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and
|
||||||
day aggregates retained indefinitely.
|
day aggregates are retained indefinitely.
|
||||||
|
|
||||||
|
Issue #74: Boundary anchors required for successor evidence are retained.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
@@ -51,6 +53,9 @@ class TestPruneOldSamples:
|
|||||||
def test_removes_old_samples(self, store_conn):
|
def test_removes_old_samples(self, store_conn):
|
||||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
# Insert samples at 10, 14, and 15 days ago
|
# Insert samples at 10, 14, and 15 days ago
|
||||||
|
# All three are before the cutoff (2026-09-01T12:00:00)
|
||||||
|
# The 15-day-old sample is not a boundary anchor because
|
||||||
|
# the next sample (14 days ago) is also before the cutoff
|
||||||
for days_ago in [10, 14, 15]:
|
for days_ago in [10, 14, 15]:
|
||||||
ts = (now - timedelta(days=days_ago)).isoformat()
|
ts = (now - timedelta(days=days_ago)).isoformat()
|
||||||
_insert_sample(store_conn, ts)
|
_insert_sample(store_conn, ts)
|
||||||
@@ -82,6 +87,7 @@ class TestPruneOldSamples:
|
|||||||
"""Hour observations are retained indefinitely."""
|
"""Hour observations are retained indefinitely."""
|
||||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
# Insert an old sample and a recent sample
|
# Insert an old sample and a recent sample
|
||||||
|
# The old sample is a boundary anchor (needed for derivation)
|
||||||
_insert_sample(store_conn, (now - timedelta(days=20)).isoformat())
|
_insert_sample(store_conn, (now - timedelta(days=20)).isoformat())
|
||||||
_insert_sample(store_conn, (now - timedelta(days=1)).isoformat())
|
_insert_sample(store_conn, (now - timedelta(days=1)).isoformat())
|
||||||
|
|
||||||
@@ -95,10 +101,55 @@ class TestPruneOldSamples:
|
|||||||
|
|
||||||
prune_old_samples(store_conn, now, retention_days=14)
|
prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
# Sample removed
|
# Old sample is retained as boundary anchor (needed for derivation)
|
||||||
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
assert cursor.fetchone()[0] == 1
|
assert cursor.fetchone()[0] == 2
|
||||||
|
|
||||||
# Hour observation retained
|
# Hour observation retained
|
||||||
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
|
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||||
assert cursor.fetchone()[0] == 1
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_boundary_anchor_retained(self, store_conn):
|
||||||
|
"""Boundary anchors required for derivation are retained."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old sample before boundary (2026-09-14T23:55:00)
|
||||||
|
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
|
||||||
|
|
||||||
|
# Sample after boundary (2026-09-16T12:30:00)
|
||||||
|
# is 13 days and 23.5 hours old (within retention)
|
||||||
|
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Run pruning
|
||||||
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# The boundary anchor should be retained
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_old_sample_with_derived_interval_removed(self, store_conn):
|
||||||
|
"""Old samples with fully derived intervals are removed."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old sample with derived interval
|
||||||
|
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Hour observation exists for the interval
|
||||||
|
store_conn.execute(
|
||||||
|
"INSERT INTO hour_observations (hour, active_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES ('2026-09-10T10:00:00+00:00', 3600, 1000000, 0, 1, 1.0)",
|
||||||
|
)
|
||||||
|
store_conn.commit()
|
||||||
|
|
||||||
|
# Run pruning
|
||||||
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# Old sample should be removed (interval is derived)
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 0
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"""Repair and retention tests (issue #74).
|
||||||
|
|
||||||
|
Tests the idempotent, safe repair of hour observations and day aggregates
|
||||||
|
from surviving raw samples, boundary anchor retention, and legacy summary
|
||||||
|
handling at actual precision.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
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.store import init_store
|
||||||
|
from fenris.repair import (
|
||||||
|
repair_derivation,
|
||||||
|
is_repair_in_progress,
|
||||||
|
get_repair_status,
|
||||||
|
)
|
||||||
|
from fenris.pruning import prune_old_samples, needs_boundary_anchor
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store_conn(tmp_path: Path):
|
||||||
|
"""Create a fresh store for each test."""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
conn = init_store(db_path)
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_sample(conn, ts_iso, bytes_written, bytes_read=0, power_on_hours=100,
|
||||||
|
device="/dev/nvme0", segment_id=None):
|
||||||
|
"""Insert a raw sample."""
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO samples
|
||||||
|
(ts, device, bytes_written, bytes_read, power_on_hours,
|
||||||
|
data_units_written, data_units_read, segment_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(ts_iso, device, bytes_written, bytes_read, power_on_hours,
|
||||||
|
bytes_written // 512000, bytes_read // 512000, segment_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_hour(conn, hour_iso, bytes_written_delta=0, active_seconds=3600,
|
||||||
|
idle_seconds=0, powered_off_seconds=0, unknown_seconds=0,
|
||||||
|
sample_count=1, coverage=1.0):
|
||||||
|
"""Insert an hour observation."""
|
||||||
|
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_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||||
|
bytes_written_delta, 0, sample_count, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_day_aggregate(conn, day, bytes_written_delta=0, coverage=1.0):
|
||||||
|
"""Insert a day aggregate."""
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO day_aggregates
|
||||||
|
(day, active_seconds, bytes_written_delta, coverage, sample_count)
|
||||||
|
VALUES (?, 3600, ?, ?, 1)""",
|
||||||
|
(day, bytes_written_delta, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start_iso, end_iso=None, end_cause=None):
|
||||||
|
"""Insert a monitoring period."""
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO monitoring_periods (started_at, ended_at, end_cause)
|
||||||
|
VALUES (?, ?, ?)""",
|
||||||
|
(start_iso, end_iso, end_cause),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC1: Normal collection, legacy import and recovery use same evidence rules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRepairUsesSameEvidenceRules:
|
||||||
|
"""AC1: Repair derives only surviving supported evidence, transactionally
|
||||||
|
and idempotently."""
|
||||||
|
|
||||||
|
def test_repair_idempotent_on_empty_store(self, store_conn):
|
||||||
|
"""Repair on empty store succeeds and does nothing."""
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
assert result.ok is True
|
||||||
|
assert result.hours_created == 0
|
||||||
|
assert result.days_created == 0
|
||||||
|
|
||||||
|
def test_repair_idempotent_on_fully_derived(self, store_conn):
|
||||||
|
"""Repair on store with existing derived data does not duplicate."""
|
||||||
|
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||||
|
|
||||||
|
# Insert samples that span an hour
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Pre-existing hour observation for the 10:00 hour
|
||||||
|
# This hour observation captures the interval [10:00, 10:30]
|
||||||
|
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
|
||||||
|
bytes_written_delta=1000000)
|
||||||
|
_insert_day_aggregate(store_conn, "2026-09-14",
|
||||||
|
bytes_written_delta=1000000)
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
|
||||||
|
# Should not create new observations (already exist)
|
||||||
|
assert result.hours_created == 0
|
||||||
|
assert result.days_created == 0
|
||||||
|
|
||||||
|
# Verify no duplicates
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM hour_observations WHERE hour = '2026-09-14T10:00:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_repair_preserves_import_markers(self, store_conn):
|
||||||
|
"""Repair does not remove legacy import markers."""
|
||||||
|
# Set legacy import marker
|
||||||
|
store_conn.execute(
|
||||||
|
"INSERT INTO store_metadata (key, value) VALUES ('legacy_imported', 'true')"
|
||||||
|
)
|
||||||
|
store_conn.commit()
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
|
||||||
|
# Verify marker preserved
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'legacy_imported'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == "true"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC2: Rerunning or interrupting repair produces no duplicates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRepairIdempotency:
|
||||||
|
"""AC2: No duplicated intervals or totals from repeated repair."""
|
||||||
|
|
||||||
|
def test_repair_no_duplicate_hours(self, store_conn):
|
||||||
|
"""Running repair twice produces no duplicate hour observations."""
|
||||||
|
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# First repair
|
||||||
|
result1 = repair_derivation(store_conn)
|
||||||
|
assert result1.hours_created == 1
|
||||||
|
|
||||||
|
# Second repair
|
||||||
|
result2 = repair_derivation(store_conn)
|
||||||
|
assert result2.hours_created == 0 # No new hours
|
||||||
|
|
||||||
|
# Verify only one hour observation
|
||||||
|
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_repair_no_duplicate_days(self, store_conn):
|
||||||
|
"""Running repair twice produces no duplicate day aggregates."""
|
||||||
|
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# First repair
|
||||||
|
result1 = repair_derivation(store_conn)
|
||||||
|
assert result1.days_created == 1
|
||||||
|
|
||||||
|
# Second repair
|
||||||
|
result2 = repair_derivation(store_conn)
|
||||||
|
assert result2.days_created == 0 # No new days
|
||||||
|
|
||||||
|
# Verify only one day aggregate
|
||||||
|
cursor = store_conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_interrupted_repair_preserves_evidence(self, store_conn):
|
||||||
|
"""If repair fails, prior valid history is preserved."""
|
||||||
|
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||||
|
|
||||||
|
# Insert valid existing data
|
||||||
|
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
|
||||||
|
bytes_written_delta=500000)
|
||||||
|
_insert_day_aggregate(store_conn, "2026-09-14",
|
||||||
|
bytes_written_delta=500000)
|
||||||
|
|
||||||
|
# Run repair (should succeed but not modify existing valid data)
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
assert result.ok is True
|
||||||
|
|
||||||
|
# Verify existing data preserved
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations "
|
||||||
|
"WHERE hour = '2026-09-14T10:00:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 500000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC3: Boundary anchor retention
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBoundaryAnchorRetention:
|
||||||
|
"""AC3: Prune samples only after durable derivation; retain anchors."""
|
||||||
|
|
||||||
|
def test_needs_boundary_anchor_sample(self, store_conn):
|
||||||
|
"""Sample before 14-day boundary is needed for derivation."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Sample just before 14-day boundary (2026-09-15T23:55:00)
|
||||||
|
# is 14 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
|
||||||
|
_insert_sample(store_conn, "2026-09-15T23:55:00+00:00", 1000000)
|
||||||
|
|
||||||
|
# Sample just after boundary (2026-09-16T12:30:00)
|
||||||
|
# is 13 days and 23.5 hours old (within retention)
|
||||||
|
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# The sample at 2026-09-15 is a boundary anchor because
|
||||||
|
# the interval spans the retention boundary
|
||||||
|
assert needs_boundary_anchor(store_conn, "2026-09-15T23:55:00+00:00", now)
|
||||||
|
|
||||||
|
def test_not_boundary_anchor_if_fully_derived(self, store_conn):
|
||||||
|
"""Sample that's fully derived is not a boundary anchor."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Insert sample and fully derive its interval
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Hour observation already exists for this interval
|
||||||
|
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
|
||||||
|
bytes_written_delta=1000000)
|
||||||
|
|
||||||
|
# Not a boundary anchor
|
||||||
|
assert not needs_boundary_anchor(store_conn, "2026-09-14T10:00:00+00:00", now)
|
||||||
|
|
||||||
|
def test_pruning_retains_boundary_anchors(self, store_conn):
|
||||||
|
"""Pruning keeps samples needed as boundary anchors."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old sample before boundary (2026-09-14T23:55:00)
|
||||||
|
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
|
||||||
|
|
||||||
|
# Sample after boundary (2026-09-16T12:30:00)
|
||||||
|
# is 13 days and 23.5 hours old (within retention)
|
||||||
|
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Recent sample
|
||||||
|
_insert_sample(store_conn, "2026-09-29T12:00:00+00:00", 3000000)
|
||||||
|
|
||||||
|
# Run pruning
|
||||||
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# The boundary anchor should be retained
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_pruning_removes_old_sample_with_derived_interval(self, store_conn):
|
||||||
|
"""Pruning removes old samples when interval is fully derived."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old sample with derived interval
|
||||||
|
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Hour observation exists for the interval
|
||||||
|
_insert_hour(store_conn, "2026-09-10T10:00:00+00:00",
|
||||||
|
bytes_written_delta=1000000)
|
||||||
|
|
||||||
|
# Run pruning
|
||||||
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# Old sample should be removed (interval is derived)
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC4: Legacy day-only summaries retain actual precision
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLegacySummaryPrecision:
|
||||||
|
"""AC4: Legacy summaries at actual precision, no interpolation."""
|
||||||
|
|
||||||
|
def test_legacy_summary_not_reconstructed(self, store_conn):
|
||||||
|
"""Legacy day-only summaries are not interpolated to hour detail."""
|
||||||
|
# Insert a legacy-style day aggregate without hour observations
|
||||||
|
_insert_day_aggregate(store_conn, "2026-08-01",
|
||||||
|
bytes_written_delta=5000000)
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
|
||||||
|
# Should not create hour observations for legacy day
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-08-01%'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 0
|
||||||
|
|
||||||
|
def test_legacy_summary_no_double_counting(self, store_conn):
|
||||||
|
"""Legacy summaries and derived intervals don't double-count."""
|
||||||
|
# Insert legacy day aggregate
|
||||||
|
_insert_day_aggregate(store_conn, "2026-08-01",
|
||||||
|
bytes_written_delta=5000000)
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
|
||||||
|
# Day aggregate should not be modified
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 5000000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC5: Status distinguishes evidence states
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestStatusEvidenceDistingushing:
|
||||||
|
"""AC5: Read-only views distinguish evidence states."""
|
||||||
|
|
||||||
|
def test_repair_status_available(self, store_conn):
|
||||||
|
"""Repair status is available for read-only views."""
|
||||||
|
status = get_repair_status(store_conn)
|
||||||
|
assert hasattr(status, 'last_repair')
|
||||||
|
assert hasattr(status, 'repair_in_progress')
|
||||||
|
assert hasattr(status, 'hours_derived')
|
||||||
|
assert hasattr(status, 'days_derived')
|
||||||
|
|
||||||
|
def test_repair_in_progress_flag(self, store_conn):
|
||||||
|
"""Repair in progress flag is trackable."""
|
||||||
|
assert not is_repair_in_progress(store_conn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC6: Migration then collection then reader consumption
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMigrationCollectionReader:
|
||||||
|
"""AC6: End-to-end migration, collection, and reader consumption."""
|
||||||
|
|
||||||
|
def test_store_with_raw_evidence_and_summaries(self, store_conn):
|
||||||
|
"""Store with raw evidence and old summaries works correctly."""
|
||||||
|
# Set up store with mixed data
|
||||||
|
_open_period(store_conn, "2026-08-01T00:00:00+00:00")
|
||||||
|
|
||||||
|
# Old day-only summary (legacy)
|
||||||
|
_insert_day_aggregate(store_conn, "2026-08-01",
|
||||||
|
bytes_written_delta=5000000)
|
||||||
|
|
||||||
|
# Recent raw samples
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
assert result.ok is True
|
||||||
|
|
||||||
|
# Verify legacy summary preserved
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 5000000
|
||||||
|
|
||||||
|
# Verify new hour observation created
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-09-14%'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_concurrent_reader_consistency(self, store_conn):
|
||||||
|
"""Reader sees consistent snapshot during repair."""
|
||||||
|
# This is more of a documentation test - SQLite WAL mode handles this
|
||||||
|
# We verify the store is in WAL mode
|
||||||
|
cursor = store_conn.execute("PRAGMA journal_mode")
|
||||||
|
assert cursor.fetchone()[0] == "wal"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -118,12 +118,12 @@ class TestMigrateToLatest:
|
|||||||
assert migrate_to_latest(db) == 0
|
assert migrate_to_latest(db) == 0
|
||||||
|
|
||||||
def test_migrates_intermediate_version(self, tmp_path):
|
def test_migrates_intermediate_version(self, tmp_path):
|
||||||
"""Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current)."""
|
"""Store at version SCHEMA_VERSION-1 → 1 step to current."""
|
||||||
|
from fenris.store import SCHEMA_VERSION
|
||||||
db = tmp_path / "observations.db"
|
db = tmp_path / "observations.db"
|
||||||
_make_store(db, version=1)
|
_make_store(db, version=SCHEMA_VERSION - 1)
|
||||||
# SCHEMA_VERSION is 1, so version 1 is current
|
|
||||||
steps = migrate_to_latest(db)
|
steps = migrate_to_latest(db)
|
||||||
assert steps == 0
|
assert steps == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Tests for Fenris theme presets (issue #80).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Three valid presets: Amber, Nord, High Contrast
|
||||||
|
- Amber is the default with amber graph role
|
||||||
|
- Theme roles for graph rendering (allocated, unallocated, gap, zero, partial)
|
||||||
|
- Status semantic colours/glyphs/text always win over theme
|
||||||
|
- Global action reachability and focus contrast in every preset
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.themes import (
|
||||||
|
THEMES,
|
||||||
|
THEME_NAMES,
|
||||||
|
get_theme,
|
||||||
|
get_graph_colors,
|
||||||
|
STATUS_COLORS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Theme registry tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestThemeRegistry:
|
||||||
|
"""All three presets are registered with correct names."""
|
||||||
|
|
||||||
|
def test_three_themes_registered(self):
|
||||||
|
"""Exactly three themes exist."""
|
||||||
|
assert len(THEMES) == 3
|
||||||
|
|
||||||
|
def test_theme_names(self):
|
||||||
|
"""Theme names are amber, nord, high_contrast."""
|
||||||
|
assert THEME_NAMES == {"amber", "nord", "high_contrast"}
|
||||||
|
|
||||||
|
def test_get_theme_valid(self):
|
||||||
|
"""get_theme returns a Theme for each valid name."""
|
||||||
|
from textual.theme import Theme
|
||||||
|
for name in THEME_NAMES:
|
||||||
|
theme = get_theme(name)
|
||||||
|
assert isinstance(theme, Theme)
|
||||||
|
|
||||||
|
def test_get_theme_invalid_returns_amber(self):
|
||||||
|
"""Unknown theme name returns the amber theme."""
|
||||||
|
theme = get_theme("nonexistent")
|
||||||
|
assert theme.name == "fenris-amber"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Amber theme tests (default, amber graph role)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAmberTheme:
|
||||||
|
"""Amber is the default theme with warm tones."""
|
||||||
|
|
||||||
|
def test_amber_is_dark(self):
|
||||||
|
"""Amber is a dark theme."""
|
||||||
|
theme = get_theme("amber")
|
||||||
|
assert theme.dark is True
|
||||||
|
|
||||||
|
def test_amber_primary_is_amber(self):
|
||||||
|
"""Primary colour is warm amber."""
|
||||||
|
theme = get_theme("amber")
|
||||||
|
assert "d4a017" in theme.primary.lower() or "amber" in theme.primary.lower()
|
||||||
|
|
||||||
|
def test_amber_has_graph_colors(self):
|
||||||
|
"""Amber defines all required graph role variables."""
|
||||||
|
colors = get_graph_colors("amber")
|
||||||
|
assert "allocated" in colors
|
||||||
|
assert "unallocated" in colors
|
||||||
|
assert "gap" in colors
|
||||||
|
assert "zero" in colors
|
||||||
|
assert "partial" in colors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nord theme tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestNordTheme:
|
||||||
|
"""Nord uses the polar night palette."""
|
||||||
|
|
||||||
|
def test_nord_is_dark(self):
|
||||||
|
"""Nord is a dark theme."""
|
||||||
|
theme = get_theme("nord")
|
||||||
|
assert theme.dark is True
|
||||||
|
|
||||||
|
def test_nord_has_graph_colors(self):
|
||||||
|
"""Nord defines all required graph role variables."""
|
||||||
|
colors = get_graph_colors("nord")
|
||||||
|
assert "allocated" in colors
|
||||||
|
assert "unallocated" in colors
|
||||||
|
assert "gap" in colors
|
||||||
|
assert "zero" in colors
|
||||||
|
assert "partial" in colors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# High Contrast theme tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestHighContrastTheme:
|
||||||
|
"""High Contrast for maximum readability."""
|
||||||
|
|
||||||
|
def test_high_contrast_is_dark(self):
|
||||||
|
"""High contrast is a dark theme."""
|
||||||
|
theme = get_theme("high_contrast")
|
||||||
|
assert theme.dark is True
|
||||||
|
|
||||||
|
def test_high_contrast_foreground_is_white(self):
|
||||||
|
"""Foreground is pure white for maximum contrast."""
|
||||||
|
theme = get_theme("high_contract") if False else get_theme("high_contrast")
|
||||||
|
assert theme.foreground is not None
|
||||||
|
|
||||||
|
def test_high_contrast_has_graph_colors(self):
|
||||||
|
"""High contrast defines all required graph role variables."""
|
||||||
|
colors = get_graph_colors("high_contrast")
|
||||||
|
assert "allocated" in colors
|
||||||
|
assert "unallocated" in colors
|
||||||
|
assert "gap" in colors
|
||||||
|
assert "zero" in colors
|
||||||
|
assert "partial" in colors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status semantic colours override theme (AC80-1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestStatusSemanticOverride:
|
||||||
|
"""Status semantic colours/glyphs/text always win over theme styling."""
|
||||||
|
|
||||||
|
def test_status_colors_defined(self):
|
||||||
|
"""STATUS_COLORS maps each StatusState to a fixed colour."""
|
||||||
|
from fenris.status_composition import StatusState
|
||||||
|
for state in StatusState:
|
||||||
|
assert state.value in STATUS_COLORS
|
||||||
|
|
||||||
|
def test_status_colors_are_not_theme_dependent(self):
|
||||||
|
"""Status colours are the same regardless of theme."""
|
||||||
|
from fenris.status_composition import StatusState
|
||||||
|
# These are the canonical status colours — they must not change with theme
|
||||||
|
assert STATUS_COLORS[StatusState.MONITORING.value] == "green"
|
||||||
|
assert STATUS_COLORS[StatusState.ERROR.value] == "red"
|
||||||
|
assert STATUS_COLORS[StatusState.PAUSED.value] == "yellow"
|
||||||
|
assert STATUS_COLORS[StatusState.INTERRUPTED.value] == "red"
|
||||||
|
assert STATUS_COLORS[StatusState.STALE.value] == "red"
|
||||||
|
assert STATUS_COLORS[StatusState.WAITING.value] == "yellow"
|
||||||
|
assert STATUS_COLORS[StatusState.UNKNOWN.value] == "dim"
|
||||||
|
assert STATUS_COLORS[StatusState.COLLECTING.value] == "green"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Graph colour role tests (AC80-5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGraphColorRoles:
|
||||||
|
"""Theme roles for graph rendering expose distinct colours per preset."""
|
||||||
|
|
||||||
|
def test_all_themes_define_same_roles(self):
|
||||||
|
"""Every theme has the same set of graph colour roles."""
|
||||||
|
roles = None
|
||||||
|
for name in THEME_NAMES:
|
||||||
|
colors = get_graph_colors(name)
|
||||||
|
if roles is None:
|
||||||
|
roles = set(colors.keys())
|
||||||
|
else:
|
||||||
|
assert set(colors.keys()) == roles
|
||||||
|
|
||||||
|
def test_graph_roles_are_distinct_across_presets(self):
|
||||||
|
"""Different themes produce different graph colour values."""
|
||||||
|
amber = get_graph_colors("amber")
|
||||||
|
nord = get_graph_colors("nord")
|
||||||
|
hc = get_graph_colors("high_contrast")
|
||||||
|
# At least one role should differ between themes
|
||||||
|
assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"]
|
||||||
|
|
||||||
|
def test_zero_role_is_dim(self):
|
||||||
|
"""Zero-usage bars use a dim/subtle colour in all themes."""
|
||||||
|
for name in THEME_NAMES:
|
||||||
|
colors = get_graph_colors(name)
|
||||||
|
# Zero should be distinct from allocated
|
||||||
|
assert colors["zero"] != colors["allocated"]
|
||||||
+703
-6
@@ -40,12 +40,25 @@ from fenris.status import (
|
|||||||
)
|
)
|
||||||
from fenris.tui import (
|
from fenris.tui import (
|
||||||
FenrisTuiApp,
|
FenrisTuiApp,
|
||||||
|
DailyBarGraph,
|
||||||
_format_remaining,
|
_format_remaining,
|
||||||
_sparkline,
|
_sparkline,
|
||||||
_habit_bar,
|
_habit_bar,
|
||||||
_query_usage_history,
|
_query_usage_history,
|
||||||
_query_drive_health,
|
_query_drive_health,
|
||||||
_query_service_facts,
|
_query_service_facts,
|
||||||
|
_query_daily_graph_data,
|
||||||
|
_query_hourly_graph_data,
|
||||||
|
_RANGE_OPTIONS,
|
||||||
|
_RANGE_DEFAULT,
|
||||||
|
_BAR_HEIGHT,
|
||||||
|
_GLYPH_ALLOCATED,
|
||||||
|
_GLYPH_UNALLOCATED,
|
||||||
|
_GLYPH_GAP,
|
||||||
|
_GLYPH_ZERO,
|
||||||
|
_GLYPH_PARTIAL,
|
||||||
|
_MIN_WIDTH,
|
||||||
|
_MIN_HEIGHT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -368,7 +381,10 @@ class TestDenseScreen:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_service_strip_has_four_facts(self, tmp_path):
|
async def test_service_strip_has_four_facts(self, tmp_path):
|
||||||
"""Service strip has four separate facts (boot, timer, collect, freshness)."""
|
"""Service strip has four separate facts (boot, timer, collect, freshness).
|
||||||
|
|
||||||
|
Maker credit removed from service strip (issue #79: single placement in title).
|
||||||
|
"""
|
||||||
conn = init_store(tmp_path / "test.db")
|
conn = init_store(tmp_path / "test.db")
|
||||||
_insert_segment(conn)
|
_insert_segment(conn)
|
||||||
_open_period(conn)
|
_open_period(conn)
|
||||||
@@ -385,7 +401,8 @@ class TestDenseScreen:
|
|||||||
assert "timer:" in strip
|
assert "timer:" in strip
|
||||||
assert "last collect:" in strip
|
assert "last collect:" in strip
|
||||||
assert "freshness:" in strip
|
assert "freshness:" in strip
|
||||||
assert "by Bongbetic" in strip
|
# Maker credit now in titlebox only (issue #79)
|
||||||
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
|
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
|
||||||
@@ -408,7 +425,7 @@ class TestDenseScreen:
|
|||||||
quit_rail = app.query_one("#quit-rail")
|
quit_rail = app.query_one("#quit-rail")
|
||||||
assert "continuity" in strip
|
assert "continuity" in strip
|
||||||
assert "monitoring: active in background · persists across reboots" in strip
|
assert "monitoring: active in background · persists across reboots" in strip
|
||||||
assert "p pause · r resume · c collect · d disclosures" in strip
|
assert "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip
|
||||||
assert "q quit" not in strip
|
assert "q quit" not in strip
|
||||||
assert rail == "q QUIT TUI"
|
assert rail == "q QUIT TUI"
|
||||||
assert usage.region.y < service.region.y < quit_rail.region.y
|
assert usage.region.y < service.region.y < quit_rail.region.y
|
||||||
@@ -514,7 +531,10 @@ class TestDenseScreen:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_branding_and_one_time_auth_banner(self, tmp_path):
|
async def test_branding_and_one_time_auth_banner(self, tmp_path):
|
||||||
"""Identity is visible at launch; auth notice clears once per session."""
|
"""Identity is visible at launch; auth notice clears once per session.
|
||||||
|
|
||||||
|
Maker credit in titlebox only (issue #79: Fenris by Bongbetic).
|
||||||
|
"""
|
||||||
app = FenrisTuiApp(
|
app = FenrisTuiApp(
|
||||||
store_path=tmp_path / "nonexistent.db",
|
store_path=tmp_path / "nonexistent.db",
|
||||||
refresh_interval_s=0.2,
|
refresh_interval_s=0.2,
|
||||||
@@ -523,9 +543,11 @@ class TestDenseScreen:
|
|||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
assert "Fenris — NVMe endurance monitor" in headline
|
# Identity now shows Fenris by Bongbetic (issue #79)
|
||||||
|
assert "Fenris by Bongbetic" in headline
|
||||||
assert auth_notice in headline
|
assert auth_notice in headline
|
||||||
assert "by Bongbetic" in str(app.query_one("#service-strip").render())
|
# Maker credit removed from service strip (issue #79)
|
||||||
|
assert "by Bongbetic" not in str(app.query_one("#service-strip").render())
|
||||||
|
|
||||||
await pilot.pause(0.25)
|
await pilot.pause(0.25)
|
||||||
assert auth_notice not in str(app.query_one("#headline-band").render())
|
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||||||
@@ -679,3 +701,678 @@ class TestStateMatrixCombinations:
|
|||||||
# Incomplete provenance → UNVERIFIED tier
|
# Incomplete provenance → UNVERIFIED tier
|
||||||
assert proj.baseline_tier.value == "unverified_override"
|
assert proj.baseline_tier.value == "unverified_override"
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hour observation helper for graph tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _insert_hour(conn, hour, bw=0, active=3600, idle=0, powered_off=0,
|
||||||
|
unknown=0, coverage=1.0, samples=1):
|
||||||
|
"""Insert an hour observation row."""
|
||||||
|
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 (?, ?, ?, ?, ?, ?, 0, ?, ?)",
|
||||||
|
(hour, active, idle, powered_off, unknown, bw, samples, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_day_with_unattributed(conn, day, bw=0, unattributed=0,
|
||||||
|
coverage=0.95, samples=24):
|
||||||
|
"""Insert a day aggregate with explicit unattributed bytes."""
|
||||||
|
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, "
|
||||||
|
" unattributed_bytes_written, unattributed_bytes_read) "
|
||||||
|
"VALUES (?, 3600, 0, 0, 0, ?, 0, ?, ?, ?, 0)",
|
||||||
|
(day, bw, samples, coverage, unattributed),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Graph data query tests (issue #75)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestQueryDailyGraphData:
|
||||||
|
def test_empty_store(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
result = _query_daily_graph_data(conn)
|
||||||
|
assert result == []
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_with_days(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
result = _query_daily_graph_data(conn)
|
||||||
|
assert len(result) == 14
|
||||||
|
assert result[0]["total_bytes"] == 1024*1024*100
|
||||||
|
assert result[0]["allocated_bytes"] == 1024*1024*100
|
||||||
|
assert result[0]["unallocated_bytes"] == 0
|
||||||
|
assert result[0]["is_zero"] is False
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_zero_day(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
_insert_day(conn, "2026-09-20", bw=0, coverage=0.95, samples=24)
|
||||||
|
result = _query_daily_graph_data(conn)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["is_zero"] is True
|
||||||
|
assert result[0]["total_bytes"] == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_unattributed_bytes(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
_insert_day_with_unattributed(
|
||||||
|
conn, "2026-09-20", bw=500, unattributed=300
|
||||||
|
)
|
||||||
|
result = _query_daily_graph_data(conn)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["allocated_bytes"] == 500
|
||||||
|
assert result[0]["unallocated_bytes"] == 300
|
||||||
|
assert result[0]["total_bytes"] == 800
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_gap_day(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
# Day with no hours but unknown seconds (gap in monitoring period)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates "
|
||||||
|
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||||
|
" bytes_written_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, 0, 0, 0, 86400, 0, 0, 0.0)",
|
||||||
|
("2026-09-20",),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
result = _query_daily_graph_data(conn)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["is_gap"] is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueryHourlyGraphData:
|
||||||
|
def test_empty_day(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||||
|
assert result == []
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_with_hours(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
for h in range(24):
|
||||||
|
hour = "2026-09-20T%02d:00:00+00:00" % h
|
||||||
|
bw = 1024*1024*100 if h in (10, 14) else 0
|
||||||
|
_insert_hour(conn, hour, bw=bw)
|
||||||
|
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||||
|
assert len(result) == 24
|
||||||
|
assert result[10]["bytes_written"] == 1024*1024*100
|
||||||
|
assert result[10]["is_zero"] is False
|
||||||
|
assert result[0]["is_zero"] is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_hour_labels(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
|
||||||
|
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["local_label"] == "12"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DailyBarGraph widget unit tests (issue #75)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDailyBarGraph:
|
||||||
|
def test_empty_data(self, tmp_path):
|
||||||
|
"""Empty data shows awaiting message."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
# We test the widget directly via the app's compose
|
||||||
|
graph = DailyBarGraph()
|
||||||
|
# Simulate setting empty data
|
||||||
|
from textual.app import App as TextualApp
|
||||||
|
class _TestApp(TextualApp):
|
||||||
|
def compose(self):
|
||||||
|
yield graph
|
||||||
|
# We can't easily test widget lifecycle outside an app, so test the data
|
||||||
|
assert graph._all_day_data == []
|
||||||
|
assert graph.range_days == _RANGE_DEFAULT
|
||||||
|
|
||||||
|
def test_range_default(self):
|
||||||
|
"""Default range is 14 days."""
|
||||||
|
graph = DailyBarGraph()
|
||||||
|
assert graph.range_days == _RANGE_DEFAULT
|
||||||
|
assert graph.selected_index == -1
|
||||||
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
|
def test_trim_to_range(self):
|
||||||
|
"""Data is trimmed to the selected range."""
|
||||||
|
graph = DailyBarGraph()
|
||||||
|
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
|
||||||
|
for d in range(1, 31)]
|
||||||
|
graph._all_day_data = data
|
||||||
|
graph.range_days = 7
|
||||||
|
graph._trim_to_range()
|
||||||
|
assert len(graph._day_data) == 7
|
||||||
|
assert graph._day_data[0]["day"] == "2026-09-24"
|
||||||
|
|
||||||
|
def test_range_all_fits(self):
|
||||||
|
"""When data fits within range, all days are shown."""
|
||||||
|
graph = DailyBarGraph()
|
||||||
|
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
|
||||||
|
for d in range(1, 8)]
|
||||||
|
graph._all_day_data = data
|
||||||
|
graph.range_days = 14
|
||||||
|
graph._trim_to_range()
|
||||||
|
assert len(graph._day_data) == 7
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bar graph headless TUI tests (issue #75)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBarGraphTUI:
|
||||||
|
"""Headless tests for the interactive bar graph."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_graph_renders_with_data(self, tmp_path):
|
||||||
|
"""Graph widget renders with day data."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
assert isinstance(graph, DailyBarGraph)
|
||||||
|
assert len(graph._day_data) > 0
|
||||||
|
# Legend should be visible
|
||||||
|
legend = str(app.query_one("#bar-legend").render())
|
||||||
|
assert "alloc" in legend
|
||||||
|
assert "zero" in legend
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_arrow_selection(self, tmp_path):
|
||||||
|
"""Left/right arrows move the selection."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
app.set_focus(graph)
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Right arrow selects first bar
|
||||||
|
await pilot.press("right")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.selected_index == 0
|
||||||
|
|
||||||
|
# Right again moves to second
|
||||||
|
await pilot.press("right")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.selected_index == 1
|
||||||
|
|
||||||
|
# Left moves back
|
||||||
|
await pilot.press("left")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.selected_index == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_range_switching(self, tmp_path):
|
||||||
|
"""1-4 keys switch the visible range."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
app.set_focus(graph)
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Default is 14
|
||||||
|
assert graph.range_days == 14
|
||||||
|
assert len(graph._day_data) == 14
|
||||||
|
|
||||||
|
# Press 1 for 7-day range
|
||||||
|
await pilot.press("1")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.range_days == 7
|
||||||
|
assert len(graph._day_data) == 7
|
||||||
|
|
||||||
|
# Press 3 for 28-day range
|
||||||
|
await pilot.press("3")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.range_days == 28
|
||||||
|
assert len(graph._day_data) == 28
|
||||||
|
|
||||||
|
# Press 4 for 90-day range (only 30 days available)
|
||||||
|
await pilot.press("4")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.range_days == 90
|
||||||
|
assert len(graph._day_data) == 30
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_readout_updates_on_selection(self, tmp_path):
|
||||||
|
"""Readout shows selected day info."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
app.set_focus(graph)
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# No selection initially
|
||||||
|
readout = str(app.query_one("#bar-readout").render())
|
||||||
|
assert "select" in readout.lower()
|
||||||
|
|
||||||
|
# Select first bar
|
||||||
|
await pilot.press("right")
|
||||||
|
await pilot.pause()
|
||||||
|
readout = str(app.query_one("#bar-readout").render())
|
||||||
|
assert "2026-09" in readout
|
||||||
|
assert "GB" in readout
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hourly_drill_down_and_back(self, tmp_path):
|
||||||
|
"""Enter drills into hourly view, Esc returns to daily."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
# Insert hours for each day
|
||||||
|
for h in range(24):
|
||||||
|
hour = "%sT%02d:00:00+00:00" % (d, h)
|
||||||
|
bw_h = 1024*1024*10 if h == 12 else 0
|
||||||
|
_insert_hour(conn, hour, bw=bw_h)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
app.set_focus(graph)
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Select a day
|
||||||
|
await pilot.press("right")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.selected_index == 0
|
||||||
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
|
# Enter drill-down
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
assert graph.drill_day is not None
|
||||||
|
assert len(graph._hour_data) == 24
|
||||||
|
|
||||||
|
# Esc returns to daily
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.view_mode == "daily"
|
||||||
|
assert graph.drill_day is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_store_graph(self, tmp_path):
|
||||||
|
"""Empty store shows awaiting message in graph."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
assert isinstance(graph, DailyBarGraph)
|
||||||
|
render = str(app.query_one("#bar-render").render())
|
||||||
|
assert "awaiting" in render.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_graph_border_title(self, tmp_path):
|
||||||
|
"""Graph pane has a border title."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
assert graph.border_title == "usage history"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_glyphs_in_legend(self, tmp_path):
|
||||||
|
"""Legend shows all required glyphs."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(7):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
legend = str(app.query_one("#bar-legend").render())
|
||||||
|
assert _GLYPH_ALLOCATED in legend
|
||||||
|
assert _GLYPH_UNALLOCATED in legend
|
||||||
|
assert _GLYPH_GAP in legend
|
||||||
|
assert _GLYPH_ZERO in legend
|
||||||
|
assert _GLYPH_PARTIAL in legend
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Issue #81: Constrained size layout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConstrainedLayout:
|
||||||
|
"""Tests for constrained terminal size behavior (issue #81)."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_at_80x24(self, tmp_path):
|
||||||
|
"""At 80×24 the dashboard renders normally with all regions visible."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
# All four regions should be visible
|
||||||
|
assert app.query_one("#headline-band") is not None
|
||||||
|
assert app.query_one("#usage-history") is not None
|
||||||
|
assert app.query_one("#drive-health") is not None
|
||||||
|
assert app.query_one("#service-strip") is not None
|
||||||
|
# Graph should not be in constrained mode
|
||||||
|
assert not app._is_constrained_mode
|
||||||
|
# Constrained summary should be hidden
|
||||||
|
summary = app.query_one("#constrained-summary")
|
||||||
|
assert str(summary.styles.display) == "none"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_below_80_width(self, tmp_path):
|
||||||
|
"""Below 80 columns the graph is hidden and textual summary shown."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(79, 24)) as pilot:
|
||||||
|
# Should be in constrained mode
|
||||||
|
assert app._is_constrained_mode
|
||||||
|
# Main grid should have constrained class
|
||||||
|
main_grid = app.query_one("#main-grid")
|
||||||
|
assert main_grid.has_class("constrained")
|
||||||
|
# Constrained summary should be visible
|
||||||
|
summary = app.query_one("#constrained-summary")
|
||||||
|
summary_text = str(summary.render())
|
||||||
|
assert "graph needs ≥80×24" in summary_text
|
||||||
|
assert "days" in summary_text
|
||||||
|
assert "GB total" in summary_text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_below_24_height(self, tmp_path):
|
||||||
|
"""Below 24 rows the graph is hidden and textual summary shown."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(80, 23)) as pilot:
|
||||||
|
# Should be in constrained mode
|
||||||
|
assert app._is_constrained_mode
|
||||||
|
# Constrained summary should be visible
|
||||||
|
summary = app.query_one("#constrained-summary")
|
||||||
|
summary_text = str(summary.render())
|
||||||
|
assert "graph needs ≥80×24" in summary_text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resize_from_constrained_to_normal(self, tmp_path):
|
||||||
|
"""Resizing from constrained to normal restores graph and coherent state."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(79, 24)) as pilot:
|
||||||
|
# Start in constrained mode
|
||||||
|
assert app._is_constrained_mode
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
# Select a day while constrained
|
||||||
|
graph.selected_index = 5
|
||||||
|
|
||||||
|
# Resize to normal — pilot.resize_terminal changes terminal size
|
||||||
|
await pilot.resize_terminal(80, 24)
|
||||||
|
await pilot.pause()
|
||||||
|
# Trigger refresh so constrained state updates (timer would do this)
|
||||||
|
app._refresh()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should no longer be constrained
|
||||||
|
assert not app._is_constrained_mode
|
||||||
|
# Main grid should not have constrained class
|
||||||
|
main_grid = app.query_one("#main-grid")
|
||||||
|
assert not main_grid.has_class("constrained")
|
||||||
|
# Selection should be preserved
|
||||||
|
assert graph.selected_index == 5
|
||||||
|
# Graph should have data
|
||||||
|
assert len(graph._day_data) > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resize_from_normal_to_constrained(self, tmp_path):
|
||||||
|
"""Resizing from normal to constrained hides graph and shows summary."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
# Start in normal mode
|
||||||
|
assert not app._is_constrained_mode
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
# Focus and select a day
|
||||||
|
app.set_focus(graph)
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("right")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.selected_index == 0
|
||||||
|
|
||||||
|
# Resize to constrained — pilot.resize_terminal changes terminal size
|
||||||
|
await pilot.resize_terminal(79, 24)
|
||||||
|
await pilot.pause()
|
||||||
|
# Trigger refresh so constrained state updates (timer would do this)
|
||||||
|
app._refresh()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should be in constrained mode
|
||||||
|
assert app._is_constrained_mode
|
||||||
|
# Constrained summary should show selected day context
|
||||||
|
summary = app.query_one("#constrained-summary")
|
||||||
|
summary_text = str(summary.render())
|
||||||
|
assert "graph needs ≥80×24" in summary_text
|
||||||
|
# Selected day context should survive
|
||||||
|
assert "2026-09" in summary_text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_summary_empty_store(self, tmp_path):
|
||||||
|
"""Constrained summary shows awaiting message for empty store."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test(size=(79, 24)) as pilot:
|
||||||
|
assert app._is_constrained_mode
|
||||||
|
summary = app.query_one("#constrained-summary")
|
||||||
|
summary_text = str(summary.render())
|
||||||
|
assert "graph needs ≥80×24" in summary_text
|
||||||
|
assert "awaiting" in summary_text.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_all_regions_usable(self, tmp_path):
|
||||||
|
"""All regions remain usable in constrained mode."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(79, 24)) as pilot:
|
||||||
|
# All regions should exist
|
||||||
|
assert app.query_one("#headline-band") is not None
|
||||||
|
assert app.query_one("#drive-health") is not None
|
||||||
|
assert app.query_one("#service-strip") is not None
|
||||||
|
assert app.query_one("#quit-rail") is not None
|
||||||
|
|
||||||
|
# Headline should contain projection
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
||||||
|
|
||||||
|
# Service strip should have actions
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
assert "p pause" in strip
|
||||||
|
assert "r resume" in strip
|
||||||
|
assert "q quit" not in strip # Quit is in quit-rail
|
||||||
|
|
||||||
|
# Quit rail should be visible
|
||||||
|
rail = str(app.query_one("#quit-rail").render())
|
||||||
|
assert "QUIT" in rail
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_long_reasons_visible(self, tmp_path):
|
||||||
|
"""Long status reasons remain visible in constrained mode."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
# Insert stale data
|
||||||
|
stale_ts = (_clock() - timedelta(days=10)).isoformat()
|
||||||
|
_insert_sample(conn, stale_ts, pu=5)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(79, 24)) as pilot:
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
# Status should be visible even with long reasons
|
||||||
|
assert "stale" in strip.lower() or "freshness" in strip.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_keyboard_navigation(self, tmp_path):
|
||||||
|
"""Keyboard focus traversal works in constrained mode."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(79, 24)) as pilot:
|
||||||
|
# Quit should still work
|
||||||
|
await pilot.press("q")
|
||||||
|
assert not app.is_running
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_theme_and_motion_toggle(self, tmp_path):
|
||||||
|
"""Theme and motion toggles work in constrained mode."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test(size=(79, 24)) as pilot:
|
||||||
|
assert app._is_constrained_mode
|
||||||
|
# Toggle theme
|
||||||
|
await pilot.press("t")
|
||||||
|
await pilot.pause()
|
||||||
|
# Toggle motion
|
||||||
|
await pilot.press("m")
|
||||||
|
await pilot.pause()
|
||||||
|
# Should still be constrained
|
||||||
|
assert app._is_constrained_mode
|
||||||
|
|
||||||
|
def test_min_width_constant(self):
|
||||||
|
"""_MIN_WIDTH is 80 columns."""
|
||||||
|
assert _MIN_WIDTH == 80
|
||||||
|
|
||||||
|
def test_min_height_constant(self):
|
||||||
|
"""_MIN_HEIGHT is 24 rows."""
|
||||||
|
assert _MIN_HEIGHT == 24
|
||||||
|
|
||||||
|
def test_is_constrained_with_terminal_width(self, tmp_path):
|
||||||
|
"""DailyBarGraph._is_constrained checks terminal width."""
|
||||||
|
graph = DailyBarGraph()
|
||||||
|
# Widget-level check (no terminal width)
|
||||||
|
assert graph._is_constrained() is False
|
||||||
|
# Terminal width below minimum
|
||||||
|
assert graph._is_constrained(terminal_width=79) is True
|
||||||
|
# Terminal width at minimum
|
||||||
|
assert graph._is_constrained(terminal_width=80) is False
|
||||||
|
# Terminal width above minimum
|
||||||
|
assert graph._is_constrained(terminal_width=120) is False
|
||||||
|
|||||||
Reference in New Issue
Block a user