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:
+112
-3
@@ -158,6 +158,10 @@ _BAR_HEIGHT = 6
|
|||||||
_BAR_WIDTH = 2
|
_BAR_WIDTH = 2
|
||||||
_BAR_SPACING = 1
|
_BAR_SPACING = 1
|
||||||
|
|
||||||
|
# Terminal size thresholds for constrained layout (issue #81)
|
||||||
|
_MIN_WIDTH = 80
|
||||||
|
_MIN_HEIGHT = 24
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Interactive daily bar graph widget
|
# Interactive daily bar graph widget
|
||||||
@@ -269,17 +273,20 @@ class DailyBarGraph(Widget):
|
|||||||
self.query_one("#bar-legend").update("")
|
self.query_one("#bar-legend").update("")
|
||||||
self.query_one("#bar-readout").update("")
|
self.query_one("#bar-readout").update("")
|
||||||
|
|
||||||
def _is_constrained(self) -> bool:
|
def _is_constrained(self, terminal_width: int = 0) -> bool:
|
||||||
"""Return True when the widget is too narrow for the bar graph.
|
"""Return True when the terminal or widget is too narrow for the bar graph.
|
||||||
|
|
||||||
At 80 columns with a 3fr:2fr grid split the pane content width is ~46
|
At 80 columns with a 3fr:2fr grid split the pane content width is ~46
|
||||||
(after round border + padding). Below 32 the bars become unreadable.
|
(after round border + padding). Below 32 the bars become unreadable.
|
||||||
|
When terminal_width is provided (issue #81), also check against _MIN_WIDTH.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
w = self.region.width
|
w = self.region.width
|
||||||
|
if terminal_width > 0 and terminal_width < _MIN_WIDTH:
|
||||||
|
return True
|
||||||
return 0 < w < 32
|
return 0 < w < 32
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return terminal_width > 0 and terminal_width < _MIN_WIDTH
|
||||||
|
|
||||||
def _show_constrained_summary(self) -> None:
|
def _show_constrained_summary(self) -> None:
|
||||||
"""Textual fallback for terminals below 80×24."""
|
"""Textual fallback for terminals below 80×24."""
|
||||||
@@ -930,6 +937,28 @@ class FenrisTuiApp(App):
|
|||||||
grid-size: 2 5;
|
grid-size: 2 5;
|
||||||
grid-rows: 8 5 10 7 3;
|
grid-rows: 8 5 10 7 3;
|
||||||
}
|
}
|
||||||
|
#main-grid.constrained {
|
||||||
|
grid-size: 1 4;
|
||||||
|
grid-columns: 1fr;
|
||||||
|
grid-rows: auto auto auto auto;
|
||||||
|
}
|
||||||
|
#main-grid.constrained.paused {
|
||||||
|
grid-size: 1 5;
|
||||||
|
grid-rows: auto auto auto auto auto;
|
||||||
|
}
|
||||||
|
#main-grid.constrained #usage-history {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#main-grid.constrained #constrained-summary {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#constrained-summary {
|
||||||
|
display: none;
|
||||||
|
border: round #555555;
|
||||||
|
padding: 0 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 3;
|
||||||
|
}
|
||||||
#dashboard-scroll { height: 1fr; }
|
#dashboard-scroll { height: 1fr; }
|
||||||
#headline-band { column-span: 2; }
|
#headline-band { column-span: 2; }
|
||||||
#paused-banner {
|
#paused-banner {
|
||||||
@@ -987,12 +1016,16 @@ class FenrisTuiApp(App):
|
|||||||
for theme in THEMES.values():
|
for theme in THEMES.values():
|
||||||
self.register_theme(theme)
|
self.register_theme(theme)
|
||||||
|
|
||||||
|
# Track constrained state for resize handling (issue #81)
|
||||||
|
self._is_constrained_mode = False
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with VerticalScroll(id="dashboard-scroll"):
|
with VerticalScroll(id="dashboard-scroll"):
|
||||||
with Container(id="main-grid"):
|
with Container(id="main-grid"):
|
||||||
yield Static("", id="headline-band", classes="pane")
|
yield Static("", id="headline-band", classes="pane")
|
||||||
yield Static("", id="paused-banner")
|
yield Static("", id="paused-banner")
|
||||||
yield DailyBarGraph(id="usage-history", classes="pane")
|
yield DailyBarGraph(id="usage-history", classes="pane")
|
||||||
|
yield Static("", id="constrained-summary", classes="pane")
|
||||||
yield Static("", id="drive-health", classes="pane")
|
yield Static("", id="drive-health", classes="pane")
|
||||||
yield Static("", id="service-strip", classes="pane")
|
yield Static("", id="service-strip", classes="pane")
|
||||||
yield Static("q QUIT TUI", id="quit-rail")
|
yield Static("q QUIT TUI", id="quit-rail")
|
||||||
@@ -1005,6 +1038,7 @@ class FenrisTuiApp(App):
|
|||||||
|
|
||||||
self.query_one("#headline-band").border_title = "headline"
|
self.query_one("#headline-band").border_title = "headline"
|
||||||
self.query_one("#usage-history").border_title = "usage history"
|
self.query_one("#usage-history").border_title = "usage history"
|
||||||
|
self.query_one("#constrained-summary").border_title = "usage history"
|
||||||
self.query_one("#drive-health").border_title = "drive"
|
self.query_one("#drive-health").border_title = "drive"
|
||||||
self.query_one("#service-strip").border_title = "service + actions"
|
self.query_one("#service-strip").border_title = "service + actions"
|
||||||
self._refresh_timer = self.set_interval(
|
self._refresh_timer = self.set_interval(
|
||||||
@@ -1012,6 +1046,55 @@ class FenrisTuiApp(App):
|
|||||||
)
|
)
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
|
def on_resize(self) -> None:
|
||||||
|
"""Handle terminal resize to switch between constrained and normal layout (issue #81)."""
|
||||||
|
width, height = self.size
|
||||||
|
was_constrained = self._is_constrained_mode
|
||||||
|
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||||
|
|
||||||
|
main_grid = self.query_one("#main-grid")
|
||||||
|
if self._is_constrained_mode:
|
||||||
|
main_grid.add_class("constrained")
|
||||||
|
else:
|
||||||
|
main_grid.remove_class("constrained")
|
||||||
|
|
||||||
|
# Re-render constrained summary when entering constrained mode
|
||||||
|
if self._is_constrained_mode and not was_constrained:
|
||||||
|
self._update_constrained_summary()
|
||||||
|
# Re-render full layout when leaving constrained mode
|
||||||
|
elif not self._is_constrained_mode and was_constrained:
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _update_constrained_summary(self) -> None:
|
||||||
|
"""Update the textual summary shown when terminal is too small for graph."""
|
||||||
|
graph = self.query_one("#usage-history")
|
||||||
|
summary = self.query_one("#constrained-summary")
|
||||||
|
|
||||||
|
if not graph._day_data:
|
||||||
|
summary.update("[dim]graph needs ≥80×24[/dim]\nawaiting first sample")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Summarise visible days as text (issue #81 AC2)
|
||||||
|
total_bytes = sum(d.get("total_bytes", 0) for d in graph._day_data)
|
||||||
|
days_with_data = sum(1 for d in graph._day_data if d.get("total_bytes", 0) > 0)
|
||||||
|
n = len(graph._day_data)
|
||||||
|
first = graph._day_data[0].get("local_label", "")
|
||||||
|
last = graph._day_data[-1].get("local_label", "")
|
||||||
|
text = (
|
||||||
|
"[dim]graph needs ≥80×24[/dim]\n"
|
||||||
|
" %d days · %d with writes · %.3f GB total\n"
|
||||||
|
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preserve selected-day context (issue #81 AC2)
|
||||||
|
if 0 <= graph.selected_index < len(graph._day_data):
|
||||||
|
day = graph._day_data[graph.selected_index]
|
||||||
|
text += "\n [bold]%s[/bold] · %.3f GB" % (
|
||||||
|
day.get("local_label", ""), day.get("total_bytes", 0) / 1e9
|
||||||
|
)
|
||||||
|
|
||||||
|
summary.update(text)
|
||||||
|
|
||||||
def on_refresh_tick(self) -> None:
|
def on_refresh_tick(self) -> None:
|
||||||
"""Refresh dashboard and dismiss launch-only authentication guidance."""
|
"""Refresh dashboard and dismiss launch-only authentication guidance."""
|
||||||
self._show_auth_notice = False
|
self._show_auth_notice = False
|
||||||
@@ -1047,6 +1130,20 @@ class FenrisTuiApp(App):
|
|||||||
def _refresh(self) -> None:
|
def _refresh(self) -> None:
|
||||||
"""Refresh all four regions from store data."""
|
"""Refresh all four regions from store data."""
|
||||||
self._clock_now = datetime.now(timezone.utc)
|
self._clock_now = datetime.now(timezone.utc)
|
||||||
|
# Also update constrained state on each refresh (issue #81)
|
||||||
|
try:
|
||||||
|
width, height = self.size
|
||||||
|
was_constrained = self._is_constrained_mode
|
||||||
|
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||||
|
main_grid = self.query_one("#main-grid")
|
||||||
|
if self._is_constrained_mode:
|
||||||
|
main_grid.add_class("constrained")
|
||||||
|
else:
|
||||||
|
main_grid.remove_class("constrained")
|
||||||
|
if self._is_constrained_mode and not was_constrained:
|
||||||
|
self._update_constrained_summary()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
conn = self._open_store()
|
conn = self._open_store()
|
||||||
|
|
||||||
if conn is None:
|
if conn is None:
|
||||||
@@ -1070,6 +1167,10 @@ class FenrisTuiApp(App):
|
|||||||
"Enable monitoring: fenris monitor resume"
|
"Enable monitoring: fenris monitor resume"
|
||||||
)
|
)
|
||||||
self.query_one("#usage-history").set_data([])
|
self.query_one("#usage-history").set_data([])
|
||||||
|
if self._is_constrained_mode:
|
||||||
|
self.query_one("#constrained-summary").update(
|
||||||
|
"[dim]graph needs ≥80×24[/dim]\nawaiting first sample"
|
||||||
|
)
|
||||||
self.query_one("#drive-health").update("")
|
self.query_one("#drive-health").update("")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
||||||
@@ -1084,6 +1185,10 @@ class FenrisTuiApp(App):
|
|||||||
"Check journalctl -u fenris-collect.service"
|
"Check journalctl -u fenris-collect.service"
|
||||||
)
|
)
|
||||||
self.query_one("#usage-history").set_data([])
|
self.query_one("#usage-history").set_data([])
|
||||||
|
if self._is_constrained_mode:
|
||||||
|
self.query_one("#constrained-summary").update(
|
||||||
|
"[dim]graph needs ≥80×24[/dim]\nobservation store unreadable"
|
||||||
|
)
|
||||||
self.query_one("#drive-health").update("")
|
self.query_one("#drive-health").update("")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||||
@@ -1133,6 +1238,10 @@ class FenrisTuiApp(App):
|
|||||||
else:
|
else:
|
||||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
||||||
|
|
||||||
|
# Update constrained summary if in constrained mode (issue #81)
|
||||||
|
if self._is_constrained_mode:
|
||||||
|
self._update_constrained_summary()
|
||||||
|
|
||||||
# --- Drive-health pane (§7.2 right, issue #79) ---
|
# --- Drive-health pane (§7.2 right, issue #79) ---
|
||||||
health = _query_drive_health(conn)
|
health = _query_drive_health(conn)
|
||||||
# Vendor wear grouped under Drive health with context
|
# Vendor wear grouped under Drive health with context
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ from fenris.tui import (
|
|||||||
_GLYPH_GAP,
|
_GLYPH_GAP,
|
||||||
_GLYPH_ZERO,
|
_GLYPH_ZERO,
|
||||||
_GLYPH_PARTIAL,
|
_GLYPH_PARTIAL,
|
||||||
|
_MIN_WIDTH,
|
||||||
|
_MIN_HEIGHT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1095,3 +1097,282 @@ class TestBarGraphTUI:
|
|||||||
assert _GLYPH_GAP in legend
|
assert _GLYPH_GAP in legend
|
||||||
assert _GLYPH_ZERO in legend
|
assert _GLYPH_ZERO in legend
|
||||||
assert _GLYPH_PARTIAL 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