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
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user