Release Fenris 0.5.0 with Chalktone dashboard and dotted activity plots
This commit is contained in:
+64
-61
@@ -40,19 +40,13 @@ from fenris.status import (
|
||||
)
|
||||
from fenris.tui import (
|
||||
FenrisTuiApp,
|
||||
DailyBarGraph,
|
||||
HistoryGraph,
|
||||
_format_remaining,
|
||||
_query_drive_health,
|
||||
_query_daily_graph_data,
|
||||
_query_hourly_graph_data,
|
||||
_RANGE_OPTIONS,
|
||||
_RANGE_DEFAULT,
|
||||
_BAR_HEIGHT,
|
||||
_GLYPH_ALLOCATED,
|
||||
_GLYPH_UNALLOCATED,
|
||||
_GLYPH_GAP,
|
||||
_GLYPH_ZERO,
|
||||
_GLYPH_PARTIAL,
|
||||
_MIN_WIDTH,
|
||||
_MIN_HEIGHT,
|
||||
)
|
||||
@@ -365,15 +359,15 @@ class TestDenseScreen:
|
||||
}):
|
||||
async with app.run_test():
|
||||
strip = str(app.query_one("#service-strip").render()).lower()
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
usage = app.query_one("#usage-history")
|
||||
rail = str(app.query_one("#action-rail").render())
|
||||
usage = app.query_one("#activity-panel")
|
||||
service = app.query_one("#service-strip")
|
||||
quit_rail = app.query_one("#quit-rail")
|
||||
quit_rail = app.query_one("#action-rail")
|
||||
assert "continuity" in strip
|
||||
assert "monitoring: active in background · persists across reboots" in strip
|
||||
assert "r resume — enable monitoring and future boots" in strip
|
||||
assert "r Resume" in str(app.query_one("#action-rail").render())
|
||||
assert "q quit" not in strip
|
||||
assert rail == "q Quit TUI"
|
||||
assert "q Quit TUI" in rail
|
||||
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
|
||||
@@ -405,13 +399,12 @@ class TestDenseScreen:
|
||||
main_grid = app.query_one("#main-grid")
|
||||
assert str(main_grid.styles.layout) == "<grid>"
|
||||
assert main_grid.has_class("paused")
|
||||
assert len(main_grid.styles.grid_rows) == 6
|
||||
banner = str(paused_banner.render()).lower()
|
||||
assert "monitoring: paused — deliberate disable" in banner
|
||||
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
||||
assert paused_banner.region.height >= 5
|
||||
assert paused_banner.region.y < app.query_one("#usage-history").region.y
|
||||
assert app.query_one("#usage-history").region.bottom <= app.query_one(
|
||||
assert paused_banner.region.height >= 2
|
||||
assert paused_banner.region.y < app.query_one("#activity-panel").region.y
|
||||
assert app.query_one("#activity-panel").region.bottom <= app.query_one(
|
||||
"#service-strip"
|
||||
).region.y
|
||||
screenshot = app.export_screenshot()
|
||||
@@ -423,11 +416,6 @@ class TestDenseScreen:
|
||||
assert "monitoring: does not start on next boot" in str(
|
||||
app.query_one("#service-strip").render()
|
||||
).lower()
|
||||
dashboard_scroll = app.query_one("#dashboard-scroll")
|
||||
assert dashboard_scroll.max_scroll_y > 0
|
||||
dashboard_scroll.focus()
|
||||
await pilot.press("end")
|
||||
assert dashboard_scroll.scroll_y == dashboard_scroll.max_scroll_y
|
||||
footer_text = " ".join(
|
||||
"".join(
|
||||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||
@@ -490,23 +478,23 @@ class TestDenseScreen:
|
||||
async with app.run_test() as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
# Identity now shows Fenris by Bongbetic (issue #79)
|
||||
assert "Fenris by Bongbetic" in headline
|
||||
assert auth_notice in headline
|
||||
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||
assert auth_notice in str(app.query_one("#auth-notice").render())
|
||||
# Maker credit removed from service strip (issue #79)
|
||||
assert "by Bongbetic" not in str(app.query_one("#service-strip").render())
|
||||
|
||||
await pilot.pause(0.25)
|
||||
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||||
assert not app.query_one("#auth-notice").display
|
||||
|
||||
await pilot.pause(0.25)
|
||||
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||||
assert not app.query_one("#auth-notice").display
|
||||
|
||||
fresh_app = FenrisTuiApp(
|
||||
store_path=tmp_path / "nonexistent.db",
|
||||
refresh_interval_s=0.2,
|
||||
)
|
||||
async with fresh_app.run_test():
|
||||
assert auth_notice in str(fresh_app.query_one("#headline-band").render())
|
||||
assert auth_notice in str(fresh_app.query_one("#auth-notice").render())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -600,7 +588,7 @@ class TestFirstRun:
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "no observations yet" in headline.lower()
|
||||
assert "r resume — enable monitoring and future boots" in headline.lower()
|
||||
assert "r resume — enable monitoring and future boots" in strip.lower()
|
||||
assert "r resume" in str(app.query_one("#action-rail").render()).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -864,15 +852,15 @@ class TestQueryHourlyGraphData:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DailyBarGraph widget unit tests (issue #75)
|
||||
# HistoryGraph widget unit tests (issue #75)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDailyBarGraph:
|
||||
class TestHistoryGraph:
|
||||
def test_empty_data(self, tmp_path):
|
||||
"""Empty data shows awaiting message."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
# We test the widget directly via the app's compose
|
||||
graph = DailyBarGraph()
|
||||
graph = HistoryGraph()
|
||||
# Simulate setting empty data
|
||||
from textual.app import App as TextualApp
|
||||
class _TestApp(TextualApp):
|
||||
@@ -884,14 +872,14 @@ class TestDailyBarGraph:
|
||||
|
||||
def test_range_default(self):
|
||||
"""Default range is 14 days."""
|
||||
graph = DailyBarGraph()
|
||||
graph = HistoryGraph()
|
||||
assert graph.range_days == _RANGE_DEFAULT
|
||||
assert graph.selected_index == -1
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
def test_trim_to_range(self):
|
||||
"""Data is trimmed to the selected range."""
|
||||
graph = DailyBarGraph()
|
||||
graph = HistoryGraph()
|
||||
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
|
||||
for d in range(1, 31)]
|
||||
graph._all_day_data = data
|
||||
@@ -902,7 +890,7 @@ class TestDailyBarGraph:
|
||||
|
||||
def test_range_all_fits(self):
|
||||
"""When data fits within range, all days are shown."""
|
||||
graph = DailyBarGraph()
|
||||
graph = HistoryGraph()
|
||||
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
|
||||
for d in range(1, 8)]
|
||||
graph._all_day_data = data
|
||||
@@ -911,7 +899,7 @@ class TestDailyBarGraph:
|
||||
assert len(graph._day_data) == 7
|
||||
|
||||
def test_scale_includes_allocated_and_unallocated_writes(self):
|
||||
graph = DailyBarGraph()
|
||||
graph = HistoryGraph()
|
||||
graph._all_day_data = [{
|
||||
"day": "2026-09-16",
|
||||
"total_bytes": 800,
|
||||
@@ -927,7 +915,7 @@ class TestDailyBarGraph:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBarGraphTUI:
|
||||
"""Headless tests for the interactive bar graph."""
|
||||
"""Headless tests for the interactive history plot."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_renders_with_data(self, tmp_path):
|
||||
@@ -943,20 +931,21 @@ class TestBarGraphTUI:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
graph = app.query_one("#usage-history")
|
||||
assert isinstance(graph, DailyBarGraph)
|
||||
assert isinstance(graph, HistoryGraph)
|
||||
assert len(graph._day_data) > 0
|
||||
# Legend should be visible
|
||||
legend = str(app.query_one("#bar-legend").render())
|
||||
assert "Alloc" in legend
|
||||
assert "Unalloc" in legend
|
||||
assert "Zero" in legend
|
||||
range_label = str(app.query_one("#bar-range").render())
|
||||
assert "14 days" in range_label
|
||||
assert "UTC" in range_label
|
||||
plotted = str(app.query_one("#bar-render").render())
|
||||
assert "Writes (" in plotted
|
||||
assert "Day UTC" in plotted
|
||||
assert "→" in plotted
|
||||
assert "Writes (" in legend
|
||||
assert "UTC" in range_label
|
||||
assert "─" in plotted
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_selection(self, tmp_path):
|
||||
@@ -972,6 +961,7 @@ class TestBarGraphTUI:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
@@ -1002,6 +992,7 @@ class TestBarGraphTUI:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
@@ -1043,6 +1034,7 @@ class TestBarGraphTUI:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
@@ -1079,6 +1071,7 @@ class TestBarGraphTUI:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
@@ -1114,8 +1107,9 @@ class TestBarGraphTUI:
|
||||
"""Empty store shows awaiting message in graph."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
graph = app.query_one("#usage-history")
|
||||
assert isinstance(graph, DailyBarGraph)
|
||||
assert isinstance(graph, HistoryGraph)
|
||||
render = str(app.query_one("#bar-render").render())
|
||||
assert "awaiting" in render.lower()
|
||||
|
||||
@@ -1124,8 +1118,9 @@ class TestBarGraphTUI:
|
||||
"""Graph pane has a border title."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
graph = app.query_one("#usage-history")
|
||||
assert graph.border_title == "Usage history"
|
||||
assert app.query_one("#activity-panel").border_title == "Drive activity"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glyphs_in_legend(self, tmp_path):
|
||||
@@ -1141,13 +1136,11 @@ class TestBarGraphTUI:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
await pilot.pause()
|
||||
legend = str(app.query_one("#bar-legend").render())
|
||||
assert _GLYPH_ALLOCATED in legend
|
||||
assert _GLYPH_UNALLOCATED in legend
|
||||
assert _GLYPH_GAP in legend
|
||||
assert _GLYPH_ZERO in legend
|
||||
assert _GLYPH_PARTIAL in legend
|
||||
for meaning in ("Writes", "Unalloc", "Gap", "Zero", "Partial"):
|
||||
assert meaning in legend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1172,6 +1165,7 @@ class TestConstrainedLayout:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
# All four regions should be visible
|
||||
assert app.query_one("#headline-band") is not None
|
||||
assert app.query_one("#usage-history") is not None
|
||||
@@ -1180,8 +1174,8 @@ class TestConstrainedLayout:
|
||||
# Graph should not be in constrained mode
|
||||
assert not app._is_constrained_mode
|
||||
# Constrained summary should be hidden
|
||||
summary = app.query_one("#constrained-summary")
|
||||
assert str(summary.styles.display) == "none"
|
||||
summary = app.query_one("#bar-render")
|
||||
assert "Graph needs" not in str(summary.render())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constrained_below_80_width(self, tmp_path):
|
||||
@@ -1198,13 +1192,14 @@ class TestConstrainedLayout:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(79, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
# Should be in constrained mode
|
||||
assert app._is_constrained_mode
|
||||
# Main grid should have constrained class
|
||||
main_grid = app.query_one("#main-grid")
|
||||
assert main_grid.has_class("constrained")
|
||||
# Constrained summary should be visible
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary = app.query_one("#bar-render")
|
||||
summary_text = str(summary.render())
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
assert "days" in summary_text
|
||||
@@ -1225,10 +1220,11 @@ class TestConstrainedLayout:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 23)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
# Should be in constrained mode
|
||||
assert app._is_constrained_mode
|
||||
# Constrained summary should be visible
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary = app.query_one("#bar-render")
|
||||
summary_text = str(summary.render())
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
|
||||
@@ -1247,11 +1243,12 @@ class TestConstrainedLayout:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(79, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
# Start in constrained mode
|
||||
assert app._is_constrained_mode
|
||||
graph = app.query_one("#usage-history")
|
||||
# Select a day while constrained
|
||||
graph.selected_index = 5
|
||||
# Select a day through the visible graph while constrained.
|
||||
await pilot.press(*(["left"] * 8))
|
||||
|
||||
# Resize to normal — pilot.resize_terminal changes terminal size
|
||||
await pilot.resize_terminal(80, 24)
|
||||
@@ -1285,6 +1282,7 @@ class TestConstrainedLayout:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
# Start in normal mode
|
||||
assert not app._is_constrained_mode
|
||||
graph = app.query_one("#usage-history")
|
||||
@@ -1303,7 +1301,7 @@ class TestConstrainedLayout:
|
||||
# Should be in constrained mode
|
||||
assert app._is_constrained_mode
|
||||
# Constrained summary should show selected day context
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary = app.query_one("#bar-render")
|
||||
summary_text = str(summary.render())
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
# Selected day context should survive
|
||||
@@ -1314,8 +1312,9 @@ class TestConstrainedLayout:
|
||||
"""Constrained summary shows awaiting message for empty store."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(79, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
assert app._is_constrained_mode
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary = app.query_one("#bar-render")
|
||||
summary_text = str(summary.render())
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
assert "awaiting" in summary_text.lower()
|
||||
@@ -1335,24 +1334,25 @@ class TestConstrainedLayout:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(79, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
# All regions should exist
|
||||
assert app.query_one("#headline-band") is not None
|
||||
assert app.query_one("#drive-health") is not None
|
||||
assert app.query_one("#service-strip") is not None
|
||||
assert app.query_one("#quit-rail") is not None
|
||||
assert app.query_one("#action-rail") is not None
|
||||
|
||||
# Headline should contain projection
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
||||
|
||||
# Service strip should have actions
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
strip = str(app.query_one("#action-rail").render())
|
||||
assert "p Pause" in strip
|
||||
assert "r Resume" in strip
|
||||
assert "q quit" not in strip # Quit is in quit-rail
|
||||
assert "q Quit TUI" in strip
|
||||
|
||||
# Quit rail should be visible
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
rail = str(app.query_one("#action-rail").render())
|
||||
assert "Quit" in rail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1368,6 +1368,7 @@ class TestConstrainedLayout:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(79, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
# Status should be visible even with long reasons
|
||||
assert "stale" in strip.lower() or "freshness" in strip.lower()
|
||||
@@ -1387,6 +1388,7 @@ class TestConstrainedLayout:
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(79, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
# Quit should still work
|
||||
await pilot.press("q")
|
||||
assert not app.is_running
|
||||
@@ -1396,6 +1398,7 @@ class TestConstrainedLayout:
|
||||
"""Theme and motion toggles work in constrained mode."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(79, 24)) as pilot:
|
||||
await pilot.press("v", "v")
|
||||
assert app._is_constrained_mode
|
||||
# Toggle theme
|
||||
await pilot.press("t")
|
||||
@@ -1415,8 +1418,8 @@ class TestConstrainedLayout:
|
||||
assert _MIN_HEIGHT == 24
|
||||
|
||||
def test_is_constrained_with_terminal_width(self, tmp_path):
|
||||
"""DailyBarGraph._is_constrained checks terminal width."""
|
||||
graph = DailyBarGraph()
|
||||
"""HistoryGraph._is_constrained checks terminal width."""
|
||||
graph = HistoryGraph()
|
||||
# Widget-level check (no terminal width)
|
||||
assert graph._is_constrained() is False
|
||||
# Terminal width below minimum
|
||||
|
||||
Reference in New Issue
Block a user