Files
Fenris/tests/test_preferences.py
T
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

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