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
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""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
|