Make the combined dashboard usable at constrained sizes (issue #81)

- Add terminal size detection with _MIN_WIDTH (80) and _MIN_HEIGHT (24) thresholds
- Add constrained CSS layout: single-column grid, hide graph, show text summary
- Add #constrained-summary widget with textual history summary
- Add on_resize handler and _refresh()-based constrained state detection
- Preserve selected-day context across resize transitions
- Ensure all regions (headline, health, service, quit) remain usable when constrained
- Update DailyBarGraph._is_constrained to accept terminal_width parameter
- Add comprehensive tests for constrained layout behavior

Covers TPH-9 and cross-cutting TPH regression proof.
Closes #81
This commit is contained in:
xavierk
2026-09-14 17:05:44 +05:30
parent 79478653fa
commit 197e8ed02d
2 changed files with 393 additions and 3 deletions
+281
View File
@@ -57,6 +57,8 @@ from fenris.tui import (
_GLYPH_GAP,
_GLYPH_ZERO,
_GLYPH_PARTIAL,
_MIN_WIDTH,
_MIN_HEIGHT,
)
@@ -1095,3 +1097,282 @@ class TestBarGraphTUI:
assert _GLYPH_GAP in legend
assert _GLYPH_ZERO in legend
assert _GLYPH_PARTIAL in legend
# ---------------------------------------------------------------------------
# Issue #81: Constrained size layout
# ---------------------------------------------------------------------------
class TestConstrainedLayout:
"""Tests for constrained terminal size behavior (issue #81)."""
@pytest.mark.asyncio
async def test_constrained_at_80x24(self, tmp_path):
"""At 80×24 the dashboard renders normally with all regions visible."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_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:
# All four regions should be visible
assert app.query_one("#headline-band") is not None
assert app.query_one("#usage-history") is not None
assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None
# 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"
@pytest.mark.asyncio
async def test_constrained_below_80_width(self, tmp_path):
"""Below 80 columns the graph is hidden and textual summary shown."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_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=(79, 24)) as pilot:
# 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_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "days" in summary_text
assert "GB total" in summary_text
@pytest.mark.asyncio
async def test_constrained_below_24_height(self, tmp_path):
"""Below 24 rows the graph is hidden and textual summary shown."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_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, 23)) as pilot:
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
@pytest.mark.asyncio
async def test_resize_from_constrained_to_normal(self, tmp_path):
"""Resizing from constrained to normal restores graph and coherent state."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_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=(79, 24)) as pilot:
# Start in constrained mode
assert app._is_constrained_mode
graph = app.query_one("#usage-history")
# Select a day while constrained
graph.selected_index = 5
# Resize to normal — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(80, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should no longer be constrained
assert not app._is_constrained_mode
# Main grid should not have constrained class
main_grid = app.query_one("#main-grid")
assert not main_grid.has_class("constrained")
# Selection should be preserved
assert graph.selected_index == 5
# Graph should have data
assert len(graph._day_data) > 0
@pytest.mark.asyncio
async def test_resize_from_normal_to_constrained(self, tmp_path):
"""Resizing from normal to constrained hides graph and shows summary."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_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:
# Start in normal mode
assert not app._is_constrained_mode
graph = app.query_one("#usage-history")
# Focus and select a day
app.set_focus(graph)
await pilot.pause()
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
# Resize to constrained — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(79, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should be in constrained mode
assert app._is_constrained_mode
# 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
# Selected day context should survive
assert "2026-09" in summary_text
@pytest.mark.asyncio
async def test_constrained_summary_empty_store(self, tmp_path):
"""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:
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 "awaiting" in summary_text.lower()
@pytest.mark.asyncio
async def test_constrained_all_regions_usable(self, tmp_path):
"""All regions remain usable in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_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=(79, 24)) as pilot:
# 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
# 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())
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
@pytest.mark.asyncio
async def test_constrained_long_reasons_visible(self, tmp_path):
"""Long status reasons remain visible in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
# Insert stale data
stale_ts = (_clock() - timedelta(days=10)).isoformat()
_insert_sample(conn, stale_ts, pu=5)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
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()
@pytest.mark.asyncio
async def test_constrained_keyboard_navigation(self, tmp_path):
"""Keyboard focus traversal works in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_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=(79, 24)) as pilot:
# Quit should still work
await pilot.press("q")
assert not app.is_running
@pytest.mark.asyncio
async def test_constrained_theme_and_motion_toggle(self, tmp_path):
"""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:
assert app._is_constrained_mode
# Toggle theme
await pilot.press("t")
await pilot.pause()
# Toggle motion
await pilot.press("m")
await pilot.pause()
# Should still be constrained
assert app._is_constrained_mode
def test_min_width_constant(self):
"""_MIN_WIDTH is 80 columns."""
assert _MIN_WIDTH == 80
def test_min_height_constant(self):
"""_MIN_HEIGHT is 24 rows."""
assert _MIN_HEIGHT == 24
def test_is_constrained_with_terminal_width(self, tmp_path):
"""DailyBarGraph._is_constrained checks terminal width."""
graph = DailyBarGraph()
# Widget-level check (no terminal width)
assert graph._is_constrained() is False
# Terminal width below minimum
assert graph._is_constrained(terminal_width=79) is True
# Terminal width at minimum
assert graph._is_constrained(terminal_width=80) is False
# Terminal width above minimum
assert graph._is_constrained(terminal_width=120) is False