feat: add interactive daily writes bar graph with hourly drill-down (issue #75)
Replace the static sparkline with an interactive block-glyph bar graph that supports writes-only daily bars, range switching (7/14/28/90 days), day selection, and hourly drill-down. No plotting dependency. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
co-authored by
CommandCodeBot
parent
6917a658cb
commit
5d916ee97f
@@ -40,12 +40,23 @@ from fenris.status import (
|
||||
)
|
||||
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,
|
||||
_RANGE_DEFAULT,
|
||||
_BAR_HEIGHT,
|
||||
_GLYPH_ALLOCATED,
|
||||
_GLYPH_UNALLOCATED,
|
||||
_GLYPH_GAP,
|
||||
_GLYPH_ZERO,
|
||||
_GLYPH_PARTIAL,
|
||||
)
|
||||
|
||||
|
||||
@@ -679,3 +690,399 @@ class TestStateMatrixCombinations:
|
||||
# Incomplete provenance → UNVERIFIED tier
|
||||
assert proj.baseline_tier.value == "unverified_override"
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hour observation helper for graph tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _insert_hour(conn, hour, bw=0, active=3600, idle=0, powered_off=0,
|
||||
unknown=0, coverage=1.0, samples=1):
|
||||
"""Insert an hour observation row."""
|
||||
conn.execute(
|
||||
"INSERT INTO hour_observations "
|
||||
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
(hour, active, idle, powered_off, unknown, bw, samples, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_day_with_unattributed(conn, day, bw=0, unattributed=0,
|
||||
coverage=0.95, samples=24):
|
||||
"""Insert a day aggregate with explicit unattributed bytes."""
|
||||
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, "
|
||||
" unattributed_bytes_written, unattributed_bytes_read) "
|
||||
"VALUES (?, 3600, 0, 0, 0, ?, 0, ?, ?, ?, 0)",
|
||||
(day, bw, samples, coverage, unattributed),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graph data query tests (issue #75)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryDailyGraphData:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_daily_graph_data(conn)
|
||||
assert result == []
|
||||
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_daily_graph_data(conn)
|
||||
assert len(result) == 14
|
||||
assert result[0]["total_bytes"] == 1024*1024*100
|
||||
assert result[0]["allocated_bytes"] == 1024*1024*100
|
||||
assert result[0]["unallocated_bytes"] == 0
|
||||
assert result[0]["is_zero"] is False
|
||||
conn.close()
|
||||
|
||||
def test_zero_day(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
_insert_day(conn, "2026-09-20", bw=0, coverage=0.95, samples=24)
|
||||
result = _query_daily_graph_data(conn)
|
||||
assert len(result) == 1
|
||||
assert result[0]["is_zero"] is True
|
||||
assert result[0]["total_bytes"] == 0
|
||||
conn.close()
|
||||
|
||||
def test_unattributed_bytes(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
_insert_day_with_unattributed(
|
||||
conn, "2026-09-20", bw=500, unattributed=300
|
||||
)
|
||||
result = _query_daily_graph_data(conn)
|
||||
assert len(result) == 1
|
||||
assert result[0]["allocated_bytes"] == 500
|
||||
assert result[0]["unallocated_bytes"] == 300
|
||||
assert result[0]["total_bytes"] == 800
|
||||
conn.close()
|
||||
|
||||
def test_gap_day(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
# Day with no hours but unknown seconds (gap in monitoring period)
|
||||
conn.execute(
|
||||
"INSERT INTO day_aggregates "
|
||||
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||
" bytes_written_delta, sample_count, coverage) "
|
||||
"VALUES (?, 0, 0, 0, 86400, 0, 0, 0.0)",
|
||||
("2026-09-20",),
|
||||
)
|
||||
conn.commit()
|
||||
result = _query_daily_graph_data(conn)
|
||||
assert len(result) == 1
|
||||
assert result[0]["is_gap"] is True
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryHourlyGraphData:
|
||||
def test_empty_day(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||
assert result == []
|
||||
conn.close()
|
||||
|
||||
def test_with_hours(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
for h in range(24):
|
||||
hour = "2026-09-20T%02d:00:00+00:00" % h
|
||||
bw = 1024*1024*100 if h in (10, 14) else 0
|
||||
_insert_hour(conn, hour, bw=bw)
|
||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||
assert len(result) == 24
|
||||
assert result[10]["bytes_written"] == 1024*1024*100
|
||||
assert result[10]["is_zero"] is False
|
||||
assert result[0]["is_zero"] is True
|
||||
conn.close()
|
||||
|
||||
def test_hour_labels(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
|
||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||
assert len(result) == 1
|
||||
assert result[0]["local_label"] == "12"
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DailyBarGraph widget unit tests (issue #75)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDailyBarGraph:
|
||||
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()
|
||||
# Simulate setting empty data
|
||||
from textual.app import App as TextualApp
|
||||
class _TestApp(TextualApp):
|
||||
def compose(self):
|
||||
yield graph
|
||||
# We can't easily test widget lifecycle outside an app, so test the data
|
||||
assert graph._all_day_data == []
|
||||
assert graph.range_days == _RANGE_DEFAULT
|
||||
|
||||
def test_range_default(self):
|
||||
"""Default range is 14 days."""
|
||||
graph = DailyBarGraph()
|
||||
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()
|
||||
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
|
||||
for d in range(1, 31)]
|
||||
graph._all_day_data = data
|
||||
graph.range_days = 7
|
||||
graph._trim_to_range()
|
||||
assert len(graph._day_data) == 7
|
||||
assert graph._day_data[0]["day"] == "2026-09-24"
|
||||
|
||||
def test_range_all_fits(self):
|
||||
"""When data fits within range, all days are shown."""
|
||||
graph = DailyBarGraph()
|
||||
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
|
||||
for d in range(1, 8)]
|
||||
graph._all_day_data = data
|
||||
graph.range_days = 14
|
||||
graph._trim_to_range()
|
||||
assert len(graph._day_data) == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bar graph headless TUI tests (issue #75)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBarGraphTUI:
|
||||
"""Headless tests for the interactive bar graph."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_renders_with_data(self, tmp_path):
|
||||
"""Graph widget renders with day data."""
|
||||
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)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
assert isinstance(graph, DailyBarGraph)
|
||||
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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_selection(self, tmp_path):
|
||||
"""Left/right arrows move the selection."""
|
||||
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)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# Right arrow selects first bar
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
|
||||
# Right again moves to second
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 1
|
||||
|
||||
# Left moves back
|
||||
await pilot.press("left")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_range_switching(self, tmp_path):
|
||||
"""1-4 keys switch the visible range."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(30):
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# Default is 14
|
||||
assert graph.range_days == 14
|
||||
assert len(graph._day_data) == 14
|
||||
|
||||
# Press 1 for 7-day range
|
||||
await pilot.press("1")
|
||||
await pilot.pause()
|
||||
assert graph.range_days == 7
|
||||
assert len(graph._day_data) == 7
|
||||
|
||||
# Press 3 for 28-day range
|
||||
await pilot.press("3")
|
||||
await pilot.pause()
|
||||
assert graph.range_days == 28
|
||||
assert len(graph._day_data) == 28
|
||||
|
||||
# Press 4 for 90-day range (only 30 days available)
|
||||
await pilot.press("4")
|
||||
await pilot.pause()
|
||||
assert graph.range_days == 90
|
||||
assert len(graph._day_data) == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readout_updates_on_selection(self, tmp_path):
|
||||
"""Readout shows selected day info."""
|
||||
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)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# No selection initially
|
||||
readout = str(app.query_one("#bar-readout").render())
|
||||
assert "select" in readout.lower()
|
||||
|
||||
# Select first bar
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
readout = str(app.query_one("#bar-readout").render())
|
||||
assert "2026-09" in readout
|
||||
assert "GB" in readout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hourly_drill_down_and_back(self, tmp_path):
|
||||
"""Enter drills into hourly view, Esc returns to daily."""
|
||||
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)
|
||||
# Insert hours for each day
|
||||
for h in range(24):
|
||||
hour = "%sT%02d:00:00+00:00" % (d, h)
|
||||
bw_h = 1024*1024*10 if h == 12 else 0
|
||||
_insert_hour(conn, hour, bw=bw_h)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# Select a day
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
# Enter drill-down
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert graph.view_mode == "hourly"
|
||||
assert graph.drill_day is not None
|
||||
assert len(graph._hour_data) == 24
|
||||
|
||||
# Esc returns to daily
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert graph.view_mode == "daily"
|
||||
assert graph.drill_day is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_store_graph(self, tmp_path):
|
||||
"""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:
|
||||
graph = app.query_one("#usage-history")
|
||||
assert isinstance(graph, DailyBarGraph)
|
||||
render = str(app.query_one("#bar-render").render())
|
||||
assert "awaiting" in render.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_border_title(self, tmp_path):
|
||||
"""Graph pane has a border title."""
|
||||
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"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glyphs_in_legend(self, tmp_path):
|
||||
"""Legend shows all required glyphs."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(7):
|
||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user