diff --git a/src/fenris/preferences.py b/src/fenris/preferences.py new file mode 100644 index 0000000..9411579 --- /dev/null +++ b/src/fenris/preferences.py @@ -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} diff --git a/src/fenris/themes.py b/src/fenris/themes.py new file mode 100644 index 0000000..ca2bb28 --- /dev/null +++ b/src/fenris/themes.py @@ -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"), + } diff --git a/src/fenris/tui.py b/src/fenris/tui.py index c5371d0..ab8ca25 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -59,6 +59,8 @@ from .status_composition import ( render_status_tui, 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("c", "collect", "Collect Now", 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), ] @@ -965,6 +969,11 @@ class FenrisTuiApp(App): refresh_interval_s: float = CADENCE_DEFAULT_S, **kwargs, ) -> 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) self.store_path = store_path or Path("/var/lib/fenris/observations.db") self.config_path = config_path @@ -974,6 +983,10 @@ class FenrisTuiApp(App): self._conn: Optional[sqlite3.Connection] = None self._clock_now = datetime.now(timezone.utc) + # Register all Fenris themes + for theme in THEMES.values(): + self.register_theme(theme) + def compose(self) -> ComposeResult: with VerticalScroll(id="dashboard-scroll"): with Container(id="main-grid"): @@ -985,7 +998,11 @@ class FenrisTuiApp(App): yield Static("q QUIT TUI", id="quit-rail") 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("#usage-history").border_title = "usage history" self.query_one("#drive-health").border_title = "drive" @@ -1057,7 +1074,7 @@ class FenrisTuiApp(App): self.query_one("#service-strip").update( "boot: disabled · timer: inactive · last collect: unknown · freshness: empty\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}) ) else: @@ -1069,7 +1086,7 @@ class FenrisTuiApp(App): self.query_one("#usage-history").set_data([]) self.query_one("#drive-health").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: @@ -1147,14 +1164,14 @@ class FenrisTuiApp(App): conn, svc, self._clock_now, store_fault=None, newer_schema=None, - reduced_motion=False, + reduced_motion=self._reduced_motion, ) # Render using the shared composition with TUI styling status_text = render_status_tui(comp) # Add TUI-only actions self.query_one("#service-strip").update( "%s\n" - "p pause · r resume · c collect · d disclosures" + "p pause · r resume · c collect · t theme · m motion · d disclosures" % status_text ) self._render_paused_banner(comp) @@ -1162,7 +1179,7 @@ class FenrisTuiApp(App): self._hide_paused_banner() self.query_one("#service-strip").update( "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: @@ -1266,6 +1283,30 @@ class FenrisTuiApp(App): # --- 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: """Pause monitoring — asks for confirmation (spec §7.4, LC-6).""" self.push_screen(ConfirmPause(), callback=self._pause_confirmed) diff --git a/tests/test_issue_80.py b/tests/test_issue_80.py new file mode 100644 index 0000000..da53ebb --- /dev/null +++ b/tests/test_issue_80.py @@ -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 diff --git a/tests/test_preferences.py b/tests/test_preferences.py new file mode 100644 index 0000000..c6449bd --- /dev/null +++ b/tests/test_preferences.py @@ -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 diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 0000000..4cebbef --- /dev/null +++ b/tests/test_themes.py @@ -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"] diff --git a/tests/test_tui.py b/tests/test_tui.py index 6c8bd38..685ef09 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -423,7 +423,7 @@ class TestDenseScreen: quit_rail = app.query_one("#quit-rail") assert "continuity" in strip assert "monitoring: active in background · persists across reboots" in strip - assert "p pause · r resume · c collect · d disclosures" in strip + assert "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip assert "q quit" not in strip assert rail == "q QUIT TUI" assert usage.region.y < service.region.y < quit_rail.region.y