"""Tests for user-scoped TUI preferences (issue #80). Covers: - Preference load/save with safe defaults - XDG_CONFIG_HOME user-scoped persistence - Chalktone 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_chalktone(self): """Amber is the default theme preset.""" assert DEFAULT_THEME == "chalktone" 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 == {"chalktone", "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"] == "chalktone" 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"] == "chalktone" 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"] == "chalktone" 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"] == "chalktone" 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