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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user