- Change default collection cadence from 5 minutes to 3 minutes (CADENCE_DEFAULT_S=180, systemd OnUnitInactiveSec=3min, runit CADENCE=180) - Update freshness threshold to 450s (2×180 + AccuracySec + 60) - Add _query_live_graph_data(): queries raw samples from the last 3 hours and computes interval byte deltas with actual timestamps - Add LiveActivityGraph widget: vertical bar chart of interval volumes with read/write toggle (w key), arrow key inspection, and click support - Wire live graph into TUI layout (full-width row between daily graph and drive health), refresh cycle, and CSS grid - Replace t theme binding with t today/live binding; theme selection via preferences file - Add w binding for read/write toggle on live graph - Update action legend, help screen, and grid layout for new live-activity row - Add 20 tests covering cadence constants, live query, widget rendering, toggle, and TUI integration - Update all cadence documentation (README, ADR 0003, acceptance criteria LC-2, fenris-redesign constants table, CHANGELOG)
490 lines
19 KiB
Python
490 lines
19 KiB
Python
"""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_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-amber"
|
|
|
|
# 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() as pilot:
|
|
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() as pilot:
|
|
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() 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
|