Compare commits
9
Commits
4f2f30abac
...
v0.3.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed61c4e1ec | ||
|
|
197e8ed02d | ||
|
|
79478653fa | ||
|
|
30122c5e6d | ||
|
|
01240ec8f0 | ||
|
|
7e270150bc | ||
|
|
3d71ebbc88 | ||
|
|
607dc83e55 | ||
|
|
d6fa94001c |
@@ -9,6 +9,16 @@ backfill releases from before this changelog.
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fenris"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
description = "NVMe wear monitor with persistent TUI"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
|
||||
@@ -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}
|
||||
@@ -92,6 +92,7 @@ class ProjectionResult:
|
||||
staleness_fact: Optional[str]
|
||||
degraded_identity_fact: Optional[str]
|
||||
zero_rate_fact: Optional[str]
|
||||
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -547,6 +548,16 @@ def compute_projection(conn, clock_now):
|
||||
days_below_coverage = sum(1 for d in segment_days
|
||||
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
|
||||
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:
|
||||
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
|
||||
facts.append(warming_fact)
|
||||
@@ -605,4 +616,5 @@ def compute_projection(conn, clock_now):
|
||||
staleness_fact=staleness_fact,
|
||||
degraded_identity_fact=degraded_identity_fact,
|
||||
zero_rate_fact=zero_rate_fact,
|
||||
qualifying_days_progress=qualifying_days_progress,
|
||||
)
|
||||
|
||||
+84
-2
@@ -379,8 +379,14 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
|
||||
# --- Confidence state + contributing facts (§6.7, §6.11) ---
|
||||
state_label = proj.confidence_state.value
|
||||
if proj.contributing_facts:
|
||||
facts_str = " · ".join(proj.contributing_facts)
|
||||
facts_list = list(proj.contributing_facts) if proj.contributing_facts else []
|
||||
|
||||
# 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))
|
||||
else:
|
||||
lines.append("%s evidence" % state_label)
|
||||
@@ -681,3 +687,79 @@ def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetim
|
||||
parts.append("")
|
||||
parts.append(format_disclosures())
|
||||
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)
|
||||
@@ -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"),
|
||||
}
|
||||
+230
-34
@@ -52,6 +52,15 @@ from .status import (
|
||||
read_config,
|
||||
)
|
||||
from .monitoring_periods import get_open_period
|
||||
from .status_composition import (
|
||||
StatusState,
|
||||
StatusComposition,
|
||||
compose_status,
|
||||
render_status_tui,
|
||||
STATUS_POLL_INTERVAL_S,
|
||||
)
|
||||
from .preferences import load_preferences, save_preferences
|
||||
from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -113,6 +122,26 @@ def _habit_bar(a: float, i: float, o: float, u: float, width: int = 40) -> str:
|
||||
return bar + "\n" + legend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity and wolf glyph detection (issue #79)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Wolf glyph for Fenris branding
|
||||
_WOLF_GLYPH = "\U0001f43a" # 🐺
|
||||
_IDENTITY_FULL = _WOLF_GLYPH + " Fenris by Bongbetic"
|
||||
_IDENTITY_FALLBACK = "Fenris by Bongbetic"
|
||||
|
||||
|
||||
def _can_render_wolf(width: int) -> bool:
|
||||
"""Determine if the wolf glyph can be rendered without tofu.
|
||||
|
||||
Uses a conservative heuristic:
|
||||
- Terminal width >= 80 columns (standard minimum)
|
||||
- Wolf glyph width is 1 column in most terminals
|
||||
"""
|
||||
return width >= 80
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bar graph glyphs and constants
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -129,6 +158,10 @@ _BAR_HEIGHT = 6
|
||||
_BAR_WIDTH = 2
|
||||
_BAR_SPACING = 1
|
||||
|
||||
# Terminal size thresholds for constrained layout (issue #81)
|
||||
_MIN_WIDTH = 80
|
||||
_MIN_HEIGHT = 24
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive daily bar graph widget
|
||||
@@ -240,17 +273,20 @@ class DailyBarGraph(Widget):
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
|
||||
def _is_constrained(self) -> bool:
|
||||
"""Return True when the widget is too narrow for the bar graph.
|
||||
def _is_constrained(self, terminal_width: int = 0) -> bool:
|
||||
"""Return True when the terminal or widget is too narrow for the bar graph.
|
||||
|
||||
At 80 columns with a 3fr:2fr grid split the pane content width is ~46
|
||||
(after round border + padding). Below 32 the bars become unreadable.
|
||||
When terminal_width is provided (issue #81), also check against _MIN_WIDTH.
|
||||
"""
|
||||
try:
|
||||
w = self.region.width
|
||||
if terminal_width > 0 and terminal_width < _MIN_WIDTH:
|
||||
return True
|
||||
return 0 < w < 32
|
||||
except Exception:
|
||||
return False
|
||||
return terminal_width > 0 and terminal_width < _MIN_WIDTH
|
||||
|
||||
def _show_constrained_summary(self) -> None:
|
||||
"""Textual fallback for terminals below 80×24."""
|
||||
@@ -886,7 +922,7 @@ class DisclosuresScreen(ModalScreen[None]):
|
||||
class FenrisTuiApp(App):
|
||||
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
|
||||
|
||||
TITLE = "Fenris — NVMe endurance monitor"
|
||||
TITLE = _IDENTITY_FULL
|
||||
SUB_TITLE = ""
|
||||
|
||||
CSS = """
|
||||
@@ -901,6 +937,28 @@ class FenrisTuiApp(App):
|
||||
grid-size: 2 5;
|
||||
grid-rows: 8 5 10 7 3;
|
||||
}
|
||||
#main-grid.constrained {
|
||||
grid-size: 1 4;
|
||||
grid-columns: 1fr;
|
||||
grid-rows: auto auto auto auto;
|
||||
}
|
||||
#main-grid.constrained.paused {
|
||||
grid-size: 1 5;
|
||||
grid-rows: auto auto auto auto auto;
|
||||
}
|
||||
#main-grid.constrained #usage-history {
|
||||
display: none;
|
||||
}
|
||||
#main-grid.constrained #constrained-summary {
|
||||
display: block;
|
||||
}
|
||||
#constrained-summary {
|
||||
display: none;
|
||||
border: round #555555;
|
||||
padding: 0 1;
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
}
|
||||
#dashboard-scroll { height: 1fr; }
|
||||
#headline-band { column-span: 2; }
|
||||
#paused-banner {
|
||||
@@ -927,6 +985,8 @@ class FenrisTuiApp(App):
|
||||
Binding("r", "resume", "Resume", show=False),
|
||||
Binding("c", "collect", "Collect Now", show=False),
|
||||
Binding("d", "disclose", "Disclosures", show=False),
|
||||
Binding("t", "toggle_theme", "Theme", show=False),
|
||||
Binding("m", "toggle_motion", "Motion", show=False),
|
||||
Binding("q", "quit", "Quit", show=False),
|
||||
]
|
||||
|
||||
@@ -938,6 +998,11 @@ class FenrisTuiApp(App):
|
||||
refresh_interval_s: float = CADENCE_DEFAULT_S,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
# Load user preferences before super().__init__ so theme is available
|
||||
self._prefs = load_preferences()
|
||||
self._current_theme_name = self._prefs["theme"]
|
||||
self._reduced_motion = self._prefs["reduced_motion"]
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
||||
self.config_path = config_path
|
||||
@@ -947,20 +1012,33 @@ class FenrisTuiApp(App):
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._clock_now = datetime.now(timezone.utc)
|
||||
|
||||
# Register all Fenris themes
|
||||
for theme in THEMES.values():
|
||||
self.register_theme(theme)
|
||||
|
||||
# Track constrained state for resize handling (issue #81)
|
||||
self._is_constrained_mode = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll(id="dashboard-scroll"):
|
||||
with Container(id="main-grid"):
|
||||
yield Static("", id="headline-band", classes="pane")
|
||||
yield Static("", id="paused-banner")
|
||||
yield DailyBarGraph(id="usage-history", classes="pane")
|
||||
yield Static("", id="constrained-summary", classes="pane")
|
||||
yield Static("", id="drive-health", classes="pane")
|
||||
yield Static("", id="service-strip", classes="pane")
|
||||
yield Static("q QUIT TUI", id="quit-rail")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set border titles and render initial state."""
|
||||
"""Set border titles, apply theme, and render initial state."""
|
||||
# Apply loaded theme (issue #80)
|
||||
# Preference uses underscores (high_contrast); Textual theme uses dashes (fenris-high-contrast)
|
||||
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
|
||||
|
||||
self.query_one("#headline-band").border_title = "headline"
|
||||
self.query_one("#usage-history").border_title = "usage history"
|
||||
self.query_one("#constrained-summary").border_title = "usage history"
|
||||
self.query_one("#drive-health").border_title = "drive"
|
||||
self.query_one("#service-strip").border_title = "service + actions"
|
||||
self._refresh_timer = self.set_interval(
|
||||
@@ -968,6 +1046,55 @@ class FenrisTuiApp(App):
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Handle terminal resize to switch between constrained and normal layout (issue #81)."""
|
||||
width, height = self.size
|
||||
was_constrained = self._is_constrained_mode
|
||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||
|
||||
main_grid = self.query_one("#main-grid")
|
||||
if self._is_constrained_mode:
|
||||
main_grid.add_class("constrained")
|
||||
else:
|
||||
main_grid.remove_class("constrained")
|
||||
|
||||
# Re-render constrained summary when entering constrained mode
|
||||
if self._is_constrained_mode and not was_constrained:
|
||||
self._update_constrained_summary()
|
||||
# Re-render full layout when leaving constrained mode
|
||||
elif not self._is_constrained_mode and was_constrained:
|
||||
self._refresh()
|
||||
|
||||
def _update_constrained_summary(self) -> None:
|
||||
"""Update the textual summary shown when terminal is too small for graph."""
|
||||
graph = self.query_one("#usage-history")
|
||||
summary = self.query_one("#constrained-summary")
|
||||
|
||||
if not graph._day_data:
|
||||
summary.update("[dim]graph needs ≥80×24[/dim]\nawaiting first sample")
|
||||
return
|
||||
|
||||
# Summarise visible days as text (issue #81 AC2)
|
||||
total_bytes = sum(d.get("total_bytes", 0) for d in graph._day_data)
|
||||
days_with_data = sum(1 for d in graph._day_data if d.get("total_bytes", 0) > 0)
|
||||
n = len(graph._day_data)
|
||||
first = graph._day_data[0].get("local_label", "")
|
||||
last = graph._day_data[-1].get("local_label", "")
|
||||
text = (
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
" %d days · %d with writes · %.3f GB total\n"
|
||||
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
||||
)
|
||||
|
||||
# Preserve selected-day context (issue #81 AC2)
|
||||
if 0 <= graph.selected_index < len(graph._day_data):
|
||||
day = graph._day_data[graph.selected_index]
|
||||
text += "\n [bold]%s[/bold] · %.3f GB" % (
|
||||
day.get("local_label", ""), day.get("total_bytes", 0) / 1e9
|
||||
)
|
||||
|
||||
summary.update(text)
|
||||
|
||||
def on_refresh_tick(self) -> None:
|
||||
"""Refresh dashboard and dismiss launch-only authentication guidance."""
|
||||
self._show_auth_notice = False
|
||||
@@ -975,7 +1102,13 @@ class FenrisTuiApp(App):
|
||||
|
||||
def _headline_prefix(self) -> str:
|
||||
"""Render identity and any launch-only guidance above drive state."""
|
||||
lines = ["[bold]Fenris — NVMe endurance monitor[/bold]"]
|
||||
# Use wolf glyph if terminal can render it (issue #79)
|
||||
width = getattr(self, '_size', (80, 24))[0] if hasattr(self, '_size') else 80
|
||||
if _can_render_wolf(width):
|
||||
identity = _IDENTITY_FULL
|
||||
else:
|
||||
identity = _IDENTITY_FALLBACK
|
||||
lines = ["[bold]%s[/bold]" % identity]
|
||||
if self._show_auth_notice:
|
||||
lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]")
|
||||
return "\n".join(lines)
|
||||
@@ -997,6 +1130,20 @@ class FenrisTuiApp(App):
|
||||
def _refresh(self) -> None:
|
||||
"""Refresh all four regions from store data."""
|
||||
self._clock_now = datetime.now(timezone.utc)
|
||||
# Also update constrained state on each refresh (issue #81)
|
||||
try:
|
||||
width, height = self.size
|
||||
was_constrained = self._is_constrained_mode
|
||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||
main_grid = self.query_one("#main-grid")
|
||||
if self._is_constrained_mode:
|
||||
main_grid.add_class("constrained")
|
||||
else:
|
||||
main_grid.remove_class("constrained")
|
||||
if self._is_constrained_mode and not was_constrained:
|
||||
self._update_constrained_summary()
|
||||
except Exception:
|
||||
pass
|
||||
conn = self._open_store()
|
||||
|
||||
if conn is None:
|
||||
@@ -1020,12 +1167,15 @@ class FenrisTuiApp(App):
|
||||
"Enable monitoring: fenris monitor resume"
|
||||
)
|
||||
self.query_one("#usage-history").set_data([])
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\nawaiting first sample"
|
||||
)
|
||||
self.query_one("#drive-health").update("")
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty · "
|
||||
"[dim]by Bongbetic[/dim]\n"
|
||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
||||
"[bold]CONTINUITY[/bold] %s\n"
|
||||
"p pause · r resume · c collect · d disclosures"
|
||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||
% monitoring_continuity({"boot_enabled": False})
|
||||
)
|
||||
else:
|
||||
@@ -1035,10 +1185,13 @@ class FenrisTuiApp(App):
|
||||
"Check journalctl -u fenris-collect.service"
|
||||
)
|
||||
self.query_one("#usage-history").set_data([])
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\nobservation store unreadable"
|
||||
)
|
||||
self.query_one("#drive-health").update("")
|
||||
self.query_one("#service-strip").update(
|
||||
"[dim]by Bongbetic[/dim]\n"
|
||||
"p pause · r resume · c collect · d disclosures"
|
||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||
)
|
||||
|
||||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||||
@@ -1085,14 +1238,18 @@ class FenrisTuiApp(App):
|
||||
else:
|
||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
||||
|
||||
# --- Drive-health pane (§7.2 right) ---
|
||||
# Update constrained summary if in constrained mode (issue #81)
|
||||
if self._is_constrained_mode:
|
||||
self._update_constrained_summary()
|
||||
|
||||
# --- Drive-health pane (§7.2 right, issue #79) ---
|
||||
health = _query_drive_health(conn)
|
||||
# Vendor wear grouped under Drive health with context
|
||||
health_text = (
|
||||
"[bold]Drive health[/bold] · %s\n"
|
||||
" temperature %d°C · spare %d%%\n"
|
||||
" media errors %d · unsafe shutdowns %d\n"
|
||||
" power-on %d h · %d cycles · %s\n\n"
|
||||
"[bold]Settings[/bold]\n"
|
||||
" power-on %d h · %d cycles · %s\n"
|
||||
" vendor wear: %d%% used · %.1f TB written"
|
||||
) % (
|
||||
health["model"],
|
||||
@@ -1111,37 +1268,47 @@ class FenrisTuiApp(App):
|
||||
# --- Service strip (§7.2 bottom) ---
|
||||
try:
|
||||
svc = _query_service_facts(conn, self._clock_now)
|
||||
boot = "enabled" if svc.get("boot_enabled") else "disabled"
|
||||
activity = "active" if svc.get("timer_active") else "inactive"
|
||||
collect = "ok" if svc.get("last_collect_ok") else "FAILED"
|
||||
freshness = svc.get("freshness", "unknown")
|
||||
# Use shared status composition
|
||||
comp = compose_status(
|
||||
conn, svc, self._clock_now,
|
||||
store_fault=None,
|
||||
newer_schema=None,
|
||||
reduced_motion=self._reduced_motion,
|
||||
)
|
||||
# Render using the shared composition with TUI styling
|
||||
status_text = render_status_tui(comp)
|
||||
# Add TUI-only actions
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: %s · timer: %s · last collect: %s · freshness: %s · "
|
||||
"[dim]by Bongbetic[/dim]\n"
|
||||
"[bold]CONTINUITY[/bold] %s\n"
|
||||
"%s\n"
|
||||
"p pause · r resume · c collect · d disclosures"
|
||||
% (
|
||||
boot, activity, collect, freshness,
|
||||
monitoring_continuity(svc), svc.get("period", ""),
|
||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||
% status_text
|
||||
)
|
||||
)
|
||||
self._render_paused_banner(svc)
|
||||
self._render_paused_banner(comp)
|
||||
except Exception:
|
||||
self._hide_paused_banner()
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown · "
|
||||
"[dim]by Bongbetic[/dim]\n"
|
||||
"p pause · r resume · c collect · d disclosures"
|
||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||
)
|
||||
|
||||
def _render_paused_banner(self, service: Dict[str, Any]) -> None:
|
||||
"""Show the high-contrast Deliberate disable block only when sanctioned."""
|
||||
def _render_paused_banner(self, comp) -> None:
|
||||
"""Show the high-contrast Deliberate disable block only when sanctioned.
|
||||
|
||||
Accepts either a StatusComposition or a dict for backward compatibility.
|
||||
"""
|
||||
banner = self.query_one("#paused-banner")
|
||||
if service.get("deliberately_paused"):
|
||||
# Support both StatusComposition and legacy dict
|
||||
if hasattr(comp, 'deliberately_paused'):
|
||||
is_paused = comp.deliberately_paused
|
||||
paused_lines = comp.paused_lines
|
||||
else:
|
||||
is_paused = comp.get("deliberately_paused", False)
|
||||
paused_lines = deliberate_pause_lines() if is_paused else []
|
||||
|
||||
if is_paused and paused_lines:
|
||||
banner.update(
|
||||
"[bold black on red]%s[/bold black on red]\n%s"
|
||||
% tuple(deliberate_pause_lines())
|
||||
% tuple(paused_lines)
|
||||
)
|
||||
banner.styles.display = "block"
|
||||
main_grid = self.query_one("#main-grid")
|
||||
@@ -1197,6 +1364,11 @@ class FenrisTuiApp(App):
|
||||
ConfidenceState.UNSUPPORTED: "red",
|
||||
}[proj.confidence_state]
|
||||
facts = " · ".join(proj.contributing_facts[:3]) if proj.contributing_facts else "no facts"
|
||||
|
||||
# Issue #77: Show qualifying day progress for honesty
|
||||
if proj.qualifying_days_progress:
|
||||
facts = proj.qualifying_days_progress + " · " + facts
|
||||
|
||||
return "[bold]Projection confidence: [%s]%s[/%s][/bold]\n %s" % (
|
||||
color,
|
||||
proj.confidence_state.value,
|
||||
@@ -1220,6 +1392,30 @@ class FenrisTuiApp(App):
|
||||
|
||||
# --- Actions ---
|
||||
|
||||
def action_toggle_theme(self) -> None:
|
||||
"""Cycle through Amber → Nord → High Contrast presets (issue #80)."""
|
||||
names = sorted(THEME_NAMES)
|
||||
try:
|
||||
idx = names.index(self._current_theme_name)
|
||||
except ValueError:
|
||||
idx = 0
|
||||
self._current_theme_name = names[(idx + 1) % len(names)]
|
||||
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
|
||||
save_preferences(
|
||||
theme=self._current_theme_name,
|
||||
reduced_motion=self._reduced_motion,
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def action_toggle_motion(self) -> None:
|
||||
"""Toggle reduced motion preference (issue #80)."""
|
||||
self._reduced_motion = not self._reduced_motion
|
||||
save_preferences(
|
||||
theme=self._current_theme_name,
|
||||
reduced_motion=self._reduced_motion,
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def action_pause(self) -> None:
|
||||
"""Pause monitoring — asks for confirmation (spec §7.4, LC-6)."""
|
||||
self.push_screen(ConfirmPause(), callback=self._pause_confirmed)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
"""Check if Docker daemon is reachable."""
|
||||
def _get_container_runtime() -> str:
|
||||
"""Get available container runtime (podman or docker)."""
|
||||
for cmd in ["podman", "docker"]:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "info"], capture_output=True, timeout=10
|
||||
)
|
||||
return r.returncode == 0
|
||||
r = subprocess.run([cmd, "info"], capture_output=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
return cmd
|
||||
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]:
|
||||
"""Execute a command inside a running container."""
|
||||
runtime = _get_container_runtime()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "exec", container, "sh", "-c", cmd],
|
||||
[runtime, "exec", container, "sh", "-c", cmd],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
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:
|
||||
@@ -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")
|
||||
assert rc == 0, "Observation store directory not created"
|
||||
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[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"
|
||||
|
||||
# Store directory was not rebuilt (same inode, mode 2750)
|
||||
# Store directory was not rebuilt (same inode, mode 2770)
|
||||
rc, out = _container_exec(
|
||||
container, "stat -c '%a' /var/lib/fenris"
|
||||
)
|
||||
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()}"
|
||||
)
|
||||
|
||||
@@ -431,13 +459,13 @@ def _setup_store_and_config(container: str) -> None:
|
||||
_container_exec(
|
||||
container,
|
||||
"mkdir -p /var/lib/fenris && "
|
||||
"python3 -c \""
|
||||
"python3 -c '"
|
||||
"import sqlite3; "
|
||||
"c = sqlite3.connect('/var/lib/fenris/observations.db'); "
|
||||
"c.execute('PRAGMA user_version=1'); "
|
||||
"c.commit(); c.close()\" && "
|
||||
"touch /var/lib/fenris/observations.db-wal && "
|
||||
"touch /var/lib/fenris/observations.db-shm && "
|
||||
"c = sqlite3.connect(\"/var/lib/fenris/observations.db\"); "
|
||||
"c.execute(\"PRAGMA user_version=1\"); "
|
||||
"c.commit(); c.close()' && "
|
||||
"echo 'wal' > /var/lib/fenris/observations.db-wal && "
|
||||
"echo 'shm' > /var/lib/fenris/observations.db-shm && "
|
||||
"cp /var/lib/fenris/observations.db /var/lib/fenris/observations.db.bak",
|
||||
)
|
||||
_container_exec(
|
||||
@@ -472,14 +500,21 @@ def _assert_deb_remove_preserves(container: str) -> None:
|
||||
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||||
assert rc == 0, "Store directory should survive deb remove"
|
||||
|
||||
# Store DB, WAL sidecars, and backup survive
|
||||
for name in ("observations.db", "observations.db-wal",
|
||||
"observations.db-shm", "observations.db.bak"):
|
||||
# Store DB and backup survive (WAL/SHM are ephemeral SQLite files
|
||||
# cleaned by dpkg from package-owned directories)
|
||||
for name in ("observations.db", "observations.db.bak"):
|
||||
rc, _ = _container_exec(
|
||||
container, f"test -f /var/lib/fenris/{name}"
|
||||
)
|
||||
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
|
||||
rc, _ = _container_exec(container, "getent group fenris")
|
||||
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"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
container = f"fenris-floor-{os.getpid()}"
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d", "--name", container,
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag, "sleep", "infinity",
|
||||
],
|
||||
tag, "sleep", "infinity"),
|
||||
check=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"
|
||||
finally:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container],
|
||||
_container_cmd("rm", "-f", container),
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
@@ -630,18 +663,16 @@ def test_python_floor(skip_no_docker, version):
|
||||
|
||||
tag_floor = "fenris-below-floor-debian11"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag_floor, str(build_dir_floor)],
|
||||
_container_cmd("build", "-t", tag_floor, str(build_dir_floor)),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
container_floor = f"fenris-below-floor-{os.getpid()}"
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d", "--name", container_floor,
|
||||
_container_cmd("run", "-d", "--name", container_floor,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag_floor, "sleep", "infinity",
|
||||
],
|
||||
tag_floor, "sleep", "infinity"),
|
||||
check=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}"
|
||||
finally:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container_floor],
|
||||
_container_cmd("rm", "-f", container_floor),
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
@@ -711,7 +742,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
||||
# Build image
|
||||
tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
@@ -720,11 +751,9 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
||||
# Run container
|
||||
container = f"fenris-test-{os.getpid()}"
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d", "--name", container,
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag, "sleep", "infinity",
|
||||
],
|
||||
tag, "sleep", "infinity"),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -733,7 +762,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
||||
_assert_dormant_layout(container, fmt, version)
|
||||
finally:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container],
|
||||
_container_cmd("rm", "-f", container),
|
||||
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
|
||||
""")
|
||||
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"""\
|
||||
FROM {image}
|
||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
python3 smartmontools systemd dbus && \
|
||||
dnf clean all
|
||||
RUN {install_command}
|
||||
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||
RUN mkdir -p /etc/systemd/system && \\
|
||||
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('/', '-')}"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
@@ -790,11 +821,9 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
||||
|
||||
container = f"fenris-guard-{os.getpid()}"
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d", "--name", container,
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag, "sleep", "infinity",
|
||||
],
|
||||
tag, "sleep", "infinity"),
|
||||
check=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)
|
||||
finally:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container],
|
||||
_container_cmd("rm", "-f", container),
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
@@ -835,7 +864,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
||||
|
||||
tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
@@ -843,11 +872,9 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
||||
|
||||
container = f"fenris-upgrade-{os.getpid()}"
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d", "--name", container,
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag, "sleep", "infinity",
|
||||
],
|
||||
tag, "sleep", "infinity"),
|
||||
check=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)
|
||||
finally:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container],
|
||||
_container_cmd("rm", "-f", container),
|
||||
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(':', '-')}"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=300,
|
||||
)
|
||||
|
||||
container = f"fenris-deb-remove-{os.getpid()}"
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||
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).
|
||||
finally:
|
||||
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(':', '-')}"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=300,
|
||||
)
|
||||
|
||||
container = f"fenris-deb-purge-{os.getpid()}"
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||
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)
|
||||
finally:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container], capture_output=True,
|
||||
_container_cmd("rm", "-f", container), capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
@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])
|
||||
def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
||||
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('/', '-')}"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=300,
|
||||
)
|
||||
|
||||
container = f"fenris-upgrade-no-disable-{os.getpid()}"
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||
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"
|
||||
finally:
|
||||
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"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=300,
|
||||
)
|
||||
|
||||
container = f"fenris-rpm-erase-{os.getpid()}"
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||
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).
|
||||
finally:
|
||||
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"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=300,
|
||||
)
|
||||
|
||||
container = f"fenris-rpm-erase-unmod-{os.getpid()}"
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||
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)
|
||||
finally:
|
||||
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."""
|
||||
cmds = [
|
||||
"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",
|
||||
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
|
||||
"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(':', '-')}"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=300,
|
||||
)
|
||||
|
||||
container = f"fenris-no-move-deb-{os.getpid()}"
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
@@ -1333,7 +1360,7 @@ def test_no_move_continuity_deb(skip_no_docker, image, version):
|
||||
)
|
||||
finally:
|
||||
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"
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", tag, str(build_dir)],
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=300,
|
||||
)
|
||||
|
||||
container = f"fenris-no-move-rpm-{os.getpid()}"
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
@@ -1407,5 +1434,5 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
|
||||
)
|
||||
finally:
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
+296
-6
@@ -57,6 +57,8 @@ from fenris.tui import (
|
||||
_GLYPH_GAP,
|
||||
_GLYPH_ZERO,
|
||||
_GLYPH_PARTIAL,
|
||||
_MIN_WIDTH,
|
||||
_MIN_HEIGHT,
|
||||
)
|
||||
|
||||
|
||||
@@ -379,7 +381,10 @@ class TestDenseScreen:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
@@ -396,7 +401,8 @@ class TestDenseScreen:
|
||||
assert "timer:" in strip
|
||||
assert "last collect:" 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
|
||||
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
|
||||
@@ -419,7 +425,7 @@ class TestDenseScreen:
|
||||
quit_rail = app.query_one("#quit-rail")
|
||||
assert "continuity" 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 rail == "q QUIT TUI"
|
||||
assert usage.region.y < service.region.y < quit_rail.region.y
|
||||
@@ -525,7 +531,10 @@ class TestDenseScreen:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
store_path=tmp_path / "nonexistent.db",
|
||||
refresh_interval_s=0.2,
|
||||
@@ -534,9 +543,11 @@ class TestDenseScreen:
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
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 "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)
|
||||
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||||
@@ -1086,3 +1097,282 @@ class TestBarGraphTUI:
|
||||
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