Release Fenris 0.5.0 with Chalktone dashboard and dotted activity plots
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""Visible geometry and evidence boundaries of the terminal volume plot."""
|
||||
from fenris.activity_plot import VolumePoint, volume_plot
|
||||
from fenris.themes import get_graph_colors
|
||||
|
||||
|
||||
def plot(points, selected=-1, width=48, height=10):
|
||||
return volume_plot(points, width, height, selected, get_graph_colors("chalktone"))
|
||||
|
||||
|
||||
def test_trace_fits_viewport_and_uses_time_not_sample_index():
|
||||
points = [VolumePoint(t, v, str(t)) for t, v in ((0, 0), (3, 50), (30, 100))]
|
||||
text, columns, unit = plot(points)
|
||||
assert unit == "B"
|
||||
assert len(text.plain.splitlines()) == 10
|
||||
assert all(len(line) <= 48 for line in text.plain.splitlines())
|
||||
assert columns[1] - columns[0] < (columns[2] - columns[0]) / 5
|
||||
assert any(0x2801 <= ord(c) <= 0x28ff for c in text.plain)
|
||||
assert not any(c in text.plain for c in "█▒░")
|
||||
assert "100.00" in text.plain and "0.00" in text.plain
|
||||
|
||||
|
||||
def test_gap_breaks_trace_while_measured_zero_stays_on_axis():
|
||||
text, columns, _ = plot([
|
||||
VolumePoint(0, 0, "00:00"), VolumePoint(1, None, "00:03", "gap"),
|
||||
VolumePoint(2, 100, "00:06"),
|
||||
])
|
||||
rows = text.plain.splitlines()
|
||||
assert "?" in rows[-2]
|
||||
# No invented intermediate dots on either side of the missing measurement.
|
||||
for row in rows[:-2]:
|
||||
assert all(c == " " for c in row[columns[0] + 1:columns[-1]])
|
||||
assert 0x2801 <= ord(rows[-3][columns[0]]) <= 0x28ff
|
||||
|
||||
|
||||
def test_partial_and_unallocated_totals_are_isolated_and_labelled():
|
||||
text, columns, unit = plot([
|
||||
VolumePoint(0, 1_000_000, "01", "partial"),
|
||||
VolumePoint(1, 2_000_000, "02", "unallocated"),
|
||||
VolumePoint(2, 3_000_000, "03"),
|
||||
])
|
||||
assert unit == "MB"
|
||||
assert "~" in text.plain and "u" in text.plain
|
||||
for row in text.plain.splitlines()[:-2]:
|
||||
assert all(c == " " for c in row[columns[0] + 1:columns[1]])
|
||||
|
||||
|
||||
def test_many_intervals_and_selection_fit_small_plot_without_losing_points():
|
||||
points = [VolumePoint(i, i % 9 * 1_000_000, str(i)) for i in range(180)]
|
||||
text, columns, _ = plot(points, selected=100, width=32, height=6)
|
||||
assert len(columns) == len(points)
|
||||
assert text.plain.splitlines()[-2][columns[100]] == "▼"
|
||||
assert all(len(row) == 32 for row in text.plain.splitlines())
|
||||
@@ -0,0 +1,176 @@
|
||||
"""User-facing navigation, zoom, and plotted volume in the redesigned dashboard."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytest
|
||||
from test_tui import (
|
||||
_insert_baseline,
|
||||
_insert_day,
|
||||
_insert_local_day,
|
||||
_insert_segment,
|
||||
_open_period,
|
||||
)
|
||||
|
||||
from fenris.store import init_store
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
NOW = datetime(2026, 9, 19, 12, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "prefs"))
|
||||
|
||||
class Clock(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return NOW
|
||||
|
||||
monkeypatch.setattr("fenris.tui.datetime", Clock)
|
||||
monkeypatch.setattr("fenris.status.query_service_state", lambda: {
|
||||
"boot_enabled": True, "timer_active": True, "last_collect_ok": True,
|
||||
"last_collect_age_s": 0, "last_collect_reason": None,
|
||||
})
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_insert_baseline(conn)
|
||||
_open_period(conn)
|
||||
for offset in range(18):
|
||||
date = (NOW - timedelta(days=offset)).date().isoformat()
|
||||
_insert_day(conn, date, bw=(offset + 1) * 1_000_000_000)
|
||||
_insert_local_day(conn, date, bw=(offset + 1) * 1_000_000_000, br=2_000_000_000)
|
||||
for index in range(61):
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id) VALUES (?, ?, ?, ?, ?)",
|
||||
((NOW - timedelta(minutes=(60 - index) * 3)).isoformat(),
|
||||
"/dev/test", index * 1_000_000, index * 2_000_000, 1),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
|
||||
"VALUES (?, ?, ?, ?, ?)", ("2026-09-18T12:00:00+00:00", 4_000_000, 8_000_000, 1, 20),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
|
||||
|
||||
def visible(app):
|
||||
return " ".join("".join(ElementTree.fromstring(app.export_screenshot()).itertext()).split())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("size", [(140, 44), (80, 24)])
|
||||
async def test_live_plot_inspection_zoom_refresh_and_resize(dashboard, size):
|
||||
app = dashboard
|
||||
async with app.run_test(size=size) as pilot:
|
||||
app.on_refresh_tick()
|
||||
await pilot.pause()
|
||||
assert app.theme == "fenris-chalktone"
|
||||
graph = app.query_one("#live-activity")
|
||||
assert app.focused is graph
|
||||
await pilot.press("left", "w")
|
||||
selected = str(app.query_one("#live-readout").render())
|
||||
assert "11:54" in selected and "W 0.001 GB" in selected and "R 0.002 GB" in selected
|
||||
assert "Reads" in str(app.query_one("#live-legend").render())
|
||||
await pilot.press("z")
|
||||
app.on_refresh_tick()
|
||||
await pilot.pause()
|
||||
assert str(app.query_one("#live-readout").render()) == selected
|
||||
assert app.query_one("#activity-panel").region.width == size[0]
|
||||
for fact in ("Freshness:", "Last collect:", "Boot:", "Timer:", "q Quit TUI"):
|
||||
assert fact in visible(app)
|
||||
await pilot.resize_terminal(100, 30)
|
||||
await pilot.press("escape")
|
||||
assert str(app.query_one("#live-readout").render()) == selected
|
||||
assert app._zoomed_panel is None
|
||||
assert app.query_one("#action-rail").region.bottom <= 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tabs_date_entry_and_hourly_inspection_keep_context(dashboard):
|
||||
app = dashboard
|
||||
async with app.run_test(size=(100, 36)) as pilot:
|
||||
await pilot.click("#view-history")
|
||||
await pilot.press("left", "enter")
|
||||
assert app.query_one("#activity-tabs").active == "view-day"
|
||||
assert "2026-09-18" in str(app.query_one("#local-day").render())
|
||||
# Hour 23 is selected initially; move to the known hour 12.
|
||||
await pilot.press(*(["left"] * 11))
|
||||
readout = str(app.query_one("#bar-readout").render())
|
||||
assert "12:00 UTC" in readout and "W 0.004 GB" in readout
|
||||
await pilot.press("z", "w")
|
||||
app.on_refresh_tick()
|
||||
await pilot.pause()
|
||||
assert str(app.query_one("#bar-readout").render()) == readout
|
||||
await pilot.press("g")
|
||||
await pilot.press(*list("2026-09-17"))
|
||||
await pilot.press("escape")
|
||||
assert str(app.query_one("#bar-readout").render()) == readout
|
||||
assert app._zoomed_panel == "activity-panel"
|
||||
await pilot.press("escape", "t")
|
||||
assert app.query_one("#activity-tabs").active == "view-live"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mouse_inspection_matches_time_axis(dashboard):
|
||||
app = dashboard
|
||||
async with app.run_test(size=(100, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#live-activity")
|
||||
x = graph._point_columns[0]
|
||||
await pilot.click("#live-render", offset=(x, 1))
|
||||
text = str(app.query_one("#live-readout").render())
|
||||
assert "09:00 → 09:03 UTC" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_theme_control_persists_choice_and_quit_never_pauses(dashboard, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(dashboard, "_run_helper", lambda *args: calls.append(args))
|
||||
async with dashboard.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.press("s")
|
||||
assert dashboard.theme == "fenris-amber"
|
||||
await pilot.press("q")
|
||||
assert calls == []
|
||||
from fenris.preferences import load_preferences
|
||||
assert load_preferences()["theme"] == "amber"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("size", [(100, 36), (70, 20)])
|
||||
async def test_unallocated_volume_survives_day_with_missing_coverage(dashboard, size):
|
||||
async with dashboard.run_test(size=size) as pilot:
|
||||
await pilot.click("#view-history")
|
||||
graph = dashboard.query_one("#usage-history")
|
||||
graph.set_data([{
|
||||
"day": "2026-09-18", "total_bytes": 2_000_000_000,
|
||||
"unallocated_bytes": 2_000_000_000, "is_gap": True,
|
||||
"is_partial": True,
|
||||
}])
|
||||
readout = str(dashboard.query_one("#bar-readout").render())
|
||||
assert "W 2.000 GB unallocated" in readout
|
||||
assert "R unavailable" in readout and "gap" in readout
|
||||
if size[0] >= 80:
|
||||
plot = str(dashboard.query_one("#bar-render").render())
|
||||
assert any(0x2801 <= ord(c) <= 0x28ff for c in plot)
|
||||
graph.measure = "read"
|
||||
graph._refresh()
|
||||
plot = str(dashboard.query_one("#bar-render").render())
|
||||
assert not any(0x2801 <= ord(c) <= 0x28ff for c in plot)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("state", ["gap", "future"])
|
||||
async def test_small_terminal_hour_readout_does_not_invent_zero(dashboard, state):
|
||||
async with dashboard.run_test(size=(70, 20)) as pilot:
|
||||
await pilot.click("#view-day")
|
||||
graph = dashboard.query_one("#usage-history")
|
||||
graph.set_hour_data([{
|
||||
"hour": "2026-09-18T12:00:00+00:00", "local_label": "12",
|
||||
"is_gap": state == "gap", "is_future": state == "future",
|
||||
}])
|
||||
graph._hourly_selected = 0
|
||||
graph._refresh_hourly()
|
||||
readout = str(dashboard.query_one("#bar-readout").render())
|
||||
assert "W unavailable · R unavailable" in readout
|
||||
assert state in readout and "12:00 UTC" in readout
|
||||
@@ -95,7 +95,7 @@ class TestFenrisIdentity:
|
||||
)
|
||||
async with app.run_test(size=(120, 24)) as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
assert "🐺 Fenris by Bongbetic" in headline
|
||||
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_titlebox_fallback_without_wolf(self, tmp_path):
|
||||
@@ -108,7 +108,7 @@ class TestFenrisIdentity:
|
||||
async with app.run_test(size=(60, 24)) as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
# Either shows wolf or fallback - both are acceptable
|
||||
assert "Fenris by Bongbetic" in headline
|
||||
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wolf_never_shows_tofu(self, tmp_path):
|
||||
@@ -140,7 +140,7 @@ class TestFenrisIdentity:
|
||||
async with app.run_test(size=(120, 24)) as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
# Identity appears once, lifespan is separate data
|
||||
assert "🐺 Fenris by Bongbetic" in headline
|
||||
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ class TestSingleMakerCredit:
|
||||
)
|
||||
async with app.run_test(size=(120, 24)) as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
assert "Fenris by Bongbetic" in headline
|
||||
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
|
||||
@@ -275,7 +275,7 @@ class TestPreservedBehavior:
|
||||
"""Separate q Quit TUI rail preserved."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(120, 24)) as pilot:
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
rail = str(app.query_one("#action-rail").render())
|
||||
assert "q Quit TUI" in rail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -287,11 +287,11 @@ class TestPreservedBehavior:
|
||||
)
|
||||
async with app.run_test(size=(120, 24)) as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
assert "polkit" in headline.lower()
|
||||
assert "polkit" in str(app.query_one("#auth-notice").render()).lower()
|
||||
# Should clear after first tick
|
||||
await pilot.pause(0.25)
|
||||
headline_after = str(app.query_one("#headline-band").render())
|
||||
assert "polkit" not in headline_after.lower()
|
||||
assert not app.query_one("#auth-notice").display
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliberate_pause_block_preserved(self, tmp_path):
|
||||
|
||||
@@ -93,7 +93,7 @@ class TestPresetLoading:
|
||||
"""Themes load from preferences and apply to the TUI."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tui_applies_amber_theme_by_default(self, tmp_path):
|
||||
async def test_tui_applies_chalktone_theme_by_default(self, tmp_path):
|
||||
"""TUI starts with the Amber theme when no preferences exist."""
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
@@ -102,7 +102,7 @@ class TestPresetLoading:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test() as pilot:
|
||||
# Theme should be fenris-amber
|
||||
assert app.theme == "fenris-amber"
|
||||
assert app.theme == "fenris-chalktone"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
|
||||
@@ -180,7 +180,7 @@ class TestKeyBindings:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test() as pilot:
|
||||
# Start at amber
|
||||
assert app.theme == "fenris-amber"
|
||||
assert app.theme == "fenris-chalktone"
|
||||
|
||||
# Enter drill mode on the graph
|
||||
graph = app.query_one("#usage-history")
|
||||
@@ -382,7 +382,7 @@ class TestSafePersistence:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test() as pilot:
|
||||
# TUI should start with default theme
|
||||
assert app.theme == "fenris-amber"
|
||||
assert app.theme == "fenris-chalktone"
|
||||
# Dashboard should be functional
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
assert headline is not None
|
||||
@@ -401,7 +401,7 @@ class TestSafePersistence:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test() as pilot:
|
||||
# TUI should start without crash
|
||||
assert app.theme in ("fenris-amber", "fenris-nord", "fenris-high-contrast")
|
||||
assert app.theme in ("fenris-chalktone", "fenris-amber", "fenris-nord", "fenris-high-contrast")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+11
-7
@@ -29,7 +29,7 @@ from fenris.local_day import (
|
||||
)
|
||||
from fenris.tui import (
|
||||
FenrisTuiApp,
|
||||
DailyBarGraph,
|
||||
HistoryGraph,
|
||||
_RANGE_OPTIONS,
|
||||
_RANGE_DEFAULT,
|
||||
)
|
||||
@@ -111,11 +111,12 @@ def _make_app(tmp_path, clock=None):
|
||||
import fenris.tui as tui_mod
|
||||
from datetime import datetime as _real_datetime
|
||||
|
||||
mock_dt = type("MockDatetime", (), {
|
||||
"now": staticmethod(lambda tz=None: clock),
|
||||
"strptime": staticmethod(lambda *a, **kw: _real_datetime.strptime(*a, **kw)),
|
||||
"date": staticmethod(lambda: clock.date()),
|
||||
})()
|
||||
class MockDatetime(_real_datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock
|
||||
|
||||
mock_dt = MockDatetime
|
||||
|
||||
patcher = patch.object(tui_mod, "datetime", mock_dt)
|
||||
patcher.start()
|
||||
@@ -262,7 +263,7 @@ class TestArrowInspection:
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
# Focus the graph widget explicitly
|
||||
await pilot.press("v", "v")
|
||||
graph.focus()
|
||||
await pilot.pause()
|
||||
day_before = graph._day_data[graph.selected_index].get("day")
|
||||
@@ -281,6 +282,7 @@ class TestArrowInspection:
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
await pilot.press("v", "v")
|
||||
await pilot.click("#usage-history")
|
||||
|
||||
# Enter drill-down
|
||||
@@ -329,6 +331,7 @@ class TestTodayBinding:
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
await pilot.press("v", "v")
|
||||
await pilot.click("#usage-history")
|
||||
|
||||
# Enter drill
|
||||
@@ -546,6 +549,7 @@ class TestDrillDownData:
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
await pilot.press("v", "v")
|
||||
await pilot.click("#usage-history")
|
||||
|
||||
# Enter drill-down
|
||||
|
||||
@@ -263,7 +263,7 @@ class TestLiveActivityGraph:
|
||||
await pilot.pause()
|
||||
header = str(graph.query_one("#live-header").render())
|
||||
assert "1 intervals" in header
|
||||
assert "w Toggle W/R" in header
|
||||
assert "w W/R" in str(app.query_one("#activity-tools").render())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Covers:
|
||||
- Preference load/save with safe defaults
|
||||
- XDG_CONFIG_HOME user-scoped persistence
|
||||
- Amber default theme, normal-motion default
|
||||
- Chalktone default theme, normal-motion default
|
||||
- Invalid/unreadable/unwritable preference data does not crash
|
||||
- Theme presets: Amber, Nord, High Contrast
|
||||
- Reduced motion preference persistence
|
||||
@@ -72,9 +72,9 @@ class TestPreferencePath:
|
||||
class TestDefaults:
|
||||
"""When no preference file exists, defaults are returned."""
|
||||
|
||||
def test_default_theme_is_amber(self):
|
||||
def test_default_theme_is_chalktone(self):
|
||||
"""Amber is the default theme preset."""
|
||||
assert DEFAULT_THEME == "amber"
|
||||
assert DEFAULT_THEME == "chalktone"
|
||||
|
||||
def test_default_reduced_motion_is_false(self):
|
||||
"""Normal motion is the default."""
|
||||
@@ -82,13 +82,13 @@ class TestDefaults:
|
||||
|
||||
def test_valid_themes_are_all_presets(self):
|
||||
"""Three valid presets exist."""
|
||||
assert VALID_THEMES == {"amber", "nord", "high_contrast"}
|
||||
assert VALID_THEMES == {"chalktone", "amber", "nord", "high_contrast"}
|
||||
|
||||
def test_load_returns_defaults_when_no_file(self, tmp_path):
|
||||
"""Missing preference file returns safe defaults."""
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
|
||||
prefs = load_preferences()
|
||||
assert prefs["theme"] == "amber"
|
||||
assert prefs["theme"] == "chalktone"
|
||||
assert prefs["reduced_motion"] is False
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ class TestSafeFailures:
|
||||
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||
prefs = load_preferences()
|
||||
assert prefs["theme"] == "amber"
|
||||
assert prefs["theme"] == "chalktone"
|
||||
assert prefs["reduced_motion"] is False
|
||||
|
||||
def test_unknown_theme_returns_default(self, tmp_path):
|
||||
@@ -165,7 +165,7 @@ class TestSafeFailures:
|
||||
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||
prefs = load_preferences()
|
||||
assert prefs["theme"] == "amber"
|
||||
assert prefs["theme"] == "chalktone"
|
||||
|
||||
def test_missing_keys_get_defaults(self, tmp_path):
|
||||
"""Partial preference file fills in missing keys."""
|
||||
@@ -218,7 +218,7 @@ class TestSafeFailures:
|
||||
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||
prefs = load_preferences()
|
||||
assert prefs["theme"] == "amber"
|
||||
assert prefs["theme"] == "chalktone"
|
||||
|
||||
def test_wrong_type_for_reduced_motion(self, tmp_path):
|
||||
"""Non-boolean reduced_motion falls back to default."""
|
||||
|
||||
@@ -67,7 +67,7 @@ async def test_cli_tui_acquisition_parity(tmp_path, kind, service):
|
||||
assert not app.query_one("#main-grid").has_class("paused")
|
||||
await pilot.resize_terminal(60, 18)
|
||||
await pilot.pause()
|
||||
assert phrase in str(app.query_one("#constrained-summary").render()).lower()
|
||||
assert phrase in str(app.query_one("#live-render").render()).lower()
|
||||
for text in (cli, strip):
|
||||
assert ("boot: enabled" if service == ACTIVE else "boot: disabled" if service == DISABLED else "boot: unknown") in text
|
||||
assert ("timer: active" if service == ACTIVE else "timer: inactive" if service == DISABLED else "timer: unknown") in text
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Covers:
|
||||
- Three valid presets: Amber, Nord, High Contrast
|
||||
- Amber is the default with amber graph role
|
||||
- Chalktone is the default; Amber retains its amber graph role
|
||||
- Theme roles for graph rendering (allocated, unallocated, gap, zero, partial)
|
||||
- Status semantic colours/glyphs/text always win over theme
|
||||
- Global action reachability and focus contrast in every preset
|
||||
@@ -29,13 +29,13 @@ from fenris.themes import (
|
||||
class TestThemeRegistry:
|
||||
"""All three presets are registered with correct names."""
|
||||
|
||||
def test_three_themes_registered(self):
|
||||
"""Exactly three themes exist."""
|
||||
assert len(THEMES) == 3
|
||||
def test_four_themes_registered(self):
|
||||
"""All four presets are registered."""
|
||||
assert len(THEMES) == 4
|
||||
|
||||
def test_theme_names(self):
|
||||
"""Theme names are amber, nord, high_contrast."""
|
||||
assert THEME_NAMES == {"amber", "nord", "high_contrast"}
|
||||
assert THEME_NAMES == {"chalktone", "amber", "nord", "high_contrast"}
|
||||
|
||||
def test_get_theme_valid(self):
|
||||
"""get_theme returns a Theme for each valid name."""
|
||||
@@ -44,14 +44,14 @@ class TestThemeRegistry:
|
||||
theme = get_theme(name)
|
||||
assert isinstance(theme, Theme)
|
||||
|
||||
def test_get_theme_invalid_returns_amber(self):
|
||||
def test_get_theme_invalid_returns_chalktone(self):
|
||||
"""Unknown theme name returns the amber theme."""
|
||||
theme = get_theme("nonexistent")
|
||||
assert theme.name == "fenris-amber"
|
||||
assert theme.name == "fenris-chalktone"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Amber theme tests (default, amber graph role)
|
||||
# Amber theme tests (amber graph role)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAmberTheme:
|
||||
@@ -179,7 +179,7 @@ class TestGraphColorRoles:
|
||||
assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"]
|
||||
|
||||
def test_zero_role_is_dim(self):
|
||||
"""Zero-usage bars use a dim/subtle colour in all themes."""
|
||||
"""Zero-usage markers use a dim/subtle colour in all themes."""
|
||||
for name in THEME_NAMES:
|
||||
colors = get_graph_colors(name)
|
||||
# Zero should be distinct from allocated
|
||||
|
||||
+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