"""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 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 ( save_preferences, ) from fenris.themes import get_graph_colors, THEME_NAMES from fenris.status_composition import ( StatusState, compose_status, ) # --------------------------------------------------------------------------- # 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_chalktone_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(): # Theme should be fenris-amber assert app.theme == "fenris-chalktone" @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(): 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(): 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 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(): # 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_goes_to_today(self, tmp_path): """Pressing t returns to today's live view (issue #91).""" 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-chalktone" # Enter drill mode on the graph graph = app.query_one("#usage-history") graph.view_mode = "hourly" graph.drill_day = "2026-09-30" # Press t to go back to today await pilot.press("t") await pilot.pause() # Should be back in daily mode assert graph.view_mode == "daily" @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 from fenris.preferences import save_preferences config_home = _make_prefs_dir(tmp_path) with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): # First run: change theme via preferences API app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app1.run_test(): save_preferences(theme="nord", reduced_motion=False) app1._current_theme_name = "nord" app1.theme = "fenris-nord" theme_after = app1.theme assert theme_after == "fenris-nord" # Second run: theme should persist app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app2.run_test(): assert app2.theme == theme_after @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(): # 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(): # TUI should start with default theme assert app.theme == "fenris-chalktone" # 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(): # TUI should start without crash assert app.theme in ("fenris-chalktone", "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