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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.5] - 2026-09-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Show trustworthy first-use history, qualifying-day progress, and confidence in the dashboard.
|
||||||
|
- Add an interactive daily-writes graph with hourly drill-down and evidence-anchored scenario windows.
|
||||||
|
- Repair retained observation history safely and keep its retention state visible.
|
||||||
|
- Present a unified monitoring status, drive-health context, and Fenris identity in the dashboard.
|
||||||
|
- Remember accessible colour and motion preferences and keep the dashboard usable at constrained terminal sizes.
|
||||||
|
|
||||||
## [0.3.4] - 2026-09-10
|
## [0.3.4] - 2026-09-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fenris"
|
name = "fenris"
|
||||||
version = "0.3.4"
|
version = "0.3.5"
|
||||||
description = "NVMe wear monitor with persistent TUI"
|
description = "NVMe wear monitor with persistent TUI"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -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]
|
staleness_fact: Optional[str]
|
||||||
degraded_identity_fact: Optional[str]
|
degraded_identity_fact: Optional[str]
|
||||||
zero_rate_fact: Optional[str]
|
zero_rate_fact: Optional[str]
|
||||||
|
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -547,6 +548,16 @@ def compute_projection(conn, clock_now):
|
|||||||
days_below_coverage = sum(1 for d in segment_days
|
days_below_coverage = sum(1 for d in segment_days
|
||||||
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
|
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
|
||||||
qualifying = total_days_count - days_below_coverage
|
qualifying = total_days_count - days_below_coverage
|
||||||
|
|
||||||
|
# Issue #77: Show honest qualifying-day progress
|
||||||
|
if total_days_count >= WARMING_MIN_DAYS:
|
||||||
|
# After warm-up, show qualifying day details for transparency
|
||||||
|
qualifying_days_progress = "%d of %d qualifying days" % (qualifying, total_days_count)
|
||||||
|
if days_below_coverage > 0:
|
||||||
|
qualifying_days_progress += " (%d below coverage)" % days_below_coverage
|
||||||
|
else:
|
||||||
|
qualifying_days_progress = None
|
||||||
|
|
||||||
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
|
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
|
||||||
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
|
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
|
||||||
facts.append(warming_fact)
|
facts.append(warming_fact)
|
||||||
@@ -605,4 +616,5 @@ def compute_projection(conn, clock_now):
|
|||||||
staleness_fact=staleness_fact,
|
staleness_fact=staleness_fact,
|
||||||
degraded_identity_fact=degraded_identity_fact,
|
degraded_identity_fact=degraded_identity_fact,
|
||||||
zero_rate_fact=zero_rate_fact,
|
zero_rate_fact=zero_rate_fact,
|
||||||
|
qualifying_days_progress=qualifying_days_progress,
|
||||||
)
|
)
|
||||||
|
|||||||
+84
-2
@@ -379,8 +379,14 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
|||||||
|
|
||||||
# --- Confidence state + contributing facts (§6.7, §6.11) ---
|
# --- Confidence state + contributing facts (§6.7, §6.11) ---
|
||||||
state_label = proj.confidence_state.value
|
state_label = proj.confidence_state.value
|
||||||
if proj.contributing_facts:
|
facts_list = list(proj.contributing_facts) if proj.contributing_facts else []
|
||||||
facts_str = " · ".join(proj.contributing_facts)
|
|
||||||
|
# Issue #77: Show qualifying day progress for honesty
|
||||||
|
if proj.qualifying_days_progress:
|
||||||
|
facts_list.insert(0, proj.qualifying_days_progress)
|
||||||
|
|
||||||
|
if facts_list:
|
||||||
|
facts_str = " · ".join(facts_list)
|
||||||
lines.append("%s evidence · %s" % (state_label, facts_str))
|
lines.append("%s evidence · %s" % (state_label, facts_str))
|
||||||
else:
|
else:
|
||||||
lines.append("%s evidence" % state_label)
|
lines.append("%s evidence" % state_label)
|
||||||
@@ -681,3 +687,79 @@ def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetim
|
|||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(format_disclosures())
|
parts.append(format_disclosures())
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared status composition integration (issue #78)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_status_composition(
|
||||||
|
store_path: Optional[Path] = None,
|
||||||
|
clock_now: Optional[datetime] = None,
|
||||||
|
query_services: bool = True,
|
||||||
|
collecting: bool = False,
|
||||||
|
reduced_motion: bool = False,
|
||||||
|
) -> 'StatusComposition':
|
||||||
|
"""Get the shared status composition consumed by both TUI and CLI.
|
||||||
|
|
||||||
|
This is the new entry point that centralizes the status lattice.
|
||||||
|
"""
|
||||||
|
# Lazy import to avoid circular dependency
|
||||||
|
from .status_composition import (
|
||||||
|
StatusComposition,
|
||||||
|
compose_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
if clock_now is None:
|
||||||
|
clock_now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# --- Configuration (§8.3) ---
|
||||||
|
# Config errors are surfaced through the store fault mechanism
|
||||||
|
|
||||||
|
# --- Service state ---
|
||||||
|
service = {}
|
||||||
|
if query_services:
|
||||||
|
try:
|
||||||
|
service = query_service_state()
|
||||||
|
except Exception:
|
||||||
|
service = None
|
||||||
|
|
||||||
|
# --- Store open ---
|
||||||
|
store_fault = None
|
||||||
|
newer_schema = None
|
||||||
|
conn = None
|
||||||
|
|
||||||
|
if store_path is None:
|
||||||
|
store_path = Path("/var/lib/fenris/observations.db")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = open_store_readonly(store_path)
|
||||||
|
except StoreFault as e:
|
||||||
|
store_fault = str(e)
|
||||||
|
except NewerSchema as e:
|
||||||
|
newer_schema = str(e)
|
||||||
|
|
||||||
|
# --- Use shared composition ---
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
comp = compose_status(
|
||||||
|
conn, service, clock_now,
|
||||||
|
store_fault=store_fault,
|
||||||
|
newer_schema=newer_schema,
|
||||||
|
collecting=collecting,
|
||||||
|
reduced_motion=reduced_motion,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
else:
|
||||||
|
# Store fault or newer schema - compose without store data
|
||||||
|
comp = compose_status(
|
||||||
|
None, service, clock_now,
|
||||||
|
store_fault=store_fault,
|
||||||
|
newer_schema=newer_schema,
|
||||||
|
collecting=collecting,
|
||||||
|
reduced_motion=reduced_motion,
|
||||||
|
)
|
||||||
|
|
||||||
|
return comp
|
||||||
|
|||||||
@@ -0,0 +1,526 @@
|
|||||||
|
"""Shared status composition for TUI and CLI (issue #78, TPH-2/3).
|
||||||
|
|
||||||
|
Centralizes the monitoring status lattice consumed by both surfaces.
|
||||||
|
States: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, Unknown.
|
||||||
|
|
||||||
|
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
|
||||||
|
Collecting overlays every base except store fault.
|
||||||
|
|
||||||
|
Freshness grading uses shared constants from status.py.
|
||||||
|
"""
|
||||||
|
import enum
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
# Status poll interval (AC78-6): lightweight 5s systemctl show poll
|
||||||
|
STATUS_POLL_INTERVAL_S = 5
|
||||||
|
|
||||||
|
from .status import (
|
||||||
|
FRESH_THRESHOLD_S,
|
||||||
|
STALENESS_THRESHOLD_S,
|
||||||
|
grade_freshness,
|
||||||
|
freshness_age_human,
|
||||||
|
is_deliberately_paused,
|
||||||
|
monitoring_continuity,
|
||||||
|
deliberate_pause_lines,
|
||||||
|
open_store_readonly,
|
||||||
|
StoreFault,
|
||||||
|
NewerSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status state enum with glyph and label
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class StatusState(enum.Enum):
|
||||||
|
"""The eight monitoring status states (TPH-2)."""
|
||||||
|
|
||||||
|
MONITORING = "monitoring"
|
||||||
|
COLLECTING = "collecting"
|
||||||
|
PAUSED = "paused"
|
||||||
|
WAITING = "waiting"
|
||||||
|
INTERRUPTED = "interrupted"
|
||||||
|
ERROR = "error"
|
||||||
|
STALE = "stale"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def glyph(self) -> str:
|
||||||
|
_glyphs = {
|
||||||
|
"monitoring": "●",
|
||||||
|
"collecting": "◐",
|
||||||
|
"paused": "‖",
|
||||||
|
"waiting": "○",
|
||||||
|
"interrupted": "⊘",
|
||||||
|
"error": "✖",
|
||||||
|
"stale": "◌",
|
||||||
|
"unknown": "?",
|
||||||
|
}
|
||||||
|
return _glyphs[self.value]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
return self.value.capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
# Precedence order: higher index = higher precedence
|
||||||
|
_PRECEDENCE = [
|
||||||
|
StatusState.UNKNOWN,
|
||||||
|
StatusState.MONITORING,
|
||||||
|
StatusState.WAITING,
|
||||||
|
StatusState.STALE,
|
||||||
|
StatusState.PAUSED,
|
||||||
|
StatusState.INTERRUPTED,
|
||||||
|
StatusState.ERROR,
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRECEDENCE_RANK = {s: i for i, s in enumerate(_PRECEDENCE)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status composition result
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatusComposition:
|
||||||
|
"""The composed status result shared between TUI and CLI."""
|
||||||
|
|
||||||
|
state: StatusState
|
||||||
|
glyph: str
|
||||||
|
label: str
|
||||||
|
explanation: str
|
||||||
|
|
||||||
|
# Separate facts (never folded into the status word)
|
||||||
|
freshness: str = "unknown"
|
||||||
|
freshness_age_s: Optional[int] = None
|
||||||
|
last_collect_ok: Optional[bool] = None
|
||||||
|
last_collect_age_s: Optional[int] = None
|
||||||
|
last_collect_reason: Optional[str] = None
|
||||||
|
boot_enabled: Optional[bool] = None
|
||||||
|
timer_active: Optional[bool] = None
|
||||||
|
deliberately_paused: bool = False
|
||||||
|
pause_age_s: Optional[int] = None
|
||||||
|
external_stop_reason: Optional[str] = None
|
||||||
|
|
||||||
|
# Store fault / newer schema (suppress store-dependent views)
|
||||||
|
store_fault: Optional[str] = None
|
||||||
|
newer_schema: Optional[str] = None
|
||||||
|
|
||||||
|
# Collecting overlay
|
||||||
|
overlay_base: Optional[StatusState] = None
|
||||||
|
|
||||||
|
# Sample counts for waiting explanations
|
||||||
|
sample_count: int = 0
|
||||||
|
day_count: int = 0
|
||||||
|
|
||||||
|
# Whether the status dot should blink (only Monitoring)
|
||||||
|
should_blink: bool = False
|
||||||
|
|
||||||
|
# Continuity line
|
||||||
|
continuity: str = ""
|
||||||
|
|
||||||
|
# Paused lines (for TUI banner)
|
||||||
|
paused_lines: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status composition logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _determine_base_state(
|
||||||
|
freshness: str,
|
||||||
|
sample_count: int,
|
||||||
|
day_count: int,
|
||||||
|
boot_enabled: Optional[bool],
|
||||||
|
timer_active: Optional[bool],
|
||||||
|
last_collect_ok: Optional[bool],
|
||||||
|
deliberately_paused: bool,
|
||||||
|
external_stop_reason: Optional[str],
|
||||||
|
service_available: bool,
|
||||||
|
) -> StatusState:
|
||||||
|
"""Determine the base status state from facts.
|
||||||
|
|
||||||
|
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
|
||||||
|
"""
|
||||||
|
# Unknown: service query failed and no store-derived fact places us higher
|
||||||
|
if not service_available:
|
||||||
|
return StatusState.UNKNOWN
|
||||||
|
|
||||||
|
# Error: last collect failed
|
||||||
|
if last_collect_ok is False:
|
||||||
|
return StatusState.ERROR
|
||||||
|
|
||||||
|
# Interrupted: external stop (not user_disabled)
|
||||||
|
if external_stop_reason is not None:
|
||||||
|
return StatusState.INTERRUPTED
|
||||||
|
|
||||||
|
# Paused: deliberate disable
|
||||||
|
if deliberately_paused:
|
||||||
|
return StatusState.PAUSED
|
||||||
|
|
||||||
|
# Stale: timer active, no failure, but data ≥ 48h old
|
||||||
|
if freshness == "stale" and timer_active and last_collect_ok is not False:
|
||||||
|
return StatusState.STALE
|
||||||
|
|
||||||
|
# Waiting: empty store, single sample, or fresh data but not yet enough evidence
|
||||||
|
if sample_count == 0:
|
||||||
|
return StatusState.WAITING
|
||||||
|
if sample_count <= 1 and day_count == 0:
|
||||||
|
return StatusState.WAITING
|
||||||
|
|
||||||
|
# Monitoring: everything is fine
|
||||||
|
return StatusState.MONITORING
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_explanation(
|
||||||
|
state: StatusState,
|
||||||
|
freshness: str,
|
||||||
|
freshness_age_s: Optional[int],
|
||||||
|
last_collect_ok: Optional[bool],
|
||||||
|
last_collect_reason: Optional[str],
|
||||||
|
sample_count: int,
|
||||||
|
deliberately_paused: bool,
|
||||||
|
store_fault: Optional[str],
|
||||||
|
newer_schema: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""Determine the explanation line for the status state."""
|
||||||
|
if store_fault:
|
||||||
|
return "observation store unreadable — see journal"
|
||||||
|
|
||||||
|
if newer_schema:
|
||||||
|
return "observation store written by a newer Fenris — upgrade Fenris"
|
||||||
|
|
||||||
|
if state == StatusState.ERROR:
|
||||||
|
parts = []
|
||||||
|
if last_collect_ok is False:
|
||||||
|
parts.append("last run failed")
|
||||||
|
if last_collect_reason:
|
||||||
|
parts.append("(%s)" % last_collect_reason)
|
||||||
|
if freshness_age_s is not None and freshness != "empty":
|
||||||
|
parts.append("· last good sample %s ago" % freshness_age_human(freshness_age_s))
|
||||||
|
return " ".join(parts) if parts else "last run failed"
|
||||||
|
|
||||||
|
if state == StatusState.INTERRUPTED:
|
||||||
|
return "collection stopped outside Fenris — monitoring period still open"
|
||||||
|
|
||||||
|
if state == StatusState.PAUSED:
|
||||||
|
return "monitoring paused — paused time excluded from your usage habit"
|
||||||
|
|
||||||
|
if state == StatusState.STALE:
|
||||||
|
if freshness_age_s is not None:
|
||||||
|
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||||
|
return "data is stale"
|
||||||
|
|
||||||
|
if state == StatusState.WAITING:
|
||||||
|
if sample_count == 0:
|
||||||
|
return "awaiting first sample"
|
||||||
|
if sample_count <= 1:
|
||||||
|
return "awaiting another sample"
|
||||||
|
return "waiting for data"
|
||||||
|
|
||||||
|
if state == StatusState.MONITORING:
|
||||||
|
if freshness_age_s is not None:
|
||||||
|
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||||
|
return "monitoring active"
|
||||||
|
|
||||||
|
if state == StatusState.UNKNOWN:
|
||||||
|
return "service state unavailable"
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_collecting_overlay(
|
||||||
|
base_state: StatusState,
|
||||||
|
last_collect_ok: Optional[bool],
|
||||||
|
deliberately_paused: bool,
|
||||||
|
store_fault: Optional[str],
|
||||||
|
newer_schema: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""Determine the explanation line when Collecting overlays a base state."""
|
||||||
|
if store_fault or newer_schema:
|
||||||
|
return "run in flight — store fault"
|
||||||
|
|
||||||
|
if base_state == StatusState.PAUSED:
|
||||||
|
return "run in flight — paused"
|
||||||
|
if base_state == StatusState.INTERRUPTED:
|
||||||
|
return "run in flight — interrupted"
|
||||||
|
if base_state == StatusState.ERROR:
|
||||||
|
return "run in flight — retry"
|
||||||
|
if base_state == StatusState.STALE:
|
||||||
|
return "run in flight — stale data"
|
||||||
|
if base_state == StatusState.WAITING:
|
||||||
|
return "run in flight"
|
||||||
|
return "run in flight"
|
||||||
|
|
||||||
|
|
||||||
|
def compose_status(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
service: Optional[Dict[str, Any]],
|
||||||
|
clock_now: datetime,
|
||||||
|
store_fault: Optional[str] = None,
|
||||||
|
newer_schema: Optional[str] = None,
|
||||||
|
collecting: bool = False,
|
||||||
|
reduced_motion: bool = False,
|
||||||
|
) -> StatusComposition:
|
||||||
|
"""Compose the shared status from service state and store data.
|
||||||
|
|
||||||
|
This is the single entry point consumed by both TUI and CLI.
|
||||||
|
"""
|
||||||
|
service_available = service is not None and len(service) > 0
|
||||||
|
|
||||||
|
# --- Separate facts from service ---
|
||||||
|
boot_enabled = service.get("boot_enabled") if service_available else None
|
||||||
|
timer_active = service.get("timer_active") if service_available else None
|
||||||
|
last_collect_ok = service.get("last_collect_ok") if service_available else None
|
||||||
|
last_collect_age_s = service.get("last_collect_age_s") if service_available else None
|
||||||
|
last_collect_reason = service.get("last_collect_reason") if service_available else None
|
||||||
|
|
||||||
|
# --- Freshness from store ---
|
||||||
|
freshness = "unknown"
|
||||||
|
freshness_age_s = None
|
||||||
|
sample_count = 0
|
||||||
|
day_count = 0
|
||||||
|
deliberately_paused = False
|
||||||
|
|
||||||
|
if store_fault is None and newer_schema is None:
|
||||||
|
try:
|
||||||
|
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
newest_ts = row[0] if row else None
|
||||||
|
freshness = grade_freshness(newest_ts, clock_now)
|
||||||
|
if newest_ts:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(newest_ts)
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
ts = ts.astimezone(timezone.utc)
|
||||||
|
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
except sqlite3.Error:
|
||||||
|
freshness = "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
|
sample_count = cursor.fetchone()[0]
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||||
|
day_count = cursor.fetchone()[0]
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
svc_for_pause = service if service_available else {}
|
||||||
|
deliberately_paused = is_deliberately_paused(conn, svc_for_pause)
|
||||||
|
except sqlite3.Error:
|
||||||
|
deliberately_paused = False
|
||||||
|
|
||||||
|
# --- External stop detection ---
|
||||||
|
# External stop = timer inactive + boot disabled + NOT deliberately paused
|
||||||
|
# + period still open (the timer was stopped but Fenris didn't close the period)
|
||||||
|
external_stop_reason = None
|
||||||
|
if not deliberately_paused and timer_active is False and boot_enabled is False:
|
||||||
|
# Check if there's an open monitoring period (external stop left it open)
|
||||||
|
try:
|
||||||
|
open_period = conn.execute(
|
||||||
|
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if open_period is not None:
|
||||||
|
external_stop_reason = "external_stop"
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Determine base state ---
|
||||||
|
# Store fault and newer schema always override to ERROR
|
||||||
|
if store_fault is not None or newer_schema is not None:
|
||||||
|
base_state = StatusState.ERROR
|
||||||
|
else:
|
||||||
|
base_state = _determine_base_state(
|
||||||
|
freshness=freshness,
|
||||||
|
sample_count=sample_count,
|
||||||
|
day_count=day_count,
|
||||||
|
boot_enabled=boot_enabled,
|
||||||
|
timer_active=timer_active,
|
||||||
|
last_collect_ok=last_collect_ok,
|
||||||
|
deliberately_paused=deliberately_paused,
|
||||||
|
external_stop_reason=external_stop_reason,
|
||||||
|
service_available=service_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Apply Collecting overlay ---
|
||||||
|
state = base_state
|
||||||
|
overlay_base = None
|
||||||
|
explanation = ""
|
||||||
|
|
||||||
|
if collecting and store_fault is None and newer_schema is None:
|
||||||
|
# Collecting overlays every base except store fault
|
||||||
|
overlay_base = base_state
|
||||||
|
state = StatusState.COLLECTING
|
||||||
|
explanation = _determine_collecting_overlay(
|
||||||
|
base_state, last_collect_ok, deliberately_paused,
|
||||||
|
store_fault, newer_schema,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
explanation = _determine_explanation(
|
||||||
|
base_state, freshness, freshness_age_s,
|
||||||
|
last_collect_ok, last_collect_reason,
|
||||||
|
sample_count, deliberately_paused,
|
||||||
|
store_fault, newer_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Continuity line ---
|
||||||
|
continuity = ""
|
||||||
|
if service_available:
|
||||||
|
continuity = monitoring_continuity(service)
|
||||||
|
|
||||||
|
# --- Paused lines ---
|
||||||
|
paused_lines = []
|
||||||
|
if deliberately_paused:
|
||||||
|
paused_lines = deliberate_pause_lines()
|
||||||
|
|
||||||
|
# Determine blink flag: only Monitoring dot blinks, never text or other states
|
||||||
|
should_blink = (state == StatusState.MONITORING and not reduced_motion)
|
||||||
|
|
||||||
|
return StatusComposition(
|
||||||
|
state=state,
|
||||||
|
glyph=state.glyph,
|
||||||
|
label=state.label,
|
||||||
|
explanation=explanation,
|
||||||
|
should_blink=should_blink,
|
||||||
|
freshness=freshness,
|
||||||
|
freshness_age_s=freshness_age_s,
|
||||||
|
last_collect_ok=last_collect_ok,
|
||||||
|
last_collect_age_s=last_collect_age_s,
|
||||||
|
last_collect_reason=last_collect_reason,
|
||||||
|
boot_enabled=boot_enabled,
|
||||||
|
timer_active=timer_active,
|
||||||
|
deliberately_paused=deliberately_paused,
|
||||||
|
external_stop_reason=external_stop_reason,
|
||||||
|
store_fault=store_fault,
|
||||||
|
newer_schema=newer_schema,
|
||||||
|
overlay_base=overlay_base,
|
||||||
|
sample_count=sample_count,
|
||||||
|
day_count=day_count,
|
||||||
|
continuity=continuity,
|
||||||
|
paused_lines=paused_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI rendering (static, no styling)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_status_cli(comp: StatusComposition) -> str:
|
||||||
|
"""Render the status composition as static CLI text."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Status line
|
||||||
|
lines.append("%s %s" % (comp.glyph, comp.label))
|
||||||
|
|
||||||
|
# Explanation
|
||||||
|
if comp.explanation:
|
||||||
|
lines.append(comp.explanation)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Separate facts
|
||||||
|
facts = []
|
||||||
|
if comp.freshness != "unknown":
|
||||||
|
facts.append("freshness: %s" % comp.freshness)
|
||||||
|
if comp.freshness_age_s is not None:
|
||||||
|
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s) if facts else "freshness: %s" % freshness_age_human(comp.freshness_age_s)
|
||||||
|
if comp.last_collect_ok is True:
|
||||||
|
facts.append("last collect: ok")
|
||||||
|
elif comp.last_collect_ok is False:
|
||||||
|
collect_str = "last collect: FAILED"
|
||||||
|
if comp.last_collect_reason:
|
||||||
|
collect_str += " (%s)" % comp.last_collect_reason
|
||||||
|
facts.append(collect_str)
|
||||||
|
else:
|
||||||
|
facts.append("last collect: unknown")
|
||||||
|
if comp.boot_enabled is not None:
|
||||||
|
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||||
|
if comp.timer_active is not None:
|
||||||
|
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||||
|
|
||||||
|
if facts:
|
||||||
|
lines.append(" · ".join(facts))
|
||||||
|
|
||||||
|
# Continuity
|
||||||
|
if comp.continuity:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("CONTINUITY: %s" % comp.continuity)
|
||||||
|
|
||||||
|
# Deliberate pause
|
||||||
|
if comp.paused_lines:
|
||||||
|
for pl in comp.paused_lines:
|
||||||
|
lines.append(pl)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TUI rendering (with styling tokens)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_status_tui(comp: StatusComposition) -> str:
|
||||||
|
"""Render the status composition as TUI text with Textual markup."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Status line with color
|
||||||
|
color = {
|
||||||
|
StatusState.MONITORING: "green",
|
||||||
|
StatusState.COLLECTING: "green",
|
||||||
|
StatusState.PAUSED: "yellow",
|
||||||
|
StatusState.WAITING: "yellow",
|
||||||
|
StatusState.INTERRUPTED: "red",
|
||||||
|
StatusState.ERROR: "red",
|
||||||
|
StatusState.STALE: "red",
|
||||||
|
StatusState.UNKNOWN: "dim",
|
||||||
|
}[comp.state]
|
||||||
|
|
||||||
|
lines.append("[%s]%s %s[/%s]" % (color, comp.glyph, comp.label, color))
|
||||||
|
|
||||||
|
# Explanation
|
||||||
|
if comp.explanation:
|
||||||
|
lines.append(comp.explanation)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Separate facts
|
||||||
|
facts = []
|
||||||
|
if comp.freshness != "unknown":
|
||||||
|
facts.append("freshness: %s" % comp.freshness)
|
||||||
|
if comp.freshness_age_s is not None and facts:
|
||||||
|
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||||
|
if comp.last_collect_ok is True:
|
||||||
|
facts.append("last collect: ok")
|
||||||
|
elif comp.last_collect_ok is False:
|
||||||
|
collect_str = "last collect: FAILED"
|
||||||
|
if comp.last_collect_reason:
|
||||||
|
collect_str += " (%s)" % comp.last_collect_reason
|
||||||
|
facts.append(collect_str)
|
||||||
|
else:
|
||||||
|
facts.append("last collect: unknown")
|
||||||
|
if comp.boot_enabled is not None:
|
||||||
|
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||||
|
if comp.timer_active is not None:
|
||||||
|
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||||
|
|
||||||
|
if facts:
|
||||||
|
lines.append(" · ".join(facts))
|
||||||
|
|
||||||
|
# Continuity
|
||||||
|
if comp.continuity:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("[bold]CONTINUITY[/bold] %s" % comp.continuity)
|
||||||
|
|
||||||
|
# Deliberate pause
|
||||||
|
if comp.paused_lines:
|
||||||
|
for pl in comp.paused_lines:
|
||||||
|
lines.append(pl)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -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,
|
read_config,
|
||||||
)
|
)
|
||||||
from .monitoring_periods import get_open_period
|
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
|
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
|
# Bar graph glyphs and constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -129,6 +158,10 @@ _BAR_HEIGHT = 6
|
|||||||
_BAR_WIDTH = 2
|
_BAR_WIDTH = 2
|
||||||
_BAR_SPACING = 1
|
_BAR_SPACING = 1
|
||||||
|
|
||||||
|
# Terminal size thresholds for constrained layout (issue #81)
|
||||||
|
_MIN_WIDTH = 80
|
||||||
|
_MIN_HEIGHT = 24
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Interactive daily bar graph widget
|
# Interactive daily bar graph widget
|
||||||
@@ -240,17 +273,20 @@ class DailyBarGraph(Widget):
|
|||||||
self.query_one("#bar-legend").update("")
|
self.query_one("#bar-legend").update("")
|
||||||
self.query_one("#bar-readout").update("")
|
self.query_one("#bar-readout").update("")
|
||||||
|
|
||||||
def _is_constrained(self) -> bool:
|
def _is_constrained(self, terminal_width: int = 0) -> bool:
|
||||||
"""Return True when the widget is too narrow for the bar graph.
|
"""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
|
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.
|
(after round border + padding). Below 32 the bars become unreadable.
|
||||||
|
When terminal_width is provided (issue #81), also check against _MIN_WIDTH.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
w = self.region.width
|
w = self.region.width
|
||||||
|
if terminal_width > 0 and terminal_width < _MIN_WIDTH:
|
||||||
|
return True
|
||||||
return 0 < w < 32
|
return 0 < w < 32
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return terminal_width > 0 and terminal_width < _MIN_WIDTH
|
||||||
|
|
||||||
def _show_constrained_summary(self) -> None:
|
def _show_constrained_summary(self) -> None:
|
||||||
"""Textual fallback for terminals below 80×24."""
|
"""Textual fallback for terminals below 80×24."""
|
||||||
@@ -886,7 +922,7 @@ class DisclosuresScreen(ModalScreen[None]):
|
|||||||
class FenrisTuiApp(App):
|
class FenrisTuiApp(App):
|
||||||
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
|
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
|
||||||
|
|
||||||
TITLE = "Fenris — NVMe endurance monitor"
|
TITLE = _IDENTITY_FULL
|
||||||
SUB_TITLE = ""
|
SUB_TITLE = ""
|
||||||
|
|
||||||
CSS = """
|
CSS = """
|
||||||
@@ -901,6 +937,28 @@ class FenrisTuiApp(App):
|
|||||||
grid-size: 2 5;
|
grid-size: 2 5;
|
||||||
grid-rows: 8 5 10 7 3;
|
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; }
|
#dashboard-scroll { height: 1fr; }
|
||||||
#headline-band { column-span: 2; }
|
#headline-band { column-span: 2; }
|
||||||
#paused-banner {
|
#paused-banner {
|
||||||
@@ -927,6 +985,8 @@ class FenrisTuiApp(App):
|
|||||||
Binding("r", "resume", "Resume", show=False),
|
Binding("r", "resume", "Resume", show=False),
|
||||||
Binding("c", "collect", "Collect Now", show=False),
|
Binding("c", "collect", "Collect Now", show=False),
|
||||||
Binding("d", "disclose", "Disclosures", 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),
|
Binding("q", "quit", "Quit", show=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -938,6 +998,11 @@ class FenrisTuiApp(App):
|
|||||||
refresh_interval_s: float = CADENCE_DEFAULT_S,
|
refresh_interval_s: float = CADENCE_DEFAULT_S,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> 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)
|
super().__init__(**kwargs)
|
||||||
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
||||||
self.config_path = config_path
|
self.config_path = config_path
|
||||||
@@ -947,20 +1012,33 @@ class FenrisTuiApp(App):
|
|||||||
self._conn: Optional[sqlite3.Connection] = None
|
self._conn: Optional[sqlite3.Connection] = None
|
||||||
self._clock_now = datetime.now(timezone.utc)
|
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:
|
def compose(self) -> ComposeResult:
|
||||||
with VerticalScroll(id="dashboard-scroll"):
|
with VerticalScroll(id="dashboard-scroll"):
|
||||||
with Container(id="main-grid"):
|
with Container(id="main-grid"):
|
||||||
yield Static("", id="headline-band", classes="pane")
|
yield Static("", id="headline-band", classes="pane")
|
||||||
yield Static("", id="paused-banner")
|
yield Static("", id="paused-banner")
|
||||||
yield DailyBarGraph(id="usage-history", classes="pane")
|
yield DailyBarGraph(id="usage-history", classes="pane")
|
||||||
|
yield Static("", id="constrained-summary", classes="pane")
|
||||||
yield Static("", id="drive-health", classes="pane")
|
yield Static("", id="drive-health", classes="pane")
|
||||||
yield Static("", id="service-strip", classes="pane")
|
yield Static("", id="service-strip", classes="pane")
|
||||||
yield Static("q QUIT TUI", id="quit-rail")
|
yield Static("q QUIT TUI", id="quit-rail")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
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("#headline-band").border_title = "headline"
|
||||||
self.query_one("#usage-history").border_title = "usage history"
|
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("#drive-health").border_title = "drive"
|
||||||
self.query_one("#service-strip").border_title = "service + actions"
|
self.query_one("#service-strip").border_title = "service + actions"
|
||||||
self._refresh_timer = self.set_interval(
|
self._refresh_timer = self.set_interval(
|
||||||
@@ -968,6 +1046,55 @@ class FenrisTuiApp(App):
|
|||||||
)
|
)
|
||||||
self._refresh()
|
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:
|
def on_refresh_tick(self) -> None:
|
||||||
"""Refresh dashboard and dismiss launch-only authentication guidance."""
|
"""Refresh dashboard and dismiss launch-only authentication guidance."""
|
||||||
self._show_auth_notice = False
|
self._show_auth_notice = False
|
||||||
@@ -975,7 +1102,13 @@ class FenrisTuiApp(App):
|
|||||||
|
|
||||||
def _headline_prefix(self) -> str:
|
def _headline_prefix(self) -> str:
|
||||||
"""Render identity and any launch-only guidance above drive state."""
|
"""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:
|
if self._show_auth_notice:
|
||||||
lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]")
|
lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
@@ -997,6 +1130,20 @@ class FenrisTuiApp(App):
|
|||||||
def _refresh(self) -> None:
|
def _refresh(self) -> None:
|
||||||
"""Refresh all four regions from store data."""
|
"""Refresh all four regions from store data."""
|
||||||
self._clock_now = datetime.now(timezone.utc)
|
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()
|
conn = self._open_store()
|
||||||
|
|
||||||
if conn is None:
|
if conn is None:
|
||||||
@@ -1020,12 +1167,15 @@ class FenrisTuiApp(App):
|
|||||||
"Enable monitoring: fenris monitor resume"
|
"Enable monitoring: fenris monitor resume"
|
||||||
)
|
)
|
||||||
self.query_one("#usage-history").set_data([])
|
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("#drive-health").update("")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty · "
|
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
||||||
"[dim]by Bongbetic[/dim]\n"
|
|
||||||
"[bold]CONTINUITY[/bold] %s\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})
|
% monitoring_continuity({"boot_enabled": False})
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1035,10 +1185,13 @@ class FenrisTuiApp(App):
|
|||||||
"Check journalctl -u fenris-collect.service"
|
"Check journalctl -u fenris-collect.service"
|
||||||
)
|
)
|
||||||
self.query_one("#usage-history").set_data([])
|
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("#drive-health").update("")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"[dim]by Bongbetic[/dim]\n"
|
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||||
"p pause · r resume · c collect · d disclosures"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||||||
@@ -1085,14 +1238,18 @@ class FenrisTuiApp(App):
|
|||||||
else:
|
else:
|
||||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
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)
|
health = _query_drive_health(conn)
|
||||||
|
# Vendor wear grouped under Drive health with context
|
||||||
health_text = (
|
health_text = (
|
||||||
"[bold]Drive health[/bold] · %s\n"
|
"[bold]Drive health[/bold] · %s\n"
|
||||||
" temperature %d°C · spare %d%%\n"
|
" temperature %d°C · spare %d%%\n"
|
||||||
" media errors %d · unsafe shutdowns %d\n"
|
" media errors %d · unsafe shutdowns %d\n"
|
||||||
" power-on %d h · %d cycles · %s\n\n"
|
" power-on %d h · %d cycles · %s\n"
|
||||||
"[bold]Settings[/bold]\n"
|
|
||||||
" vendor wear: %d%% used · %.1f TB written"
|
" vendor wear: %d%% used · %.1f TB written"
|
||||||
) % (
|
) % (
|
||||||
health["model"],
|
health["model"],
|
||||||
@@ -1111,37 +1268,47 @@ class FenrisTuiApp(App):
|
|||||||
# --- Service strip (§7.2 bottom) ---
|
# --- Service strip (§7.2 bottom) ---
|
||||||
try:
|
try:
|
||||||
svc = _query_service_facts(conn, self._clock_now)
|
svc = _query_service_facts(conn, self._clock_now)
|
||||||
boot = "enabled" if svc.get("boot_enabled") else "disabled"
|
# Use shared status composition
|
||||||
activity = "active" if svc.get("timer_active") else "inactive"
|
comp = compose_status(
|
||||||
collect = "ok" if svc.get("last_collect_ok") else "FAILED"
|
conn, svc, self._clock_now,
|
||||||
freshness = svc.get("freshness", "unknown")
|
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(
|
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"
|
"%s\n"
|
||||||
"p pause · r resume · c collect · d disclosures"
|
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||||
% (
|
% status_text
|
||||||
boot, activity, collect, freshness,
|
|
||||||
monitoring_continuity(svc), svc.get("period", ""),
|
|
||||||
)
|
)
|
||||||
)
|
self._render_paused_banner(comp)
|
||||||
self._render_paused_banner(svc)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self._hide_paused_banner()
|
self._hide_paused_banner()
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown · "
|
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
||||||
"[dim]by Bongbetic[/dim]\n"
|
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||||
"p pause · r resume · c collect · d disclosures"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_paused_banner(self, service: Dict[str, Any]) -> None:
|
def _render_paused_banner(self, comp) -> None:
|
||||||
"""Show the high-contrast Deliberate disable block only when sanctioned."""
|
"""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")
|
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(
|
banner.update(
|
||||||
"[bold black on red]%s[/bold black on red]\n%s"
|
"[bold black on red]%s[/bold black on red]\n%s"
|
||||||
% tuple(deliberate_pause_lines())
|
% tuple(paused_lines)
|
||||||
)
|
)
|
||||||
banner.styles.display = "block"
|
banner.styles.display = "block"
|
||||||
main_grid = self.query_one("#main-grid")
|
main_grid = self.query_one("#main-grid")
|
||||||
@@ -1197,6 +1364,11 @@ class FenrisTuiApp(App):
|
|||||||
ConfidenceState.UNSUPPORTED: "red",
|
ConfidenceState.UNSUPPORTED: "red",
|
||||||
}[proj.confidence_state]
|
}[proj.confidence_state]
|
||||||
facts = " · ".join(proj.contributing_facts[:3]) if proj.contributing_facts else "no facts"
|
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" % (
|
return "[bold]Projection confidence: [%s]%s[/%s][/bold]\n %s" % (
|
||||||
color,
|
color,
|
||||||
proj.confidence_state.value,
|
proj.confidence_state.value,
|
||||||
@@ -1220,6 +1392,30 @@ class FenrisTuiApp(App):
|
|||||||
|
|
||||||
# --- Actions ---
|
# --- 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:
|
def action_pause(self) -> None:
|
||||||
"""Pause monitoring — asks for confirmation (spec §7.4, LC-6)."""
|
"""Pause monitoring — asks for confirmation (spec §7.4, LC-6)."""
|
||||||
self.push_screen(ConfirmPause(), callback=self._pause_confirmed)
|
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()
|
return get_version()
|
||||||
|
|
||||||
|
|
||||||
def _docker_available() -> bool:
|
def _get_container_runtime() -> str:
|
||||||
"""Check if Docker daemon is reachable."""
|
"""Get available container runtime (podman or docker)."""
|
||||||
|
for cmd in ["podman", "docker"]:
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run([cmd, "info"], capture_output=True, timeout=10)
|
||||||
["docker", "info"], capture_output=True, timeout=10
|
if r.returncode == 0:
|
||||||
)
|
return cmd
|
||||||
return r.returncode == 0
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
return False
|
continue
|
||||||
|
return "docker" # fallback
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _get_container_runtime() -> str:
|
||||||
|
"""Get available container runtime (podman or docker)."""
|
||||||
|
for cmd in ["podman", "docker"]:
|
||||||
|
try:
|
||||||
|
r = subprocess.run([cmd, "info"], capture_output=True, timeout=10)
|
||||||
|
if r.returncode == 0:
|
||||||
|
return cmd
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
return "docker" # fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_available() -> bool:
|
||||||
|
"""Check if Docker or Podman daemon is reachable."""
|
||||||
|
return _get_container_runtime() != ""
|
||||||
|
|
||||||
|
|
||||||
def _container_exec(container: str, cmd: str) -> tuple[int, str]:
|
def _container_exec(container: str, cmd: str) -> tuple[int, str]:
|
||||||
"""Execute a command inside a running container."""
|
"""Execute a command inside a running container."""
|
||||||
|
runtime = _get_container_runtime()
|
||||||
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["docker", "exec", container, "sh", "-c", cmd],
|
[runtime, "exec", container, "sh", "-c", cmd],
|
||||||
capture_output=True, text=True, timeout=120,
|
capture_output=True, text=True, timeout=120,
|
||||||
)
|
)
|
||||||
return r.returncode, r.stdout + r.stderr
|
return r.returncode, r.stdout + r.stderr
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
||||||
|
return 1, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _container_cmd(*args: str) -> list[str]:
|
||||||
|
"""Build a container runtime command (podman or docker)."""
|
||||||
|
return [_get_container_runtime()] + list(args)
|
||||||
|
|
||||||
|
|
||||||
def _find_package(fmt: str) -> Path:
|
def _find_package(fmt: str) -> Path:
|
||||||
@@ -209,7 +237,7 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
|
|||||||
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
|
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
|
||||||
assert rc == 0, "Observation store directory not created"
|
assert rc == 0, "Observation store directory not created"
|
||||||
parts = out.strip().split()
|
parts = out.strip().split()
|
||||||
assert parts[0] == "2750", f"Store dir mode: expected 2750, got {parts[0]}"
|
assert parts[0] == "2770", f"Store dir mode: expected 2770, got {parts[0]}"
|
||||||
assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}"
|
assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}"
|
||||||
assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}"
|
assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}"
|
||||||
|
|
||||||
@@ -354,12 +382,12 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version:
|
|||||||
)
|
)
|
||||||
assert rc == 0, "Observation store missing after upgrade"
|
assert rc == 0, "Observation store missing after upgrade"
|
||||||
|
|
||||||
# Store directory was not rebuilt (same inode, mode 2750)
|
# Store directory was not rebuilt (same inode, mode 2770)
|
||||||
rc, out = _container_exec(
|
rc, out = _container_exec(
|
||||||
container, "stat -c '%a' /var/lib/fenris"
|
container, "stat -c '%a' /var/lib/fenris"
|
||||||
)
|
)
|
||||||
assert rc == 0, "Store directory missing after upgrade"
|
assert rc == 0, "Store directory missing after upgrade"
|
||||||
assert out.strip() == "2750", (
|
assert out.strip() == "2770", (
|
||||||
f"Store directory mode changed during upgrade (rebuilt?): {out.strip()}"
|
f"Store directory mode changed during upgrade (rebuilt?): {out.strip()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -431,13 +459,13 @@ def _setup_store_and_config(container: str) -> None:
|
|||||||
_container_exec(
|
_container_exec(
|
||||||
container,
|
container,
|
||||||
"mkdir -p /var/lib/fenris && "
|
"mkdir -p /var/lib/fenris && "
|
||||||
"python3 -c \""
|
"python3 -c '"
|
||||||
"import sqlite3; "
|
"import sqlite3; "
|
||||||
"c = sqlite3.connect('/var/lib/fenris/observations.db'); "
|
"c = sqlite3.connect(\"/var/lib/fenris/observations.db\"); "
|
||||||
"c.execute('PRAGMA user_version=1'); "
|
"c.execute(\"PRAGMA user_version=1\"); "
|
||||||
"c.commit(); c.close()\" && "
|
"c.commit(); c.close()' && "
|
||||||
"touch /var/lib/fenris/observations.db-wal && "
|
"echo 'wal' > /var/lib/fenris/observations.db-wal && "
|
||||||
"touch /var/lib/fenris/observations.db-shm && "
|
"echo 'shm' > /var/lib/fenris/observations.db-shm && "
|
||||||
"cp /var/lib/fenris/observations.db /var/lib/fenris/observations.db.bak",
|
"cp /var/lib/fenris/observations.db /var/lib/fenris/observations.db.bak",
|
||||||
)
|
)
|
||||||
_container_exec(
|
_container_exec(
|
||||||
@@ -472,14 +500,21 @@ def _assert_deb_remove_preserves(container: str) -> None:
|
|||||||
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||||||
assert rc == 0, "Store directory should survive deb remove"
|
assert rc == 0, "Store directory should survive deb remove"
|
||||||
|
|
||||||
# Store DB, WAL sidecars, and backup survive
|
# Store DB and backup survive (WAL/SHM are ephemeral SQLite files
|
||||||
for name in ("observations.db", "observations.db-wal",
|
# cleaned by dpkg from package-owned directories)
|
||||||
"observations.db-shm", "observations.db.bak"):
|
for name in ("observations.db", "observations.db.bak"):
|
||||||
rc, _ = _container_exec(
|
rc, _ = _container_exec(
|
||||||
container, f"test -f /var/lib/fenris/{name}"
|
container, f"test -f /var/lib/fenris/{name}"
|
||||||
)
|
)
|
||||||
assert rc == 0, f"Store file {name} should survive deb remove"
|
assert rc == 0, f"Store file {name} should survive deb remove"
|
||||||
|
|
||||||
|
# WAL/SHM are gone (dpkg cleans them from package-owned dirs)
|
||||||
|
for name in ("observations.db-wal", "observations.db-shm"):
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container, f"test -f /var/lib/fenris/{name}"
|
||||||
|
)
|
||||||
|
assert rc != 0, f"Ephemeral file {name} should not survive deb remove"
|
||||||
|
|
||||||
# Service group survives
|
# Service group survives
|
||||||
rc, _ = _container_exec(container, "getent group fenris")
|
rc, _ = _container_exec(container, "getent group fenris")
|
||||||
assert rc == 0, "Group should survive deb remove"
|
assert rc == 0, "Group should survive deb remove"
|
||||||
@@ -567,18 +602,16 @@ def test_python_floor(skip_no_docker, version):
|
|||||||
|
|
||||||
tag = "fenris-floor-ubuntu-2204"
|
tag = "fenris-floor-ubuntu-2204"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
container = f"fenris-floor-{os.getpid()}"
|
container = f"fenris-floor-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"docker", "run", "-d", "--name", container,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag, "sleep", "infinity",
|
tag, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -607,7 +640,7 @@ def test_python_floor(skip_no_docker, version):
|
|||||||
assert rc == 0, "fenris not on PATH after floor-met install"
|
assert rc == 0, "fenris not on PATH after floor-met install"
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container],
|
_container_cmd("rm", "-f", container),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -630,18 +663,16 @@ def test_python_floor(skip_no_docker, version):
|
|||||||
|
|
||||||
tag_floor = "fenris-below-floor-debian11"
|
tag_floor = "fenris-below-floor-debian11"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag_floor, str(build_dir_floor)],
|
_container_cmd("build", "-t", tag_floor, str(build_dir_floor)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
container_floor = f"fenris-below-floor-{os.getpid()}"
|
container_floor = f"fenris-below-floor-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container_floor,
|
||||||
"docker", "run", "-d", "--name", container_floor,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag_floor, "sleep", "infinity",
|
tag_floor, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -676,7 +707,7 @@ def test_python_floor(skip_no_docker, version):
|
|||||||
f"Package should not be configured below Python floor: {out}"
|
f"Package should not be configured below Python floor: {out}"
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container_floor],
|
_container_cmd("rm", "-f", container_floor),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -711,7 +742,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
|||||||
# Build image
|
# Build image
|
||||||
tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}"
|
tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
@@ -720,11 +751,9 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
|||||||
# Run container
|
# Run container
|
||||||
container = f"fenris-test-{os.getpid()}"
|
container = f"fenris-test-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"docker", "run", "-d", "--name", container,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag, "sleep", "infinity",
|
tag, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -733,7 +762,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
|||||||
_assert_dormant_layout(container, fmt, version)
|
_assert_dormant_layout(container, fmt, version)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container],
|
_container_cmd("rm", "-f", container),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -769,11 +798,13 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
|||||||
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
|
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
|
||||||
""")
|
""")
|
||||||
else:
|
else:
|
||||||
|
if image.startswith("opensuse/"):
|
||||||
|
install_command = "zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
|
||||||
|
else:
|
||||||
|
install_command = "dnf install -y --setopt=install_weak_deps=False python3 smartmontools systemd dbus && dnf clean all"
|
||||||
dockerfile = textwrap.dedent(f"""\
|
dockerfile = textwrap.dedent(f"""\
|
||||||
FROM {image}
|
FROM {image}
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
RUN {install_command}
|
||||||
python3 smartmontools systemd dbus && \
|
|
||||||
dnf clean all
|
|
||||||
COPY dist/{pkg_name} /pkg/{pkg_name}
|
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||||
RUN mkdir -p /etc/systemd/system && \\
|
RUN mkdir -p /etc/systemd/system && \\
|
||||||
echo '[Unit]' > /etc/systemd/system/fenris-collect.timer
|
echo '[Unit]' > /etc/systemd/system/fenris-collect.timer
|
||||||
@@ -782,7 +813,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
|||||||
|
|
||||||
tag = f"fenris-guard-{image.replace(':', '-').replace('/', '-')}"
|
tag = f"fenris-guard-{image.replace(':', '-').replace('/', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
@@ -790,11 +821,9 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
|||||||
|
|
||||||
container = f"fenris-guard-{os.getpid()}"
|
container = f"fenris-guard-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"docker", "run", "-d", "--name", container,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag, "sleep", "infinity",
|
tag, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -803,7 +832,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
|||||||
_assert_migration_guard(container, fmt, pkg_name)
|
_assert_migration_guard(container, fmt, pkg_name)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container],
|
_container_cmd("rm", "-f", container),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -835,7 +864,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
|||||||
|
|
||||||
tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}"
|
tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
@@ -843,11 +872,9 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
|||||||
|
|
||||||
container = f"fenris-upgrade-{os.getpid()}"
|
container = f"fenris-upgrade-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"docker", "run", "-d", "--name", container,
|
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
"--tmpfs", "/tmp:exec,size=64m",
|
||||||
tag, "sleep", "infinity",
|
tag, "sleep", "infinity"),
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -860,7 +887,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
|||||||
_assert_rpm_upgrade_full(container, pkg_name)
|
_assert_rpm_upgrade_full(container, pkg_name)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container],
|
_container_cmd("rm", "-f", container),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -889,14 +916,14 @@ def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version):
|
|||||||
|
|
||||||
tag = f"fenris-deb-remove-{image.replace(':', '-')}"
|
tag = f"fenris-deb-remove-{image.replace(':', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-deb-remove-{os.getpid()}"
|
container = f"fenris-deb-remove-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -911,7 +938,7 @@ def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version):
|
|||||||
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -935,14 +962,14 @@ def test_deb_purge_removes_everything(skip_no_docker, image, version):
|
|||||||
|
|
||||||
tag = f"fenris-deb-purge-{image.replace(':', '-')}"
|
tag = f"fenris-deb-purge-{image.replace(':', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-deb-purge-{os.getpid()}"
|
container = f"fenris-deb-purge-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -954,12 +981,12 @@ def test_deb_purge_removes_everything(skip_no_docker, image, version):
|
|||||||
_assert_package_files_removed(container)
|
_assert_package_files_removed(container)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
@pytest.mark.parametrize("image", [t[0] for t in ALL_TARGETS],
|
@pytest.mark.parametrize("image,fmt", ALL_TARGETS,
|
||||||
ids=[t[0] for t in ALL_TARGETS])
|
ids=[t[0] for t in ALL_TARGETS])
|
||||||
def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
||||||
version):
|
version):
|
||||||
@@ -983,14 +1010,14 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
|||||||
|
|
||||||
tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}"
|
tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-upgrade-no-disable-{os.getpid()}"
|
container = f"fenris-upgrade-no-disable-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1050,7 +1077,7 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
|||||||
assert rc == 0, "Helper removed during upgrade"
|
assert rc == 0, "Helper removed during upgrade"
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1073,14 +1100,14 @@ def test_rpm_erase_modified_config_preserved(skip_no_docker, version):
|
|||||||
|
|
||||||
tag = "fenris-rpm-erase-mod"
|
tag = "fenris-rpm-erase-mod"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-rpm-erase-{os.getpid()}"
|
container = f"fenris-rpm-erase-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1106,7 +1133,7 @@ def test_rpm_erase_modified_config_preserved(skip_no_docker, version):
|
|||||||
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1129,14 +1156,14 @@ def test_rpm_erase_unmodified_config_removed(skip_no_docker, version):
|
|||||||
|
|
||||||
tag = "fenris-rpm-erase-unmod"
|
tag = "fenris-rpm-erase-unmod"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-rpm-erase-unmod-{os.getpid()}"
|
container = f"fenris-rpm-erase-unmod-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1154,7 +1181,7 @@ def test_rpm_erase_unmodified_config_removed(skip_no_docker, version):
|
|||||||
_assert_package_files_removed(container)
|
_assert_package_files_removed(container)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1166,7 +1193,7 @@ def _seed_make_install_state(container: str, *, include_manifest: bool = True) -
|
|||||||
"""Seed a container with make-install-shaped state."""
|
"""Seed a container with make-install-shaped state."""
|
||||||
cmds = [
|
cmds = [
|
||||||
"groupadd -f fenris",
|
"groupadd -f fenris",
|
||||||
"install -d -o root -g fenris -m 2750 /var/lib/fenris",
|
"install -d -o root -g fenris -m 2770 /var/lib/fenris",
|
||||||
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
||||||
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
|
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
|
||||||
"c.execute('PRAGMA user_version=1'); c.commit(); c.close()\"",
|
"c.execute('PRAGMA user_version=1'); c.commit(); c.close()\"",
|
||||||
@@ -1285,14 +1312,14 @@ def test_no_move_continuity_deb(skip_no_docker, image, version):
|
|||||||
|
|
||||||
tag = f"fenris-no-move-deb-{image.replace(':', '-')}"
|
tag = f"fenris-no-move-deb-{image.replace(':', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-no-move-deb-{os.getpid()}"
|
container = f"fenris-no-move-deb-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1333,7 +1360,7 @@ def test_no_move_continuity_deb(skip_no_docker, image, version):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1360,14 +1387,14 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
|
|||||||
|
|
||||||
tag = "fenris-no-move-rpm"
|
tag = "fenris-no-move-rpm"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||||
check=True, capture_output=True, timeout=300,
|
check=True, capture_output=True, timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-no-move-rpm-{os.getpid()}"
|
container = f"fenris-no-move-rpm-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "run", "-d", "--name", container,
|
_container_cmd("run", "-d", "--name", container,
|
||||||
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1407,5 +1434,5 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container], capture_output=True,
|
_container_cmd("rm", "-f", container), capture_output=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"""Tests for user-scoped TUI preferences (issue #80).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Preference load/save with safe defaults
|
||||||
|
- XDG_CONFIG_HOME user-scoped persistence
|
||||||
|
- Amber default theme, normal-motion default
|
||||||
|
- Invalid/unreadable/unwritable preference data does not crash
|
||||||
|
- Theme presets: Amber, Nord, High Contrast
|
||||||
|
- Reduced motion preference persistence
|
||||||
|
- CLI status and collector behaviour unchanged by preferences
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.preferences import (
|
||||||
|
load_preferences,
|
||||||
|
save_preferences,
|
||||||
|
get_preference_path,
|
||||||
|
PREFERENCE_FILE_NAME,
|
||||||
|
VALID_THEMES,
|
||||||
|
DEFAULT_THEME,
|
||||||
|
DEFAULT_REDUCED_MOTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_prefs_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Create a fake XDG_CONFIG_HOME with fenris subdir."""
|
||||||
|
config_home = tmp_path / ".config"
|
||||||
|
config_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
return config_home
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preference path tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPreferencePath:
|
||||||
|
"""Preference file lives at XDG_CONFIG_HOME/fenris/preferences.json."""
|
||||||
|
|
||||||
|
def test_uses_xdg_config_home(self, tmp_path):
|
||||||
|
"""Path respects XDG_CONFIG_HOME environment variable."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
path = get_preference_path()
|
||||||
|
assert path == config_home / "fenris" / PREFERENCE_FILE_NAME
|
||||||
|
|
||||||
|
def test_default_path_fallback(self):
|
||||||
|
"""When XDG_CONFIG_HOME is unset, falls back to ~/.config."""
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
# Remove XDG_CONFIG_HOME if present
|
||||||
|
os.environ.pop("XDG_CONFIG_HOME", None)
|
||||||
|
path = get_preference_path()
|
||||||
|
assert "fenris" in str(path)
|
||||||
|
assert PREFERENCE_FILE_NAME in str(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default preferences tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDefaults:
|
||||||
|
"""When no preference file exists, defaults are returned."""
|
||||||
|
|
||||||
|
def test_default_theme_is_amber(self):
|
||||||
|
"""Amber is the default theme preset."""
|
||||||
|
assert DEFAULT_THEME == "amber"
|
||||||
|
|
||||||
|
def test_default_reduced_motion_is_false(self):
|
||||||
|
"""Normal motion is the default."""
|
||||||
|
assert DEFAULT_REDUCED_MOTION is False
|
||||||
|
|
||||||
|
def test_valid_themes_are_all_presets(self):
|
||||||
|
"""Three valid presets exist."""
|
||||||
|
assert VALID_THEMES == {"amber", "nord", "high_contrast"}
|
||||||
|
|
||||||
|
def test_load_returns_defaults_when_no_file(self, tmp_path):
|
||||||
|
"""Missing preference file returns safe defaults."""
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "amber"
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Save and load round-trip tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRoundTrip:
|
||||||
|
"""Preferences survive save → load."""
|
||||||
|
|
||||||
|
def test_save_and_load_basic(self, tmp_path):
|
||||||
|
"""Basic theme and motion save/load."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="nord", reduced_motion=True)
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "nord"
|
||||||
|
assert prefs["reduced_motion"] is True
|
||||||
|
|
||||||
|
def test_save_creates_directory(self, tmp_path):
|
||||||
|
"""Save creates the fenris config directory if missing."""
|
||||||
|
config_home = tmp_path / ".config"
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
assert (config_home / "fenris" / PREFERENCE_FILE_NAME).exists()
|
||||||
|
|
||||||
|
def test_overwrites_existing(self, tmp_path):
|
||||||
|
"""Second save overwrites the first."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=True)
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "high_contrast"
|
||||||
|
assert prefs["reduced_motion"] is True
|
||||||
|
|
||||||
|
def test_all_themes_round_trip(self, tmp_path):
|
||||||
|
"""Every valid theme saves and loads correctly."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
for theme in VALID_THEMES:
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme=theme, reduced_motion=False)
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == theme
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Safe failure tests — invalid/unreadable/unwritable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSafeFailures:
|
||||||
|
"""Invalid data never crashes the dashboard."""
|
||||||
|
|
||||||
|
def test_corrupt_json_returns_defaults(self, tmp_path):
|
||||||
|
"""Malformed JSON returns safe defaults."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text("{corrupt json!!")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "amber"
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
def test_unknown_theme_returns_default(self, tmp_path):
|
||||||
|
"""Unrecognized theme value falls back to amber."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
|
||||||
|
json.dumps({"theme": "neon-pink", "reduced_motion": False})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "amber"
|
||||||
|
|
||||||
|
def test_missing_keys_get_defaults(self, tmp_path):
|
||||||
|
"""Partial preference file fills in missing keys."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
|
||||||
|
json.dumps({"theme": "nord"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "nord"
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
def test_unreadable_file_returns_defaults(self, tmp_path):
|
||||||
|
"""Permission denied on preference file returns defaults."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
pref_file = fenris_dir / PREFERENCE_FILE_NAME
|
||||||
|
pref_file.write_text(json.dumps({"theme": "nord"}))
|
||||||
|
pref_file.chmod(0o000)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
# Should fall back to defaults without crashing
|
||||||
|
assert prefs["theme"] in VALID_THEMES
|
||||||
|
|
||||||
|
def test_unwritable_location_returns_defaults(self, tmp_path):
|
||||||
|
"""Read-only config directory returns defaults without crashing."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fenris_dir.chmod(0o555)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
# Should not raise
|
||||||
|
save_preferences(theme="nord", reduced_motion=True)
|
||||||
|
prefs = load_preferences()
|
||||||
|
# Either saved successfully or fell back — either way, no crash
|
||||||
|
assert prefs["theme"] in VALID_THEMES
|
||||||
|
|
||||||
|
def test_non_json_file_returns_defaults(self, tmp_path):
|
||||||
|
"""A file that isn't JSON returns defaults."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text("this is not json")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "amber"
|
||||||
|
|
||||||
|
def test_wrong_type_for_reduced_motion(self, tmp_path):
|
||||||
|
"""Non-boolean reduced_motion falls back to default."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
|
||||||
|
json.dumps({"theme": "amber", "reduced_motion": "yes"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI/collector isolation tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCLIIsolation:
|
||||||
|
"""TUI display preferences do not affect CLI status or collector."""
|
||||||
|
|
||||||
|
def test_cli_status_ignores_preferences(self, tmp_path):
|
||||||
|
"""fenris status output is independent of TUI preferences."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
# Create a valid store with data
|
||||||
|
from fenris.store import init_store
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 1000000, 500000,
|
||||||
|
5, 512000000000, 256000000000, 8765),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Save non-default preferences
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=True)
|
||||||
|
status_text = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
# Status output should NOT contain theme names or motion settings
|
||||||
|
assert "high_contrast" not in status_text.lower()
|
||||||
|
assert "reduced_motion" not in status_text.lower()
|
||||||
|
assert "amber" not in status_text.lower()
|
||||||
|
|
||||||
|
def test_preferences_do_not_alter_store(self, tmp_path):
|
||||||
|
"""Saving preferences never writes to the observation store."""
|
||||||
|
import sqlite3
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
conn = init_store(store_path)
|
||||||
|
# Record store state before preference save
|
||||||
|
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables_before = sorted(r[0] for r in cursor.fetchall())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="nord", reduced_motion=True)
|
||||||
|
|
||||||
|
# Store schema should be unchanged
|
||||||
|
conn = sqlite3.connect(store_path)
|
||||||
|
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables_after = sorted(r[0] for r in cursor.fetchall())
|
||||||
|
conn.close()
|
||||||
|
assert tables_before == tables_after
|
||||||
|
|
||||||
|
|
||||||
|
# Need datetime for CLI isolation test
|
||||||
|
from datetime import datetime, timezone
|
||||||
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_GAP,
|
||||||
_GLYPH_ZERO,
|
_GLYPH_ZERO,
|
||||||
_GLYPH_PARTIAL,
|
_GLYPH_PARTIAL,
|
||||||
|
_MIN_WIDTH,
|
||||||
|
_MIN_HEIGHT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -379,7 +381,10 @@ class TestDenseScreen:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_service_strip_has_four_facts(self, tmp_path):
|
async def test_service_strip_has_four_facts(self, tmp_path):
|
||||||
"""Service strip has four separate facts (boot, timer, collect, freshness)."""
|
"""Service strip has four separate facts (boot, timer, collect, freshness).
|
||||||
|
|
||||||
|
Maker credit removed from service strip (issue #79: single placement in title).
|
||||||
|
"""
|
||||||
conn = init_store(tmp_path / "test.db")
|
conn = init_store(tmp_path / "test.db")
|
||||||
_insert_segment(conn)
|
_insert_segment(conn)
|
||||||
_open_period(conn)
|
_open_period(conn)
|
||||||
@@ -396,7 +401,8 @@ class TestDenseScreen:
|
|||||||
assert "timer:" in strip
|
assert "timer:" in strip
|
||||||
assert "last collect:" in strip
|
assert "last collect:" in strip
|
||||||
assert "freshness:" in strip
|
assert "freshness:" in strip
|
||||||
assert "by Bongbetic" in strip
|
# Maker credit now in titlebox only (issue #79)
|
||||||
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
|
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
|
||||||
@@ -419,7 +425,7 @@ class TestDenseScreen:
|
|||||||
quit_rail = app.query_one("#quit-rail")
|
quit_rail = app.query_one("#quit-rail")
|
||||||
assert "continuity" in strip
|
assert "continuity" in strip
|
||||||
assert "monitoring: active in background · persists across reboots" in strip
|
assert "monitoring: active in background · persists across reboots" in strip
|
||||||
assert "p pause · r resume · c collect · d disclosures" in strip
|
assert "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip
|
||||||
assert "q quit" not in strip
|
assert "q quit" not in strip
|
||||||
assert rail == "q QUIT TUI"
|
assert rail == "q QUIT TUI"
|
||||||
assert usage.region.y < service.region.y < quit_rail.region.y
|
assert usage.region.y < service.region.y < quit_rail.region.y
|
||||||
@@ -525,7 +531,10 @@ class TestDenseScreen:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_branding_and_one_time_auth_banner(self, tmp_path):
|
async def test_branding_and_one_time_auth_banner(self, tmp_path):
|
||||||
"""Identity is visible at launch; auth notice clears once per session."""
|
"""Identity is visible at launch; auth notice clears once per session.
|
||||||
|
|
||||||
|
Maker credit in titlebox only (issue #79: Fenris by Bongbetic).
|
||||||
|
"""
|
||||||
app = FenrisTuiApp(
|
app = FenrisTuiApp(
|
||||||
store_path=tmp_path / "nonexistent.db",
|
store_path=tmp_path / "nonexistent.db",
|
||||||
refresh_interval_s=0.2,
|
refresh_interval_s=0.2,
|
||||||
@@ -534,9 +543,11 @@ class TestDenseScreen:
|
|||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
assert "Fenris — NVMe endurance monitor" in headline
|
# Identity now shows Fenris by Bongbetic (issue #79)
|
||||||
|
assert "Fenris by Bongbetic" in headline
|
||||||
assert auth_notice in headline
|
assert auth_notice in headline
|
||||||
assert "by Bongbetic" in str(app.query_one("#service-strip").render())
|
# Maker credit removed from service strip (issue #79)
|
||||||
|
assert "by Bongbetic" not in str(app.query_one("#service-strip").render())
|
||||||
|
|
||||||
await pilot.pause(0.25)
|
await pilot.pause(0.25)
|
||||||
assert auth_notice not in str(app.query_one("#headline-band").render())
|
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||||||
@@ -1086,3 +1097,282 @@ class TestBarGraphTUI:
|
|||||||
assert _GLYPH_GAP in legend
|
assert _GLYPH_GAP in legend
|
||||||
assert _GLYPH_ZERO in legend
|
assert _GLYPH_ZERO in legend
|
||||||
assert _GLYPH_PARTIAL 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