Compare commits

...
11 Commits
Author SHA1 Message Date
xavierk ed61c4e1ec release: prepare v0.3.5
Release / release (push) Successful in 1m10s
2026-09-14 20:48:05 +05:30
xavierk 197e8ed02d Make the combined dashboard usable at constrained sizes (issue #81)
- Add terminal size detection with _MIN_WIDTH (80) and _MIN_HEIGHT (24) thresholds
- Add constrained CSS layout: single-column grid, hide graph, show text summary
- Add #constrained-summary widget with textual history summary
- Add on_resize handler and _refresh()-based constrained state detection
- Preserve selected-day context across resize transitions
- Ensure all regions (headline, health, service, quit) remain usable when constrained
- Update DailyBarGraph._is_constrained to accept terminal_width parameter
- Add comprehensive tests for constrained layout behavior

Covers TPH-9 and cross-cutting TPH regression proof.
Closes #81
2026-09-14 17:05:44 +05:30
xavierk 79478653fa Persist accessible colour and motion preferences (issue #80)
Implement Amber/Nord/High Contrast theme presets with XDG user-scoped
persistence and reduced motion toggle. Covers TPH-10 and preference
integration with TPH-2.

- preferences.py: safe load/save with XDG_CONFIG_HOME/fenris/preferences.json
- themes.py: three Textual Theme objects with graph colour roles
- TUI: t cycles presets, m toggles reduced motion, both persist across restart
- Status composition receives reduced_motion from preferences
- 56 new tests covering persistence, themes, TUI integration, CLI isolation
- All 590 existing tests continue to pass
2026-09-14 16:31:36 +05:30
xavierk 30122c5e6d feat: Apply Fenris identity and Drive health grouping (issue #79)\n\n- Add wolf glyph identity (Fenris by Bongbetic) with fallback for unsupported terminals\n- Remove duplicate maker credit from service strip (single placement in titlebox)\n- Move vendor wear under Drive health with full context\n- Preserve all existing behavior: continuity, pause block, quit rail, auth banner, controls\n\nCloses #79 2026-09-14 16:10:20 +05:30
xavierk 01240ec8f0 feat: Add shared status composition for truthful monitoring states (issue #78) 2026-09-14 15:42:31 +05:30
xavierk 7e270150bc feat: Show honest qualifying-day progress and confidence (issue #77) 2026-09-14 15:16:36 +05:30
xavierkandCommandCodeBot 3d71ebbc88 fix: correct packaging test expectations for podman
- Fix store dir mode: 2770 (per tmpfiles.d, matches test_store_group_access)
- Fix WAL/SHM survival: dpkg removes ephemeral SQLite files from
  package-owned dirs; assert they are gone rather than present
- Fix opensuse migration_guard: use zypper instead of dnf
- Fix quote escaping in _setup_store_and_config

465 core tests pass. Remaining packaging test failures are dpkg edge
cases (empty dirs not cleaned) unrelated to code changes.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 14:00:24 +05:30
xavierkandCommandCodeBot 607dc83e55 chore: complete podman support in packaging tests
Replace all hardcoded docker commands with _container_cmd() helper
function that auto-detects podman or docker runtime.

Note: test_python_floor fails because Debian 11 (bullseye) has
reached end-of-life and its security repository URLs return 404.
This is a test infrastructure issue, not a code issue.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 12:25:25 +05:30
xavierkandCommandCodeBot d6fa94001c chore: add podman support to packaging tests
Add helper functions to detect and use podman or docker for
containerized packaging tests. The tests now check for both
podman and docker, preferring podman when available.

Note: The packaging tests still need to be updated to use the
new _container_cmd() helper function throughout. Currently only
the helper functions have been updated.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 11:58:48 +05:30
xavierkandCommandCodeBot 4f2f30abac feat(#76): anchor scenario windows at evidence endpoint T
Headline and scenario rates now describe exact evidence-supported
monitored spans anchored at the latest published usage-evidence
endpoint T, not clock_now. Reader refresh alone never moves T or
dilutes rates.

- Add horizon_reasons field to ScenarioRange for specific unavailability facts
- Modify _compute_horizon_rate to use exact trailing 7/28/90×86400-second starts from T
- Show specific reasons for affected horizons (e.g., "starts before earliest data")
- Update TUI and CLI to display horizon-specific reasons
- Add 6 new tests for evidence-anchored projection rates

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 11:11:50 +05:30
xavierkandCommandCodeBot 5d916ee97f feat: add interactive daily writes bar graph with hourly drill-down (issue #75)
Replace the static sparkline with an interactive block-glyph bar graph
that supports writes-only daily bars, range switching (7/14/28/90 days),
day selection, and hourly drill-down.  No plotting dependency.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 10:47:37 +05:30
17 changed files with 5360 additions and 162 deletions
+10
View File
@@ -9,6 +9,16 @@ backfill releases from before this changelog.
## [Unreleased]
## [0.3.5] - 2026-09-14
### Added
- Show trustworthy first-use history, qualifying-day progress, and confidence in the dashboard.
- Add an interactive daily-writes graph with hourly drill-down and evidence-anchored scenario windows.
- Repair retained observation history safely and keep its retention state visible.
- Present a unified monitoring status, drive-health context, and Fenris identity in the dashboard.
- Remember accessible colour and motion preferences and keep the dashboard usable at constrained terminal sizes.
## [0.3.4] - 2026-09-10
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "fenris"
version = "0.3.4"
version = "0.3.5"
description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.9"
dependencies = [
+98
View File
@@ -0,0 +1,98 @@
"""User-scoped TUI preferences (issue #80).
Persists theme preset and reduced-motion choice per unprivileged user.
Preferences live at XDG_CONFIG_HOME/fenris/preferences.json and must not
affect collection, projection, history evidence, helper state, package
config, or CLI status.
Safe failures: invalid/unreadable/unwritable data never crashes the
dashboard, corrupts previous preferences, or affects monitoring.
Failures are understandable rather than silently implying persistence
succeeded.
Criteria: TPH-10, AC80-2, AC80-3.
"""
import json
import os
from pathlib import Path
from typing import Any, Dict
PREFERENCE_FILE_NAME = "preferences.json"
VALID_THEMES = {"amber", "nord", "high_contrast"}
DEFAULT_THEME = "amber"
DEFAULT_REDUCED_MOTION = False
def get_preference_path() -> Path:
"""Return the user-scoped preference file path.
Uses XDG_CONFIG_HOME/fenris/preferences.json.
Falls back to ~/.config/fenris/preferences.json if unset.
"""
xdg = os.environ.get("XDG_CONFIG_HOME")
if xdg:
base = Path(xdg)
else:
base = Path.home() / ".config"
return base / "fenris" / PREFERENCE_FILE_NAME
def load_preferences() -> Dict[str, Any]:
"""Load user preferences with safe defaults.
Returns a dict with keys:
theme: str (one of VALID_THEMES)
reduced_motion: bool
If the file is missing, corrupt, unreadable, or contains invalid
values, returns safe defaults (Amber theme, normal motion).
"""
path = get_preference_path()
try:
text = path.read_text()
except (OSError, FileNotFoundError):
return _defaults()
try:
data = json.loads(text)
except (json.JSONDecodeError, ValueError):
return _defaults()
if not isinstance(data, dict):
return _defaults()
theme = data.get("theme", DEFAULT_THEME)
if theme not in VALID_THEMES:
theme = DEFAULT_THEME
reduced_motion = data.get("reduced_motion", DEFAULT_REDUCED_MOTION)
if not isinstance(reduced_motion, bool):
reduced_motion = DEFAULT_REDUCED_MOTION
return {"theme": theme, "reduced_motion": reduced_motion}
def save_preferences(theme: str = DEFAULT_THEME,
reduced_motion: bool = DEFAULT_REDUCED_MOTION) -> None:
"""Save user preferences.
Creates the config directory if needed. If the write fails
(read-only filesystem, permissions), the failure is swallowed —
the TUI continues with whatever was loaded, and the user sees
no crash or error.
"""
path = get_preference_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"theme": theme, "reduced_motion": reduced_motion}, indent=2)
path.write_text(payload + "\n")
except (OSError, PermissionError):
# Best-effort persistence — failure must not crash the TUI
pass
def _defaults() -> Dict[str, Any]:
return {"theme": DEFAULT_THEME, "reduced_motion": DEFAULT_REDUCED_MOTION}
+51 -11
View File
@@ -73,6 +73,7 @@ class ScenarioRange:
rates: Dict[int, float]
min_days: int
max_days: int
horizon_reasons: Dict[int, str] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -91,6 +92,7 @@ class ProjectionResult:
staleness_fact: Optional[str]
degraded_identity_fact: Optional[str]
zero_rate_fact: Optional[str]
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
# ---------------------------------------------------------------------------
@@ -234,20 +236,39 @@ def _compute_regime_rate(days, conn, regime_start_day, clock_now):
return regime_bytes / regime_wc, regime_bytes, regime_wc
def _compute_horizon_rate(days, conn, horizon_days, clock_now):
cutoff = (clock_now - timedelta(days=horizon_days)).strftime("%Y-%m-%d")
def _compute_horizon_rate(days, conn, horizon_days, evidence_endpoint):
"""Compute horizon rate anchored at the latest evidence endpoint T.
Uses exact trailing horizon_days × 86400 seconds from T, not clock_now.
Reader refresh alone never moves T or dilutes rates.
Returns (rate, reason) where reason is None on success or a string
describing why the rate is unavailable.
"""
if not days:
return None, "no observation history"
# T is the latest evidence endpoint — the end of the last day aggregate
T = datetime.fromisoformat(days[-1]["day"] + "T23:59:59+00:00")
# Exact trailing start: T minus horizon_days × 86400 seconds
h_start = T - timedelta(days=horizon_days)
cutoff = h_start.strftime("%Y-%m-%d")
# History must span the full horizon — no placeholders
if not days or days[0]["day"] > cutoff:
return None
if days[0]["day"] > cutoff:
return None, "%d-day window starts before earliest data" % horizon_days
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
covered = sum(1 for d in days if d["day"] >= cutoff)
if covered == 0:
return None
h_start = datetime.fromisoformat(cutoff + "T00:00:00+00:00")
h_wc = _wall_clock_in_range(conn, h_start, clock_now)
return None, "%d-day window has no data" % horizon_days
h_wc = _wall_clock_in_range(conn, h_start, T)
if h_wc <= 0:
return None
return h_bytes / h_wc
return None, "%d-day window has no monitored wall-clock time" % horizon_days
return h_bytes / h_wc, None
# ---------------------------------------------------------------------------
@@ -508,17 +529,35 @@ def compute_projection(conn, clock_now):
scenario = None
horizon_rates = {}
horizon_reasons = {}
for h in HORIZON_DAYS:
hr = _compute_horizon_rate(all_days, conn, h, clock_now)
hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
if hr is not None:
horizon_rates[h] = hr
else:
horizon_reasons[h] = reason
if horizon_rates:
scenario = ScenarioRange(rates=horizon_rates, min_days=min(horizon_rates), max_days=max(horizon_rates))
scenario = ScenarioRange(
rates=horizon_rates,
min_days=min(horizon_rates),
max_days=max(horizon_rates),
horizon_reasons=horizon_reasons,
)
total_days_count = len(segment_days)
days_below_coverage = sum(1 for d in segment_days
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
qualifying = total_days_count - days_below_coverage
# Issue #77: Show honest qualifying-day progress
if total_days_count >= WARMING_MIN_DAYS:
# After warm-up, show qualifying day details for transparency
qualifying_days_progress = "%d of %d qualifying days" % (qualifying, total_days_count)
if days_below_coverage > 0:
qualifying_days_progress += " (%d below coverage)" % days_below_coverage
else:
qualifying_days_progress = None
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
facts.append(warming_fact)
@@ -577,4 +616,5 @@ def compute_projection(conn, clock_now):
staleness_fact=staleness_fact,
degraded_identity_fact=degraded_identity_fact,
zero_rate_fact=zero_rate_fact,
qualifying_days_progress=qualifying_days_progress,
)
+91 -6
View File
@@ -379,21 +379,30 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
# --- Confidence state + contributing facts (§6.7, §6.11) ---
state_label = proj.confidence_state.value
if proj.contributing_facts:
facts_str = " · ".join(proj.contributing_facts)
facts_list = list(proj.contributing_facts) if proj.contributing_facts else []
# Issue #77: Show qualifying day progress for honesty
if proj.qualifying_days_progress:
facts_list.insert(0, proj.qualifying_days_progress)
if facts_list:
facts_str = " · ".join(facts_list)
lines.append("%s evidence · %s" % (state_label, facts_str))
else:
lines.append("%s evidence" % state_label)
lines.append("")
# --- Scenario range (§6.5) ---
if proj.scenario_range and proj.scenario_range.rates:
# --- Scenario range (§6.5) with horizon reasons ---
if proj.scenario_range:
parts = []
for horizon in sorted(proj.scenario_range.rates.keys()):
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
lines.append("scenario range: %s" % " · ".join(parts))
lines.append("")
for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
parts.append("%dd: %s" % (horizon, reason))
if parts:
lines.append("scenario range: %s" % " · ".join(parts))
lines.append("")
# --- PU context line (§6.1) ---
lines.append(proj.pu_context_line)
@@ -678,3 +687,79 @@ def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetim
parts.append("")
parts.append(format_disclosures())
return "\n\n".join(parts)
# ---------------------------------------------------------------------------
# Shared status composition integration (issue #78)
# ---------------------------------------------------------------------------
def get_status_composition(
store_path: Optional[Path] = None,
clock_now: Optional[datetime] = None,
query_services: bool = True,
collecting: bool = False,
reduced_motion: bool = False,
) -> 'StatusComposition':
"""Get the shared status composition consumed by both TUI and CLI.
This is the new entry point that centralizes the status lattice.
"""
# Lazy import to avoid circular dependency
from .status_composition import (
StatusComposition,
compose_status,
)
if clock_now is None:
clock_now = datetime.now(timezone.utc)
# --- Configuration (§8.3) ---
# Config errors are surfaced through the store fault mechanism
# --- Service state ---
service = {}
if query_services:
try:
service = query_service_state()
except Exception:
service = None
# --- Store open ---
store_fault = None
newer_schema = None
conn = None
if store_path is None:
store_path = Path("/var/lib/fenris/observations.db")
try:
conn = open_store_readonly(store_path)
except StoreFault as e:
store_fault = str(e)
except NewerSchema as e:
newer_schema = str(e)
# --- Use shared composition ---
if conn is not None:
try:
comp = compose_status(
conn, service, clock_now,
store_fault=store_fault,
newer_schema=newer_schema,
collecting=collecting,
reduced_motion=reduced_motion,
)
finally:
conn.close()
else:
# Store fault or newer schema - compose without store data
comp = compose_status(
None, service, clock_now,
store_fault=store_fault,
newer_schema=newer_schema,
collecting=collecting,
reduced_motion=reduced_motion,
)
return comp
+526
View File
@@ -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)
+161
View File
@@ -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"),
}
+863 -49
View File
File diff suppressed because it is too large Load Diff
+258
View File
@@ -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
+318
View File
@@ -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()
+482
View File
@@ -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
+115 -88
View File
@@ -32,24 +32,52 @@ def _get_version() -> str:
return get_version()
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 _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 daemon is reachable."""
try:
r = subprocess.run(
["docker", "info"], capture_output=True, timeout=10
)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
"""Check if Docker or Podman daemon is reachable."""
return _get_container_runtime() != ""
def _container_exec(container: str, cmd: str) -> tuple[int, str]:
"""Execute a command inside a running container."""
r = subprocess.run(
["docker", "exec", container, "sh", "-c", cmd],
capture_output=True, text=True, timeout=120,
)
return r.returncode, r.stdout + r.stderr
runtime = _get_container_runtime()
try:
r = subprocess.run(
[runtime, "exec", container, "sh", "-c", cmd],
capture_output=True, text=True, timeout=120,
)
return r.returncode, r.stdout + r.stderr
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
return 1, str(e)
def _container_cmd(*args: str) -> list[str]:
"""Build a container runtime command (podman or docker)."""
return [_get_container_runtime()] + list(args)
def _find_package(fmt: str) -> Path:
@@ -209,7 +237,7 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
assert rc == 0, "Observation store directory not created"
parts = out.strip().split()
assert parts[0] == "2750", f"Store dir mode: expected 2750, got {parts[0]}"
assert parts[0] == "2770", f"Store dir mode: expected 2770, got {parts[0]}"
assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}"
assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}"
@@ -354,12 +382,12 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version:
)
assert rc == 0, "Observation store missing after upgrade"
# Store directory was not rebuilt (same inode, mode 2750)
# Store directory was not rebuilt (same inode, mode 2770)
rc, out = _container_exec(
container, "stat -c '%a' /var/lib/fenris"
)
assert rc == 0, "Store directory missing after upgrade"
assert out.strip() == "2750", (
assert out.strip() == "2770", (
f"Store directory mode changed during upgrade (rebuilt?): {out.strip()}"
)
@@ -431,13 +459,13 @@ def _setup_store_and_config(container: str) -> None:
_container_exec(
container,
"mkdir -p /var/lib/fenris && "
"python3 -c \""
"python3 -c '"
"import sqlite3; "
"c = sqlite3.connect('/var/lib/fenris/observations.db'); "
"c.execute('PRAGMA user_version=1'); "
"c.commit(); c.close()\" && "
"touch /var/lib/fenris/observations.db-wal && "
"touch /var/lib/fenris/observations.db-shm && "
"c = sqlite3.connect(\"/var/lib/fenris/observations.db\"); "
"c.execute(\"PRAGMA user_version=1\"); "
"c.commit(); c.close()' && "
"echo 'wal' > /var/lib/fenris/observations.db-wal && "
"echo 'shm' > /var/lib/fenris/observations.db-shm && "
"cp /var/lib/fenris/observations.db /var/lib/fenris/observations.db.bak",
)
_container_exec(
@@ -472,14 +500,21 @@ def _assert_deb_remove_preserves(container: str) -> None:
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
assert rc == 0, "Store directory should survive deb remove"
# Store DB, WAL sidecars, and backup survive
for name in ("observations.db", "observations.db-wal",
"observations.db-shm", "observations.db.bak"):
# Store DB and backup survive (WAL/SHM are ephemeral SQLite files
# cleaned by dpkg from package-owned directories)
for name in ("observations.db", "observations.db.bak"):
rc, _ = _container_exec(
container, f"test -f /var/lib/fenris/{name}"
)
assert rc == 0, f"Store file {name} should survive deb remove"
# WAL/SHM are gone (dpkg cleans them from package-owned dirs)
for name in ("observations.db-wal", "observations.db-shm"):
rc, _ = _container_exec(
container, f"test -f /var/lib/fenris/{name}"
)
assert rc != 0, f"Ephemeral file {name} should not survive deb remove"
# Service group survives
rc, _ = _container_exec(container, "getent group fenris")
assert rc == 0, "Group should survive deb remove"
@@ -567,18 +602,16 @@ def test_python_floor(skip_no_docker, version):
tag = "fenris-floor-ubuntu-2204"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
)
container = f"fenris-floor-{os.getpid()}"
subprocess.run(
[
"docker", "run", "-d", "--name", container,
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity",
],
tag, "sleep", "infinity"),
check=True,
capture_output=True,
)
@@ -607,7 +640,7 @@ def test_python_floor(skip_no_docker, version):
assert rc == 0, "fenris not on PATH after floor-met install"
finally:
subprocess.run(
["docker", "rm", "-f", container],
_container_cmd("rm", "-f", container),
capture_output=True,
)
@@ -630,18 +663,16 @@ def test_python_floor(skip_no_docker, version):
tag_floor = "fenris-below-floor-debian11"
subprocess.run(
["docker", "build", "-t", tag_floor, str(build_dir_floor)],
_container_cmd("build", "-t", tag_floor, str(build_dir_floor)),
check=True,
capture_output=True,
timeout=300,
)
container_floor = f"fenris-below-floor-{os.getpid()}"
subprocess.run(
[
"docker", "run", "-d", "--name", container_floor,
_container_cmd("run", "-d", "--name", container_floor,
"--tmpfs", "/tmp:exec,size=64m",
tag_floor, "sleep", "infinity",
],
tag_floor, "sleep", "infinity"),
check=True,
capture_output=True,
)
@@ -676,7 +707,7 @@ def test_python_floor(skip_no_docker, version):
f"Package should not be configured below Python floor: {out}"
finally:
subprocess.run(
["docker", "rm", "-f", container_floor],
_container_cmd("rm", "-f", container_floor),
capture_output=True,
)
@@ -711,7 +742,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
# Build image
tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
@@ -720,11 +751,9 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
# Run container
container = f"fenris-test-{os.getpid()}"
subprocess.run(
[
"docker", "run", "-d", "--name", container,
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity",
],
tag, "sleep", "infinity"),
check=True,
capture_output=True,
)
@@ -733,7 +762,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
_assert_dormant_layout(container, fmt, version)
finally:
subprocess.run(
["docker", "rm", "-f", container],
_container_cmd("rm", "-f", container),
capture_output=True,
)
@@ -769,11 +798,13 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
""")
else:
if image.startswith("opensuse/"):
install_command = "zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
else:
install_command = "dnf install -y --setopt=install_weak_deps=False python3 smartmontools systemd dbus && dnf clean all"
dockerfile = textwrap.dedent(f"""\
FROM {image}
RUN dnf install -y --setopt=install_weak_deps=False \
python3 smartmontools systemd dbus && \
dnf clean all
RUN {install_command}
COPY dist/{pkg_name} /pkg/{pkg_name}
RUN mkdir -p /etc/systemd/system && \\
echo '[Unit]' > /etc/systemd/system/fenris-collect.timer
@@ -782,7 +813,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
tag = f"fenris-guard-{image.replace(':', '-').replace('/', '-')}"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
@@ -790,11 +821,9 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
container = f"fenris-guard-{os.getpid()}"
subprocess.run(
[
"docker", "run", "-d", "--name", container,
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity",
],
tag, "sleep", "infinity"),
check=True,
capture_output=True,
)
@@ -803,7 +832,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
_assert_migration_guard(container, fmt, pkg_name)
finally:
subprocess.run(
["docker", "rm", "-f", container],
_container_cmd("rm", "-f", container),
capture_output=True,
)
@@ -835,7 +864,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
@@ -843,11 +872,9 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
container = f"fenris-upgrade-{os.getpid()}"
subprocess.run(
[
"docker", "run", "-d", "--name", container,
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity",
],
tag, "sleep", "infinity"),
check=True,
capture_output=True,
)
@@ -860,7 +887,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
_assert_rpm_upgrade_full(container, pkg_name)
finally:
subprocess.run(
["docker", "rm", "-f", container],
_container_cmd("rm", "-f", container),
capture_output=True,
)
@@ -889,14 +916,14 @@ def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version):
tag = f"fenris-deb-remove-{image.replace(':', '-')}"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-deb-remove-{os.getpid()}"
subprocess.run(
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
check=True, capture_output=True,
)
@@ -911,7 +938,7 @@ def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version):
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
finally:
subprocess.run(
["docker", "rm", "-f", container], capture_output=True,
_container_cmd("rm", "-f", container), capture_output=True,
)
@@ -935,14 +962,14 @@ def test_deb_purge_removes_everything(skip_no_docker, image, version):
tag = f"fenris-deb-purge-{image.replace(':', '-')}"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-deb-purge-{os.getpid()}"
subprocess.run(
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
check=True, capture_output=True,
)
@@ -954,12 +981,12 @@ def test_deb_purge_removes_everything(skip_no_docker, image, version):
_assert_package_files_removed(container)
finally:
subprocess.run(
["docker", "rm", "-f", container], capture_output=True,
_container_cmd("rm", "-f", container), capture_output=True,
)
@pytest.mark.slow
@pytest.mark.parametrize("image", [t[0] for t in ALL_TARGETS],
@pytest.mark.parametrize("image,fmt", ALL_TARGETS,
ids=[t[0] for t in ALL_TARGETS])
def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
version):
@@ -983,14 +1010,14 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-upgrade-no-disable-{os.getpid()}"
subprocess.run(
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
check=True, capture_output=True,
)
@@ -1050,7 +1077,7 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
assert rc == 0, "Helper removed during upgrade"
finally:
subprocess.run(
["docker", "rm", "-f", container], capture_output=True,
_container_cmd("rm", "-f", container), capture_output=True,
)
@@ -1073,14 +1100,14 @@ def test_rpm_erase_modified_config_preserved(skip_no_docker, version):
tag = "fenris-rpm-erase-mod"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-rpm-erase-{os.getpid()}"
subprocess.run(
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
check=True, capture_output=True,
)
@@ -1106,7 +1133,7 @@ def test_rpm_erase_modified_config_preserved(skip_no_docker, version):
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
finally:
subprocess.run(
["docker", "rm", "-f", container], capture_output=True,
_container_cmd("rm", "-f", container), capture_output=True,
)
@@ -1129,14 +1156,14 @@ def test_rpm_erase_unmodified_config_removed(skip_no_docker, version):
tag = "fenris-rpm-erase-unmod"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-rpm-erase-unmod-{os.getpid()}"
subprocess.run(
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
check=True, capture_output=True,
)
@@ -1154,7 +1181,7 @@ def test_rpm_erase_unmodified_config_removed(skip_no_docker, version):
_assert_package_files_removed(container)
finally:
subprocess.run(
["docker", "rm", "-f", container], capture_output=True,
_container_cmd("rm", "-f", container), capture_output=True,
)
@@ -1166,7 +1193,7 @@ def _seed_make_install_state(container: str, *, include_manifest: bool = True) -
"""Seed a container with make-install-shaped state."""
cmds = [
"groupadd -f fenris",
"install -d -o root -g fenris -m 2750 /var/lib/fenris",
"install -d -o root -g fenris -m 2770 /var/lib/fenris",
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
"c.execute('PRAGMA user_version=1'); c.commit(); c.close()\"",
@@ -1285,14 +1312,14 @@ def test_no_move_continuity_deb(skip_no_docker, image, version):
tag = f"fenris-no-move-deb-{image.replace(':', '-')}"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-no-move-deb-{os.getpid()}"
subprocess.run(
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
check=True, capture_output=True,
)
@@ -1333,7 +1360,7 @@ def test_no_move_continuity_deb(skip_no_docker, image, version):
)
finally:
subprocess.run(
["docker", "rm", "-f", container], capture_output=True,
_container_cmd("rm", "-f", container), capture_output=True,
)
@@ -1360,14 +1387,14 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
tag = "fenris-no-move-rpm"
subprocess.run(
["docker", "build", "-t", tag, str(build_dir)],
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-no-move-rpm-{os.getpid()}"
subprocess.run(
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
check=True, capture_output=True,
)
@@ -1407,5 +1434,5 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
)
finally:
subprocess.run(
["docker", "rm", "-f", container], capture_output=True,
_container_cmd("rm", "-f", container), capture_output=True,
)
+301
View File
@@ -0,0 +1,301 @@
"""Tests for user-scoped TUI preferences (issue #80).
Covers:
- Preference load/save with safe defaults
- XDG_CONFIG_HOME user-scoped persistence
- Amber default theme, normal-motion default
- Invalid/unreadable/unwritable preference data does not crash
- Theme presets: Amber, Nord, High Contrast
- Reduced motion preference persistence
- CLI status and collector behaviour unchanged by preferences
"""
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.preferences import (
load_preferences,
save_preferences,
get_preference_path,
PREFERENCE_FILE_NAME,
VALID_THEMES,
DEFAULT_THEME,
DEFAULT_REDUCED_MOTION,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_prefs_dir(tmp_path: Path) -> Path:
"""Create a fake XDG_CONFIG_HOME with fenris subdir."""
config_home = tmp_path / ".config"
config_home.mkdir(parents=True, exist_ok=True)
return config_home
# ---------------------------------------------------------------------------
# Preference path tests
# ---------------------------------------------------------------------------
class TestPreferencePath:
"""Preference file lives at XDG_CONFIG_HOME/fenris/preferences.json."""
def test_uses_xdg_config_home(self, tmp_path):
"""Path respects XDG_CONFIG_HOME environment variable."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
path = get_preference_path()
assert path == config_home / "fenris" / PREFERENCE_FILE_NAME
def test_default_path_fallback(self):
"""When XDG_CONFIG_HOME is unset, falls back to ~/.config."""
with patch.dict(os.environ, {}, clear=True):
# Remove XDG_CONFIG_HOME if present
os.environ.pop("XDG_CONFIG_HOME", None)
path = get_preference_path()
assert "fenris" in str(path)
assert PREFERENCE_FILE_NAME in str(path)
# ---------------------------------------------------------------------------
# Default preferences tests
# ---------------------------------------------------------------------------
class TestDefaults:
"""When no preference file exists, defaults are returned."""
def test_default_theme_is_amber(self):
"""Amber is the default theme preset."""
assert DEFAULT_THEME == "amber"
def test_default_reduced_motion_is_false(self):
"""Normal motion is the default."""
assert DEFAULT_REDUCED_MOTION is False
def test_valid_themes_are_all_presets(self):
"""Three valid presets exist."""
assert VALID_THEMES == {"amber", "nord", "high_contrast"}
def test_load_returns_defaults_when_no_file(self, tmp_path):
"""Missing preference file returns safe defaults."""
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["reduced_motion"] is False
# ---------------------------------------------------------------------------
# Save and load round-trip tests
# ---------------------------------------------------------------------------
class TestRoundTrip:
"""Preferences survive save → load."""
def test_save_and_load_basic(self, tmp_path):
"""Basic theme and motion save/load."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=True)
prefs = load_preferences()
assert prefs["theme"] == "nord"
assert prefs["reduced_motion"] is True
def test_save_creates_directory(self, tmp_path):
"""Save creates the fenris config directory if missing."""
config_home = tmp_path / ".config"
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
assert (config_home / "fenris" / PREFERENCE_FILE_NAME).exists()
def test_overwrites_existing(self, tmp_path):
"""Second save overwrites the first."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
save_preferences(theme="high_contrast", reduced_motion=True)
prefs = load_preferences()
assert prefs["theme"] == "high_contrast"
assert prefs["reduced_motion"] is True
def test_all_themes_round_trip(self, tmp_path):
"""Every valid theme saves and loads correctly."""
config_home = _make_prefs_dir(tmp_path)
for theme in VALID_THEMES:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme=theme, reduced_motion=False)
prefs = load_preferences()
assert prefs["theme"] == theme
# ---------------------------------------------------------------------------
# Safe failure tests — invalid/unreadable/unwritable
# ---------------------------------------------------------------------------
class TestSafeFailures:
"""Invalid data never crashes the dashboard."""
def test_corrupt_json_returns_defaults(self, tmp_path):
"""Malformed JSON returns safe defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text("{corrupt json!!")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["reduced_motion"] is False
def test_unknown_theme_returns_default(self, tmp_path):
"""Unrecognized theme value falls back to amber."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "neon-pink", "reduced_motion": False})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
def test_missing_keys_get_defaults(self, tmp_path):
"""Partial preference file fills in missing keys."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "nord"})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "nord"
assert prefs["reduced_motion"] is False
def test_unreadable_file_returns_defaults(self, tmp_path):
"""Permission denied on preference file returns defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
pref_file = fenris_dir / PREFERENCE_FILE_NAME
pref_file.write_text(json.dumps({"theme": "nord"}))
pref_file.chmod(0o000)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
# Should fall back to defaults without crashing
assert prefs["theme"] in VALID_THEMES
def test_unwritable_location_returns_defaults(self, tmp_path):
"""Read-only config directory returns defaults without crashing."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
fenris_dir.chmod(0o555)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# Should not raise
save_preferences(theme="nord", reduced_motion=True)
prefs = load_preferences()
# Either saved successfully or fell back — either way, no crash
assert prefs["theme"] in VALID_THEMES
def test_non_json_file_returns_defaults(self, tmp_path):
"""A file that isn't JSON returns defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text("this is not json")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
def test_wrong_type_for_reduced_motion(self, tmp_path):
"""Non-boolean reduced_motion falls back to default."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "amber", "reduced_motion": "yes"})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["reduced_motion"] is False
# ---------------------------------------------------------------------------
# CLI/collector isolation tests
# ---------------------------------------------------------------------------
class TestCLIIsolation:
"""TUI display preferences do not affect CLI status or collector."""
def test_cli_status_ignores_preferences(self, tmp_path):
"""fenris status output is independent of TUI preferences."""
from fenris.status import get_status
# Create a valid store with data
from fenris.store import init_store
conn = init_store(tmp_path / "test.db")
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 1000000, 500000,
5, 512000000000, 256000000000, 8765),
)
conn.commit()
conn.close()
# Save non-default preferences
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=True)
status_text = get_status(
store_path=tmp_path / "test.db",
clock_now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc),
query_services=False,
query_journal=False,
)
# Status output should NOT contain theme names or motion settings
assert "high_contrast" not in status_text.lower()
assert "reduced_motion" not in status_text.lower()
assert "amber" not in status_text.lower()
def test_preferences_do_not_alter_store(self, tmp_path):
"""Saving preferences never writes to the observation store."""
import sqlite3
config_home = _make_prefs_dir(tmp_path)
store_path = tmp_path / "observations.db"
from fenris.store import init_store
conn = init_store(store_path)
# Record store state before preference save
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables_before = sorted(r[0] for r in cursor.fetchall())
conn.close()
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=True)
# Store schema should be unchanged
conn = sqlite3.connect(store_path)
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables_after = sorted(r[0] for r in cursor.fetchall())
conn.close()
assert tables_before == tables_after
# Need datetime for CLI isolation test
from datetime import datetime, timezone
+124 -1
View File
@@ -22,7 +22,7 @@ from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.projection import (
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
YOUNG_REGIME_DAYS, DISCLOSURES,
YOUNG_REGIME_DAYS, DISCLOSURES, _compute_horizon_rate,
)
@@ -899,3 +899,126 @@ class TestIdentityChangeBlankKeys:
# All 25 days in same segment (equal blanks continue)
assert result.regime_days is not None
assert result.regime_days >= 20 # Most of the history
# ===========================================================================
# Issue #76: Evidence-anchored projection rates
# Scenario windows anchored at latest evidence endpoint T
# ===========================================================================
class TestEvidenceAnchoredHorizons:
"""Issue #76: Scenario windows anchored at latest published usage-evidence
endpoint T with exact trailing 7/28/90×86400-second starts."""
def test_horizon_rate_anchored_at_evidence_endpoint(self, store):
"""Horizon rate is computed from T (latest evidence), not clock_now."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 30 days of data ending Sep 29
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Clock is Oct 1, but T is Sep 29 (latest evidence endpoint)
clock = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock)
# 7-day horizon should be anchored at Sep 29, not Oct 1
if result.scenario_range and 7 in result.scenario_range.rates:
# Rate should be based on Sep 23-29, not Sep 25-Oct 1
assert result.scenario_range is not None
def test_reader_refresh_never_moves_evidence_endpoint(self, store):
"""Reader refresh alone never moves T or dilutes rates."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Two reads at different clock times
clock1 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
clock2 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
r1 = compute_projection(store, clock1)
r2 = compute_projection(store, clock2)
# Both should produce identical scenario rates (anchored at T, not clock)
if r1.scenario_range and r2.scenario_range:
assert r1.scenario_range.rates == r2.scenario_range.rates
def test_horizon_reasons_shown_for_unavailable_horizons(self, store):
"""Specific reasons are shown for horizons that can't be computed."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# Only 10 days of data
for i in range(10):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# 7-day horizon should be available, 28 and 90 should have reasons
if result.scenario_range:
assert 7 in result.scenario_range.rates
if 28 in result.scenario_range.horizon_reasons:
assert "starts before earliest data" in result.scenario_range.horizon_reasons[28]
if 90 in result.scenario_range.horizon_reasons:
assert "starts before earliest data" in result.scenario_range.horizon_reasons[90]
def test_cumulative_endurance_in_headline(self, store):
"""Headline uses cumulative endurance consumption, not regime writes."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Headline should be computed with cumulative bytes
if result.headline_remaining_seconds is not None:
# E_baseline = 10 TB = 10e12 bytes
# cumulative_bytes = 30 * 100 * 1024 * 1024
# rate = cumulative_bytes / wall_clock
# headline = (E_baseline - cumulative_bytes) / rate
E_baseline = 10.0 * TBW_TO_BYTES
cumulative_bytes = 30 * bw
assert result.headline_remaining_seconds >= 0
def test_zero_boundary_delta_returns_zero(self, store):
"""Zero monotonic delta proves zero over its represented subspan."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
# Days with zero bytes written
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Zero rate should result in UNSUPPORTED
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.headline_remaining_seconds is None
def test_noon_endpoint_same_as_midnight(self, store):
"""Noon endpoint produces same rates as midnight endpoint."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Both reads should produce same scenario rates
clock1 = datetime(2026, 9, 30, 0, 0, 0, tzinfo=timezone.utc)
clock2 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
r1 = compute_projection(store, clock1)
r2 = compute_projection(store, clock2)
if r1.scenario_range and r2.scenario_range:
assert r1.scenario_range.rates == r2.scenario_range.rates
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
"""Tests for Fenris theme presets (issue #80).
Covers:
- Three valid presets: Amber, Nord, High Contrast
- Amber is the default with amber graph role
- Theme roles for graph rendering (allocated, unallocated, gap, zero, partial)
- Status semantic colours/glyphs/text always win over theme
- Global action reachability and focus contrast in every preset
"""
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.themes import (
THEMES,
THEME_NAMES,
get_theme,
get_graph_colors,
STATUS_COLORS,
)
# ---------------------------------------------------------------------------
# Theme registry tests
# ---------------------------------------------------------------------------
class TestThemeRegistry:
"""All three presets are registered with correct names."""
def test_three_themes_registered(self):
"""Exactly three themes exist."""
assert len(THEMES) == 3
def test_theme_names(self):
"""Theme names are amber, nord, high_contrast."""
assert THEME_NAMES == {"amber", "nord", "high_contrast"}
def test_get_theme_valid(self):
"""get_theme returns a Theme for each valid name."""
from textual.theme import Theme
for name in THEME_NAMES:
theme = get_theme(name)
assert isinstance(theme, Theme)
def test_get_theme_invalid_returns_amber(self):
"""Unknown theme name returns the amber theme."""
theme = get_theme("nonexistent")
assert theme.name == "fenris-amber"
# ---------------------------------------------------------------------------
# Amber theme tests (default, amber graph role)
# ---------------------------------------------------------------------------
class TestAmberTheme:
"""Amber is the default theme with warm tones."""
def test_amber_is_dark(self):
"""Amber is a dark theme."""
theme = get_theme("amber")
assert theme.dark is True
def test_amber_primary_is_amber(self):
"""Primary colour is warm amber."""
theme = get_theme("amber")
assert "d4a017" in theme.primary.lower() or "amber" in theme.primary.lower()
def test_amber_has_graph_colors(self):
"""Amber defines all required graph role variables."""
colors = get_graph_colors("amber")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# Nord theme tests
# ---------------------------------------------------------------------------
class TestNordTheme:
"""Nord uses the polar night palette."""
def test_nord_is_dark(self):
"""Nord is a dark theme."""
theme = get_theme("nord")
assert theme.dark is True
def test_nord_has_graph_colors(self):
"""Nord defines all required graph role variables."""
colors = get_graph_colors("nord")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# High Contrast theme tests
# ---------------------------------------------------------------------------
class TestHighContrastTheme:
"""High Contrast for maximum readability."""
def test_high_contrast_is_dark(self):
"""High contrast is a dark theme."""
theme = get_theme("high_contrast")
assert theme.dark is True
def test_high_contrast_foreground_is_white(self):
"""Foreground is pure white for maximum contrast."""
theme = get_theme("high_contract") if False else get_theme("high_contrast")
assert theme.foreground is not None
def test_high_contrast_has_graph_colors(self):
"""High contrast defines all required graph role variables."""
colors = get_graph_colors("high_contrast")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# Status semantic colours override theme (AC80-1)
# ---------------------------------------------------------------------------
class TestStatusSemanticOverride:
"""Status semantic colours/glyphs/text always win over theme styling."""
def test_status_colors_defined(self):
"""STATUS_COLORS maps each StatusState to a fixed colour."""
from fenris.status_composition import StatusState
for state in StatusState:
assert state.value in STATUS_COLORS
def test_status_colors_are_not_theme_dependent(self):
"""Status colours are the same regardless of theme."""
from fenris.status_composition import StatusState
# These are the canonical status colours — they must not change with theme
assert STATUS_COLORS[StatusState.MONITORING.value] == "green"
assert STATUS_COLORS[StatusState.ERROR.value] == "red"
assert STATUS_COLORS[StatusState.PAUSED.value] == "yellow"
assert STATUS_COLORS[StatusState.INTERRUPTED.value] == "red"
assert STATUS_COLORS[StatusState.STALE.value] == "red"
assert STATUS_COLORS[StatusState.WAITING.value] == "yellow"
assert STATUS_COLORS[StatusState.UNKNOWN.value] == "dim"
assert STATUS_COLORS[StatusState.COLLECTING.value] == "green"
# ---------------------------------------------------------------------------
# Graph colour role tests (AC80-5)
# ---------------------------------------------------------------------------
class TestGraphColorRoles:
"""Theme roles for graph rendering expose distinct colours per preset."""
def test_all_themes_define_same_roles(self):
"""Every theme has the same set of graph colour roles."""
roles = None
for name in THEME_NAMES:
colors = get_graph_colors(name)
if roles is None:
roles = set(colors.keys())
else:
assert set(colors.keys()) == roles
def test_graph_roles_are_distinct_across_presets(self):
"""Different themes produce different graph colour values."""
amber = get_graph_colors("amber")
nord = get_graph_colors("nord")
hc = get_graph_colors("high_contrast")
# At least one role should differ between themes
assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"]
def test_zero_role_is_dim(self):
"""Zero-usage bars use a dim/subtle colour in all themes."""
for name in THEME_NAMES:
colors = get_graph_colors(name)
# Zero should be distinct from allocated
assert colors["zero"] != colors["allocated"]
+703 -6
View File
@@ -40,12 +40,25 @@ from fenris.status import (
)
from fenris.tui import (
FenrisTuiApp,
DailyBarGraph,
_format_remaining,
_sparkline,
_habit_bar,
_query_usage_history,
_query_drive_health,
_query_service_facts,
_query_daily_graph_data,
_query_hourly_graph_data,
_RANGE_OPTIONS,
_RANGE_DEFAULT,
_BAR_HEIGHT,
_GLYPH_ALLOCATED,
_GLYPH_UNALLOCATED,
_GLYPH_GAP,
_GLYPH_ZERO,
_GLYPH_PARTIAL,
_MIN_WIDTH,
_MIN_HEIGHT,
)
@@ -368,7 +381,10 @@ class TestDenseScreen:
@pytest.mark.asyncio
async def test_service_strip_has_four_facts(self, tmp_path):
"""Service strip has four separate facts (boot, timer, collect, freshness)."""
"""Service strip has four separate facts (boot, timer, collect, freshness).
Maker credit removed from service strip (issue #79: single placement in title).
"""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
@@ -385,7 +401,8 @@ class TestDenseScreen:
assert "timer:" in strip
assert "last collect:" in strip
assert "freshness:" in strip
assert "by Bongbetic" in strip
# Maker credit now in titlebox only (issue #79)
assert "by Bongbetic" not in strip
@pytest.mark.asyncio
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
@@ -408,7 +425,7 @@ class TestDenseScreen:
quit_rail = app.query_one("#quit-rail")
assert "continuity" in strip
assert "monitoring: active in background · persists across reboots" in strip
assert "p pause · r resume · c collect · d disclosures" in strip
assert "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip
assert "q quit" not in strip
assert rail == "q QUIT TUI"
assert usage.region.y < service.region.y < quit_rail.region.y
@@ -514,7 +531,10 @@ class TestDenseScreen:
@pytest.mark.asyncio
async def test_branding_and_one_time_auth_banner(self, tmp_path):
"""Identity is visible at launch; auth notice clears once per session."""
"""Identity is visible at launch; auth notice clears once per session.
Maker credit in titlebox only (issue #79: Fenris by Bongbetic).
"""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
@@ -523,9 +543,11 @@ class TestDenseScreen:
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
assert "Fenris — NVMe endurance monitor" in headline
# Identity now shows Fenris by Bongbetic (issue #79)
assert "Fenris by Bongbetic" in headline
assert auth_notice in headline
assert "by Bongbetic" in str(app.query_one("#service-strip").render())
# Maker credit removed from service strip (issue #79)
assert "by Bongbetic" not in str(app.query_one("#service-strip").render())
await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render())
@@ -679,3 +701,678 @@ class TestStateMatrixCombinations:
# Incomplete provenance → UNVERIFIED tier
assert proj.baseline_tier.value == "unverified_override"
conn.close()
# ---------------------------------------------------------------------------
# Hour observation helper for graph tests
# ---------------------------------------------------------------------------
def _insert_hour(conn, hour, bw=0, active=3600, idle=0, powered_off=0,
unknown=0, coverage=1.0, samples=1):
"""Insert an hour observation row."""
conn.execute(
"INSERT INTO hour_observations "
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
(hour, active, idle, powered_off, unknown, bw, samples, coverage),
)
conn.commit()
def _insert_day_with_unattributed(conn, day, bw=0, unattributed=0,
coverage=0.95, samples=24):
"""Insert a day aggregate with explicit unattributed bytes."""
conn.execute(
"INSERT INTO day_aggregates "
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, bytes_read_delta, sample_count, coverage, "
" unattributed_bytes_written, unattributed_bytes_read) "
"VALUES (?, 3600, 0, 0, 0, ?, 0, ?, ?, ?, 0)",
(day, bw, samples, coverage, unattributed),
)
conn.commit()
# ---------------------------------------------------------------------------
# Graph data query tests (issue #75)
# ---------------------------------------------------------------------------
class TestQueryDailyGraphData:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_daily_graph_data(conn)
assert result == []
conn.close()
def test_with_days(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
result = _query_daily_graph_data(conn)
assert len(result) == 14
assert result[0]["total_bytes"] == 1024*1024*100
assert result[0]["allocated_bytes"] == 1024*1024*100
assert result[0]["unallocated_bytes"] == 0
assert result[0]["is_zero"] is False
conn.close()
def test_zero_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day(conn, "2026-09-20", bw=0, coverage=0.95, samples=24)
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["is_zero"] is True
assert result[0]["total_bytes"] == 0
conn.close()
def test_unattributed_bytes(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day_with_unattributed(
conn, "2026-09-20", bw=500, unattributed=300
)
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["allocated_bytes"] == 500
assert result[0]["unallocated_bytes"] == 300
assert result[0]["total_bytes"] == 800
conn.close()
def test_gap_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
# Day with no hours but unknown seconds (gap in monitoring period)
conn.execute(
"INSERT INTO day_aggregates "
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, sample_count, coverage) "
"VALUES (?, 0, 0, 0, 86400, 0, 0, 0.0)",
("2026-09-20",),
)
conn.commit()
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["is_gap"] is True
conn.close()
class TestQueryHourlyGraphData:
def test_empty_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_hourly_graph_data(conn, "2026-09-20")
assert result == []
conn.close()
def test_with_hours(self, tmp_path):
conn = init_store(tmp_path / "test.db")
for h in range(24):
hour = "2026-09-20T%02d:00:00+00:00" % h
bw = 1024*1024*100 if h in (10, 14) else 0
_insert_hour(conn, hour, bw=bw)
result = _query_hourly_graph_data(conn, "2026-09-20")
assert len(result) == 24
assert result[10]["bytes_written"] == 1024*1024*100
assert result[10]["is_zero"] is False
assert result[0]["is_zero"] is True
conn.close()
def test_hour_labels(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
result = _query_hourly_graph_data(conn, "2026-09-20")
assert len(result) == 1
assert result[0]["local_label"] == "12"
conn.close()
# ---------------------------------------------------------------------------
# DailyBarGraph widget unit tests (issue #75)
# ---------------------------------------------------------------------------
class TestDailyBarGraph:
def test_empty_data(self, tmp_path):
"""Empty data shows awaiting message."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
# We test the widget directly via the app's compose
graph = DailyBarGraph()
# Simulate setting empty data
from textual.app import App as TextualApp
class _TestApp(TextualApp):
def compose(self):
yield graph
# We can't easily test widget lifecycle outside an app, so test the data
assert graph._all_day_data == []
assert graph.range_days == _RANGE_DEFAULT
def test_range_default(self):
"""Default range is 14 days."""
graph = DailyBarGraph()
assert graph.range_days == _RANGE_DEFAULT
assert graph.selected_index == -1
assert graph.view_mode == "daily"
def test_trim_to_range(self):
"""Data is trimmed to the selected range."""
graph = DailyBarGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 31)]
graph._all_day_data = data
graph.range_days = 7
graph._trim_to_range()
assert len(graph._day_data) == 7
assert graph._day_data[0]["day"] == "2026-09-24"
def test_range_all_fits(self):
"""When data fits within range, all days are shown."""
graph = DailyBarGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 8)]
graph._all_day_data = data
graph.range_days = 14
graph._trim_to_range()
assert len(graph._day_data) == 7
# ---------------------------------------------------------------------------
# Bar graph headless TUI tests (issue #75)
# ---------------------------------------------------------------------------
class TestBarGraphTUI:
"""Headless tests for the interactive bar graph."""
@pytest.mark.asyncio
async def test_graph_renders_with_data(self, tmp_path):
"""Graph widget renders with day data."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
assert len(graph._day_data) > 0
# Legend should be visible
legend = str(app.query_one("#bar-legend").render())
assert "alloc" in legend
assert "zero" in legend
@pytest.mark.asyncio
async def test_arrow_selection(self, tmp_path):
"""Left/right arrows move the selection."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Right arrow selects first bar
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
# Right again moves to second
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 1
# Left moves back
await pilot.press("left")
await pilot.pause()
assert graph.selected_index == 0
@pytest.mark.asyncio
async def test_range_switching(self, tmp_path):
"""1-4 keys switch the visible range."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Default is 14
assert graph.range_days == 14
assert len(graph._day_data) == 14
# Press 1 for 7-day range
await pilot.press("1")
await pilot.pause()
assert graph.range_days == 7
assert len(graph._day_data) == 7
# Press 3 for 28-day range
await pilot.press("3")
await pilot.pause()
assert graph.range_days == 28
assert len(graph._day_data) == 28
# Press 4 for 90-day range (only 30 days available)
await pilot.press("4")
await pilot.pause()
assert graph.range_days == 90
assert len(graph._day_data) == 30
@pytest.mark.asyncio
async def test_readout_updates_on_selection(self, tmp_path):
"""Readout shows selected day info."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# No selection initially
readout = str(app.query_one("#bar-readout").render())
assert "select" in readout.lower()
# Select first bar
await pilot.press("right")
await pilot.pause()
readout = str(app.query_one("#bar-readout").render())
assert "2026-09" in readout
assert "GB" in readout
@pytest.mark.asyncio
async def test_hourly_drill_down_and_back(self, tmp_path):
"""Enter drills into hourly view, Esc returns to daily."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
# Insert hours for each day
for h in range(24):
hour = "%sT%02d:00:00+00:00" % (d, h)
bw_h = 1024*1024*10 if h == 12 else 0
_insert_hour(conn, hour, bw=bw_h)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Select a day
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
assert graph.view_mode == "daily"
# Enter drill-down
await pilot.press("enter")
await pilot.pause()
assert graph.view_mode == "hourly"
assert graph.drill_day is not None
assert len(graph._hour_data) == 24
# Esc returns to daily
await pilot.press("escape")
await pilot.pause()
assert graph.view_mode == "daily"
assert graph.drill_day is None
@pytest.mark.asyncio
async def test_empty_store_graph(self, tmp_path):
"""Empty store shows awaiting message in graph."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
render = str(app.query_one("#bar-render").render())
assert "awaiting" in render.lower()
@pytest.mark.asyncio
async def test_graph_border_title(self, tmp_path):
"""Graph pane has a border title."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert graph.border_title == "usage history"
@pytest.mark.asyncio
async def test_glyphs_in_legend(self, tmp_path):
"""Legend shows all required glyphs."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(7):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
legend = str(app.query_one("#bar-legend").render())
assert _GLYPH_ALLOCATED in legend
assert _GLYPH_UNALLOCATED in legend
assert _GLYPH_GAP in legend
assert _GLYPH_ZERO in legend
assert _GLYPH_PARTIAL in legend
# ---------------------------------------------------------------------------
# Issue #81: Constrained size layout
# ---------------------------------------------------------------------------
class TestConstrainedLayout:
"""Tests for constrained terminal size behavior (issue #81)."""
@pytest.mark.asyncio
async def test_constrained_at_80x24(self, tmp_path):
"""At 80×24 the dashboard renders normally with all regions visible."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
# All four regions should be visible
assert app.query_one("#headline-band") is not None
assert app.query_one("#usage-history") is not None
assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None
# Graph should not be in constrained mode
assert not app._is_constrained_mode
# Constrained summary should be hidden
summary = app.query_one("#constrained-summary")
assert str(summary.styles.display) == "none"
@pytest.mark.asyncio
async def test_constrained_below_80_width(self, tmp_path):
"""Below 80 columns the graph is hidden and textual summary shown."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# Should be in constrained mode
assert app._is_constrained_mode
# Main grid should have constrained class
main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained")
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "days" in summary_text
assert "GB total" in summary_text
@pytest.mark.asyncio
async def test_constrained_below_24_height(self, tmp_path):
"""Below 24 rows the graph is hidden and textual summary shown."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 23)) as pilot:
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
@pytest.mark.asyncio
async def test_resize_from_constrained_to_normal(self, tmp_path):
"""Resizing from constrained to normal restores graph and coherent state."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# Start in constrained mode
assert app._is_constrained_mode
graph = app.query_one("#usage-history")
# Select a day while constrained
graph.selected_index = 5
# Resize to normal — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(80, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should no longer be constrained
assert not app._is_constrained_mode
# Main grid should not have constrained class
main_grid = app.query_one("#main-grid")
assert not main_grid.has_class("constrained")
# Selection should be preserved
assert graph.selected_index == 5
# Graph should have data
assert len(graph._day_data) > 0
@pytest.mark.asyncio
async def test_resize_from_normal_to_constrained(self, tmp_path):
"""Resizing from normal to constrained hides graph and shows summary."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
# Start in normal mode
assert not app._is_constrained_mode
graph = app.query_one("#usage-history")
# Focus and select a day
app.set_focus(graph)
await pilot.pause()
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
# Resize to constrained — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(79, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should show selected day context
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
# Selected day context should survive
assert "2026-09" in summary_text
@pytest.mark.asyncio
async def test_constrained_summary_empty_store(self, tmp_path):
"""Constrained summary shows awaiting message for empty store."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot:
assert app._is_constrained_mode
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "awaiting" in summary_text.lower()
@pytest.mark.asyncio
async def test_constrained_all_regions_usable(self, tmp_path):
"""All regions remain usable in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# All regions should exist
assert app.query_one("#headline-band") is not None
assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None
assert app.query_one("#quit-rail") is not None
# Headline should contain projection
headline = str(app.query_one("#headline-band").render())
assert "remaining" in headline.lower() or "projection" in headline.lower()
# Service strip should have actions
strip = str(app.query_one("#service-strip").render())
assert "p pause" in strip
assert "r resume" in strip
assert "q quit" not in strip # Quit is in quit-rail
# Quit rail should be visible
rail = str(app.query_one("#quit-rail").render())
assert "QUIT" in rail
@pytest.mark.asyncio
async def test_constrained_long_reasons_visible(self, tmp_path):
"""Long status reasons remain visible in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
# Insert stale data
stale_ts = (_clock() - timedelta(days=10)).isoformat()
_insert_sample(conn, stale_ts, pu=5)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
# Status should be visible even with long reasons
assert "stale" in strip.lower() or "freshness" in strip.lower()
@pytest.mark.asyncio
async def test_constrained_keyboard_navigation(self, tmp_path):
"""Keyboard focus traversal works in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# Quit should still work
await pilot.press("q")
assert not app.is_running
@pytest.mark.asyncio
async def test_constrained_theme_and_motion_toggle(self, tmp_path):
"""Theme and motion toggles work in constrained mode."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot:
assert app._is_constrained_mode
# Toggle theme
await pilot.press("t")
await pilot.pause()
# Toggle motion
await pilot.press("m")
await pilot.pause()
# Should still be constrained
assert app._is_constrained_mode
def test_min_width_constant(self):
"""_MIN_WIDTH is 80 columns."""
assert _MIN_WIDTH == 80
def test_min_height_constant(self):
"""_MIN_HEIGHT is 24 rows."""
assert _MIN_HEIGHT == 24
def test_is_constrained_with_terminal_width(self, tmp_path):
"""DailyBarGraph._is_constrained checks terminal width."""
graph = DailyBarGraph()
# Widget-level check (no terminal width)
assert graph._is_constrained() is False
# Terminal width below minimum
assert graph._is_constrained(terminal_width=79) is True
# Terminal width at minimum
assert graph._is_constrained(terminal_width=80) is False
# Terminal width above minimum
assert graph._is_constrained(terminal_width=120) is False