"""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