+60
-109
@@ -42,11 +42,7 @@ from fenris.tui import (
|
||||
FenrisTuiApp,
|
||||
DailyBarGraph,
|
||||
_format_remaining,
|
||||
_sparkline,
|
||||
_habit_bar,
|
||||
_query_usage_history,
|
||||
_query_drive_health,
|
||||
_query_service_facts,
|
||||
_query_daily_graph_data,
|
||||
_query_hourly_graph_data,
|
||||
_RANGE_OPTIONS,
|
||||
@@ -142,64 +138,6 @@ class TestFormatRemaining:
|
||||
assert _format_remaining(-100) == "endurance exhausted"
|
||||
|
||||
|
||||
class TestSparkline:
|
||||
def test_empty(self):
|
||||
assert _sparkline([]) == ""
|
||||
|
||||
def test_single_value(self):
|
||||
result = _sparkline([100.0])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_multiple_values(self):
|
||||
result = _sparkline([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
assert len(result) > 0
|
||||
assert all(c in " ▁▂▃▄▅▆▇█" for c in result)
|
||||
|
||||
def test_width_limit(self):
|
||||
result = _sparkline([1.0] * 100, width=20)
|
||||
assert len(result) <= 20
|
||||
|
||||
|
||||
class TestHabitBar:
|
||||
def test_all_active(self):
|
||||
result = _habit_bar(1.0, 0.0, 0.0, 0.0)
|
||||
assert "active 100%" in result
|
||||
|
||||
def test_mixed(self):
|
||||
result = _habit_bar(0.5, 0.3, 0.1, 0.1)
|
||||
assert "active 50%" in result
|
||||
assert "idle 30%" in result
|
||||
|
||||
def test_all_unknown(self):
|
||||
result = _habit_bar(0.0, 0.0, 0.0, 1.0)
|
||||
assert "unknown 100%" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data query tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryUsageHistory:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_usage_history(conn)
|
||||
assert result["num_days"] == 0
|
||||
assert result["sparkline"] == ""
|
||||
conn.close()
|
||||
|
||||
def test_with_days(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
result = _query_usage_history(conn)
|
||||
assert result["num_days"] == 14
|
||||
assert result["sparkline"] != ""
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryDriveHealth:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
@@ -215,27 +153,6 @@ class TestQueryDriveHealth:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryServiceFacts:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
result = _query_service_facts(conn, now)
|
||||
assert result["freshness"] == "empty"
|
||||
conn.close()
|
||||
|
||||
def test_fresh_sample(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
_insert_day(conn, "2026-09-29", bw=1024*1024*100)
|
||||
now = _clock()
|
||||
ts = (now - timedelta(minutes=2)).isoformat()
|
||||
_insert_sample(conn, ts)
|
||||
result = _query_service_facts(conn, now)
|
||||
assert result["freshness"] == "fresh"
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CI-1: Exhaustive state matrix from synthetic stores
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -397,10 +314,10 @@ class TestDenseScreen:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "boot:" in strip
|
||||
assert "timer:" in strip
|
||||
assert "last collect:" in strip
|
||||
assert "freshness:" in strip
|
||||
assert "Boot:" in strip
|
||||
assert "Timer:" in strip
|
||||
assert "Last collect:" in strip
|
||||
assert "Freshness:" in strip
|
||||
# Maker credit now in titlebox only (issue #79)
|
||||
assert "by Bongbetic" not in strip
|
||||
|
||||
@@ -412,7 +329,7 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||
"last_collect_reason": None,
|
||||
@@ -427,7 +344,7 @@ class TestDenseScreen:
|
||||
assert "monitoring: active in background · persists across reboots" in strip
|
||||
assert "r resume — enable monitoring and future boots" in strip
|
||||
assert "q quit" not in strip
|
||||
assert rail == "q QUIT TUI"
|
||||
assert rail == "q Quit TUI"
|
||||
assert usage.region.y < service.region.y < quit_rail.region.y
|
||||
assert usage.region.bottom <= service.region.y
|
||||
assert service.region.bottom <= quit_rail.region.y
|
||||
@@ -446,11 +363,11 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
@@ -472,7 +389,7 @@ class TestDenseScreen:
|
||||
visible_text = " ".join(
|
||||
"".join(ElementTree.fromstring(screenshot).itertext()).split()
|
||||
)
|
||||
assert "paused time is excluded from your usage habit" in visible_text
|
||||
assert "Paused time is excluded from your usage habit" in visible_text
|
||||
assert "resume: fenris monitor resume" in visible_text
|
||||
assert "monitoring: does not start on next boot" in str(
|
||||
app.query_one("#service-strip").render()
|
||||
@@ -487,7 +404,7 @@ class TestDenseScreen:
|
||||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||
).split()
|
||||
)
|
||||
assert "q QUIT TUI" in footer_text
|
||||
assert "q Quit TUI" in footer_text
|
||||
await pilot.press("q")
|
||||
assert not app.is_running
|
||||
subprocess_run.assert_not_called()
|
||||
@@ -509,11 +426,11 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test() as pilot:
|
||||
@@ -539,7 +456,7 @@ class TestDenseScreen:
|
||||
store_path=tmp_path / "nonexistent.db",
|
||||
refresh_interval_s=0.2,
|
||||
)
|
||||
auth_notice = "privileged actions will prompt for authentication (polkit)"
|
||||
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
@@ -595,12 +512,47 @@ class TestDisclosuresAndGreeting:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFirstRun:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("size", [(80, 24), (60, 18)])
|
||||
async def test_sudo_help_remains_available_after_launch(self, tmp_path, size):
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch.object(app, "_run_helper") as run_helper:
|
||||
async with app.run_test(size=size) as pilot:
|
||||
app.on_refresh_tick()
|
||||
await pilot.press("?")
|
||||
help_text = str(app.screen.query_one("#help-text").render())
|
||||
assert "The dashboard does not need sudo" in help_text
|
||||
assert "sudo fenris monitor resume" in help_text
|
||||
assert "sudo fenris monitor pause" in help_text
|
||||
assert "sudo fenris sample" in help_text
|
||||
assert 'sudo usermod -aG fenris "$USER"' in help_text
|
||||
assert "log out and back in" in help_text
|
||||
await pilot.press("end")
|
||||
visible = " ".join("".join(
|
||||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||
).split())
|
||||
assert "Esc Close" in visible
|
||||
await pilot.press("escape")
|
||||
assert len(app.screen_stack) == 1
|
||||
run_helper.assert_not_called()
|
||||
|
||||
def test_missing_polkit_identifies_command_and_points_to_help(self, tmp_path):
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", "pkexec")), \
|
||||
patch.object(app, "suspend"), patch.object(app, "_refresh"), \
|
||||
patch.object(app, "notify") as notify:
|
||||
app.action_resume()
|
||||
message = notify.call_args.args[0]
|
||||
assert "Command not found: pkexec" in message
|
||||
assert "sudo /usr/libexec/fenris/fenris-monitor enable --now" in message
|
||||
|
||||
def test_unprivileged_resume_uses_polkit(self, tmp_path):
|
||||
"""TUI controls use the same authenticated path as the CLI."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
|
||||
with patch("fenris.tui.os.geteuid", return_value=1000), \
|
||||
patch("fenris.tui.subprocess.run") as run, \
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run") as run, \
|
||||
patch.object(app, "suspend"), \
|
||||
patch.object(app, "_refresh"):
|
||||
run.return_value.returncode = 0
|
||||
@@ -608,7 +560,6 @@ class TestFirstRun:
|
||||
|
||||
run.assert_called_once_with(
|
||||
["pkexec", "/usr/libexec/fenris/fenris-monitor", "enable", "--now"],
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -924,8 +875,8 @@ class TestBarGraphTUI:
|
||||
assert len(graph._day_data) > 0
|
||||
# Legend should be visible
|
||||
legend = str(app.query_one("#bar-legend").render())
|
||||
assert "alloc" in legend
|
||||
assert "zero" in legend
|
||||
assert "Alloc" in legend
|
||||
assert "Zero" in legend
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_selection(self, tmp_path):
|
||||
@@ -1091,7 +1042,7 @@ class TestBarGraphTUI:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
assert graph.border_title == "usage history"
|
||||
assert graph.border_title == "Usage history"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glyphs_in_legend(self, tmp_path):
|
||||
@@ -1172,7 +1123,7 @@ class TestConstrainedLayout:
|
||||
# Constrained summary should be visible
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
assert "days" in summary_text
|
||||
assert "GB total" in summary_text
|
||||
|
||||
@@ -1196,7 +1147,7 @@ class TestConstrainedLayout:
|
||||
# Constrained summary should be visible
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resize_from_constrained_to_normal(self, tmp_path):
|
||||
@@ -1273,7 +1224,7 @@ class TestConstrainedLayout:
|
||||
# Constrained summary should show selected day context
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
# Selected day context should survive
|
||||
assert "2026-09" in summary_text
|
||||
|
||||
@@ -1285,7 +1236,7 @@ class TestConstrainedLayout:
|
||||
assert app._is_constrained_mode
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
assert "awaiting" in summary_text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1315,13 +1266,13 @@ class TestConstrainedLayout:
|
||||
|
||||
# Service strip should have actions
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "p pause" in strip
|
||||
assert "r resume" in strip
|
||||
assert "p Pause" in strip
|
||||
assert "r Resume" in strip
|
||||
assert "q quit" not in strip # Quit is in quit-rail
|
||||
|
||||
# Quit rail should be visible
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
assert "QUIT" in rail
|
||||
assert "Quit" in rail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constrained_long_reasons_visible(self, tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user