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
This commit is contained in:
@@ -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}
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
+47
-6
@@ -59,6 +59,8 @@ from .status_composition import (
|
|||||||
render_status_tui,
|
render_status_tui,
|
||||||
STATUS_POLL_INTERVAL_S,
|
STATUS_POLL_INTERVAL_S,
|
||||||
)
|
)
|
||||||
|
from .preferences import load_preferences, save_preferences
|
||||||
|
from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -954,6 +956,8 @@ class FenrisTuiApp(App):
|
|||||||
Binding("r", "resume", "Resume", show=False),
|
Binding("r", "resume", "Resume", show=False),
|
||||||
Binding("c", "collect", "Collect Now", show=False),
|
Binding("c", "collect", "Collect Now", show=False),
|
||||||
Binding("d", "disclose", "Disclosures", show=False),
|
Binding("d", "disclose", "Disclosures", show=False),
|
||||||
|
Binding("t", "toggle_theme", "Theme", show=False),
|
||||||
|
Binding("m", "toggle_motion", "Motion", show=False),
|
||||||
Binding("q", "quit", "Quit", show=False),
|
Binding("q", "quit", "Quit", show=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -965,6 +969,11 @@ class FenrisTuiApp(App):
|
|||||||
refresh_interval_s: float = CADENCE_DEFAULT_S,
|
refresh_interval_s: float = CADENCE_DEFAULT_S,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Load user preferences before super().__init__ so theme is available
|
||||||
|
self._prefs = load_preferences()
|
||||||
|
self._current_theme_name = self._prefs["theme"]
|
||||||
|
self._reduced_motion = self._prefs["reduced_motion"]
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
||||||
self.config_path = config_path
|
self.config_path = config_path
|
||||||
@@ -974,6 +983,10 @@ class FenrisTuiApp(App):
|
|||||||
self._conn: Optional[sqlite3.Connection] = None
|
self._conn: Optional[sqlite3.Connection] = None
|
||||||
self._clock_now = datetime.now(timezone.utc)
|
self._clock_now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Register all Fenris themes
|
||||||
|
for theme in THEMES.values():
|
||||||
|
self.register_theme(theme)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with VerticalScroll(id="dashboard-scroll"):
|
with VerticalScroll(id="dashboard-scroll"):
|
||||||
with Container(id="main-grid"):
|
with Container(id="main-grid"):
|
||||||
@@ -985,7 +998,11 @@ class FenrisTuiApp(App):
|
|||||||
yield Static("q QUIT TUI", id="quit-rail")
|
yield Static("q QUIT TUI", id="quit-rail")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Set border titles and render initial state."""
|
"""Set border titles, apply theme, and render initial state."""
|
||||||
|
# Apply loaded theme (issue #80)
|
||||||
|
# Preference uses underscores (high_contrast); Textual theme uses dashes (fenris-high-contrast)
|
||||||
|
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
|
||||||
|
|
||||||
self.query_one("#headline-band").border_title = "headline"
|
self.query_one("#headline-band").border_title = "headline"
|
||||||
self.query_one("#usage-history").border_title = "usage history"
|
self.query_one("#usage-history").border_title = "usage history"
|
||||||
self.query_one("#drive-health").border_title = "drive"
|
self.query_one("#drive-health").border_title = "drive"
|
||||||
@@ -1057,7 +1074,7 @@ class FenrisTuiApp(App):
|
|||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
||||||
"[bold]CONTINUITY[/bold] %s\n"
|
"[bold]CONTINUITY[/bold] %s\n"
|
||||||
"p pause · r resume · c collect · d disclosures"
|
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||||
% monitoring_continuity({"boot_enabled": False})
|
% monitoring_continuity({"boot_enabled": False})
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1069,7 +1086,7 @@ class FenrisTuiApp(App):
|
|||||||
self.query_one("#usage-history").set_data([])
|
self.query_one("#usage-history").set_data([])
|
||||||
self.query_one("#drive-health").update("")
|
self.query_one("#drive-health").update("")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"p pause · r resume · c collect · d disclosures"
|
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||||||
@@ -1147,14 +1164,14 @@ class FenrisTuiApp(App):
|
|||||||
conn, svc, self._clock_now,
|
conn, svc, self._clock_now,
|
||||||
store_fault=None,
|
store_fault=None,
|
||||||
newer_schema=None,
|
newer_schema=None,
|
||||||
reduced_motion=False,
|
reduced_motion=self._reduced_motion,
|
||||||
)
|
)
|
||||||
# Render using the shared composition with TUI styling
|
# Render using the shared composition with TUI styling
|
||||||
status_text = render_status_tui(comp)
|
status_text = render_status_tui(comp)
|
||||||
# Add TUI-only actions
|
# Add TUI-only actions
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"%s\n"
|
"%s\n"
|
||||||
"p pause · r resume · c collect · d disclosures"
|
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||||
% status_text
|
% status_text
|
||||||
)
|
)
|
||||||
self._render_paused_banner(comp)
|
self._render_paused_banner(comp)
|
||||||
@@ -1162,7 +1179,7 @@ class FenrisTuiApp(App):
|
|||||||
self._hide_paused_banner()
|
self._hide_paused_banner()
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
||||||
"p pause · r resume · c collect · d disclosures"
|
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_paused_banner(self, comp) -> None:
|
def _render_paused_banner(self, comp) -> None:
|
||||||
@@ -1266,6 +1283,30 @@ class FenrisTuiApp(App):
|
|||||||
|
|
||||||
# --- Actions ---
|
# --- Actions ---
|
||||||
|
|
||||||
|
def action_toggle_theme(self) -> None:
|
||||||
|
"""Cycle through Amber → Nord → High Contrast presets (issue #80)."""
|
||||||
|
names = sorted(THEME_NAMES)
|
||||||
|
try:
|
||||||
|
idx = names.index(self._current_theme_name)
|
||||||
|
except ValueError:
|
||||||
|
idx = 0
|
||||||
|
self._current_theme_name = names[(idx + 1) % len(names)]
|
||||||
|
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
|
||||||
|
save_preferences(
|
||||||
|
theme=self._current_theme_name,
|
||||||
|
reduced_motion=self._reduced_motion,
|
||||||
|
)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def action_toggle_motion(self) -> None:
|
||||||
|
"""Toggle reduced motion preference (issue #80)."""
|
||||||
|
self._reduced_motion = not self._reduced_motion
|
||||||
|
save_preferences(
|
||||||
|
theme=self._current_theme_name,
|
||||||
|
reduced_motion=self._reduced_motion,
|
||||||
|
)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
def action_pause(self) -> None:
|
def action_pause(self) -> None:
|
||||||
"""Pause monitoring — asks for confirmation (spec §7.4, LC-6)."""
|
"""Pause monitoring — asks for confirmation (spec §7.4, LC-6)."""
|
||||||
self.push_screen(ConfirmPause(), callback=self._pause_confirmed)
|
self.push_screen(ConfirmPause(), callback=self._pause_confirmed)
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
+1
-1
@@ -423,7 +423,7 @@ class TestDenseScreen:
|
|||||||
quit_rail = app.query_one("#quit-rail")
|
quit_rail = app.query_one("#quit-rail")
|
||||||
assert "continuity" in strip
|
assert "continuity" in strip
|
||||||
assert "monitoring: active in background · persists across reboots" in strip
|
assert "monitoring: active in background · persists across reboots" in strip
|
||||||
assert "p pause · r resume · c collect · d disclosures" in strip
|
assert "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip
|
||||||
assert "q quit" not in strip
|
assert "q quit" not in strip
|
||||||
assert rail == "q QUIT TUI"
|
assert rail == "q QUIT TUI"
|
||||||
assert usage.region.y < service.region.y < quit_rail.region.y
|
assert usage.region.y < service.region.y < quit_rail.region.y
|
||||||
|
|||||||
Reference in New Issue
Block a user