Repair the collection-to-display path so each successful acquisition publishes correct read/write deltas through the observation store and visible dashboard. Fixes: - derive.py: accumulate bytes_read_delta on same-hour hour_observation merge (was silently dropped) - collector.py: rebuild day_aggregates from hour observations after each collection run (previously only populated for cross-hour intervals) - day_aggregate.py: add persist_day_aggregate upsert helper - tui.py: query and display both read and write deltas in daily and hourly readouts, constrained summaries, and graph data queries Tests: - Add 14 integration tests (test_measured_activity.py) exercising the full collector→store→reader→display path with real fixtures and injected time - Update constrained-layout assertion to match new W/R format Closes #89
1399 lines
58 KiB
Python
1399 lines
58 KiB
Python
"""Headless tests for the Panes TUI (issue #28).
|
||
|
||
Covers:
|
||
- CI-1: Exhaustive state matrix from synthetic stores
|
||
- TUI-1: One dense keyboard-first screen with four normative regions
|
||
- TUI-4: Layout regions normative per register
|
||
- CI-4: Six disclosures verbatim, empty-store greeting, first-run opt-in
|
||
- IN-3: First-run TUI prompt enables timer and opens first period
|
||
|
||
Criteria: TUI-1, TUI-4, CI-1, CI-4, IN-3.
|
||
"""
|
||
import sqlite3
|
||
from xml.etree import ElementTree
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
import pytest
|
||
import sys
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||
|
||
from textual.app import App
|
||
from textual.pilot import Pilot
|
||
|
||
from fenris.store import init_store, SCHEMA_VERSION
|
||
from fenris.monitoring_periods import ensure_period_open, close_period
|
||
from fenris.projection import (
|
||
ConfidenceState,
|
||
compute_projection,
|
||
DISCLOSURES,
|
||
WARMING_MIN_DAYS,
|
||
STALENESS_HOURS,
|
||
YOUNG_REGIME_DAYS,
|
||
)
|
||
from fenris.status import (
|
||
FRESH_THRESHOLD_S,
|
||
STALENESS_THRESHOLD_S,
|
||
grade_freshness,
|
||
)
|
||
from fenris.tui import (
|
||
FenrisTuiApp,
|
||
DailyBarGraph,
|
||
_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,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _clock(year=2026, month=9, day=30, hour=12):
|
||
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||
|
||
|
||
def _insert_baseline(conn, tbw_tb=1.0, verified=True,
|
||
model="Samsung SSD 970 EVO Plus 1TB"):
|
||
conn.execute(
|
||
"INSERT INTO endurance_baseline "
|
||
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
|
||
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01", model,
|
||
1024000000000, "machine_match" if verified else None, verified,
|
||
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||
identity_key="nqn.test", degraded=False,
|
||
mn="Samsung SSD 970 EVO Plus 1TB"):
|
||
conn.execute(
|
||
"INSERT INTO controller_segments "
|
||
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
|
||
"0x144d", "0x144d", "pcie"),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
|
||
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) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def _insert_sample(conn, ts, pu=5, device="/dev/nvme0n1"):
|
||
conn.execute(
|
||
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(ts, device, 1000000, 500000, pu, 512000000000, 256000000000, 8765),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||
ensure_period_open(conn, datetime.fromisoformat(start))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit tests for helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestFormatRemaining:
|
||
def test_hours_only(self):
|
||
assert _format_remaining(3600) == "1 h"
|
||
|
||
def test_days_and_hours(self):
|
||
assert _format_remaining(86400) == "1 d 0 h"
|
||
|
||
def test_years(self):
|
||
assert _format_remaining(31557600) == "1 yr 0 d 0 h"
|
||
|
||
def test_zero(self):
|
||
assert _format_remaining(0) == "endurance exhausted"
|
||
|
||
def test_negative(self):
|
||
assert _format_remaining(-100) == "endurance exhausted"
|
||
|
||
|
||
class TestQueryDriveHealth:
|
||
def test_empty_store(self, tmp_path):
|
||
conn = init_store(tmp_path / "test.db")
|
||
result = _query_drive_health(conn)
|
||
assert result["model"] == "unknown"
|
||
conn.close()
|
||
|
||
def test_with_sample(self, tmp_path):
|
||
conn = init_store(tmp_path / "test.db")
|
||
_insert_sample(conn, "2026-09-30T10:00:00+00:00", pu=10)
|
||
result = _query_drive_health(conn)
|
||
assert result["percentage_used"] == 10
|
||
conn.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CI-1: Exhaustive state matrix from synthetic stores
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestStateMatrix:
|
||
"""TUI renders every realizable combination of confidence state × freshness × baseline tier."""
|
||
|
||
def test_unsupported_no_baseline(self, tmp_path):
|
||
"""No baseline → Unavailable."""
|
||
conn = init_store(tmp_path / "test.db")
|
||
_insert_segment(conn)
|
||
_open_period(conn)
|
||
for i in range(20):
|
||
d = (datetime(2026, 9, 10) + 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")
|
||
proj = compute_projection(conn, _clock())
|
||
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||
assert proj.headline_remaining_seconds is None
|
||
conn.close()
|
||
|
||
def test_limited_warming(self, tmp_path):
|
||
"""Warming up (< 14 days) → Limited."""
|
||
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(10):
|
||
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")
|
||
proj = compute_projection(conn, _clock())
|
||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||
assert proj.headline_remaining_seconds is not None
|
||
conn.close()
|
||
|
||
def test_supported_full(self, tmp_path):
|
||
"""Full data → Supported."""
|
||
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(30):
|
||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||
proj = compute_projection(conn, _clock())
|
||
assert proj.confidence_state == ConfidenceState.SUPPORTED
|
||
assert proj.headline_remaining_seconds is not None
|
||
conn.close()
|
||
|
||
def test_stale_freshness(self, tmp_path):
|
||
"""Stale data: newest day aggregate > 48h old → Limited or Unsupported."""
|
||
conn = init_store(tmp_path / "test.db")
|
||
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||
# Segment opened long ago so days are old
|
||
_insert_segment(conn, opened_at="2026-08-01T00:00:00+00:00")
|
||
_open_period(conn, start="2026-08-01T00:00:00+00:00")
|
||
# Days all end on Aug 30 — 31 days before clock (Sept 30)
|
||
for i in range(30):
|
||
d = (datetime(2026, 8, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||
# Sample also old
|
||
stale_ts = (_clock() - timedelta(days=31)).isoformat()
|
||
_insert_sample(conn, stale_ts)
|
||
proj = compute_projection(conn, _clock())
|
||
# Stale data (> 48h since newest day) → not Supported
|
||
assert proj.confidence_state != ConfidenceState.SUPPORTED
|
||
conn.close()
|
||
|
||
def test_empty_store_state(self, tmp_path):
|
||
"""Empty store → no projection, headline=None."""
|
||
conn = init_store(tmp_path / "test.db")
|
||
proj = compute_projection(conn, _clock())
|
||
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||
assert proj.headline_remaining_seconds is None
|
||
conn.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TUI-1: One dense keyboard-first screen
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestDenseScreen:
|
||
"""TUI renders one dense screen with four normative regions."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_four_regions_exist(self, tmp_path):
|
||
"""All four normative regions are present in the DOM."""
|
||
conn = init_store(tmp_path / "test.db")
|
||
_insert_segment(conn)
|
||
_open_period(conn)
|
||
for i in range(20):
|
||
d = (datetime(2026, 9, 10) + 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() as pilot:
|
||
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
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_headline_contains_projection(self, tmp_path):
|
||
"""Headline band shows projection headline."""
|
||
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(30):
|
||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||
_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() as pilot:
|
||
headline = str(app.query_one("#headline-band").render())
|
||
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_confidence_rendered_as_evidence(self, tmp_path):
|
||
"""Confidence is state + contributing facts, never a percentage."""
|
||
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(30):
|
||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||
_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() as pilot:
|
||
headline = str(app.query_one("#headline-band").render())
|
||
assert "projection confidence" in headline.lower()
|
||
# Contributing facts shown, never a percentage as confidence
|
||
# (percentage in "95% interval coverage" is allowed as a fact, not as confidence)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_service_strip_has_four_facts(self, tmp_path):
|
||
"""Service strip has four separate facts (boot, timer, collect, freshness).
|
||
|
||
Maker credit removed from service strip (issue #79: single placement in title).
|
||
"""
|
||
conn = init_store(tmp_path / "test.db")
|
||
_insert_segment(conn)
|
||
_open_period(conn)
|
||
for i in range(20):
|
||
d = (datetime(2026, 9, 10) + 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() as pilot:
|
||
strip = str(app.query_one("#service-strip").render())
|
||
assert "Boot:" in strip
|
||
assert "Timer:" in strip
|
||
assert "Last collect:" in strip
|
||
assert "Freshness:" in strip
|
||
# Maker credit now in titlebox only (issue #79)
|
||
assert "by Bongbetic" not in strip
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
|
||
"""The visible action footer excludes quit because the rail owns it."""
|
||
conn = init_store(tmp_path / "test.db")
|
||
_open_period(conn)
|
||
conn.close()
|
||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||
|
||
with patch("fenris.status.query_service_state", return_value={
|
||
"boot_enabled": True, "timer_active": True,
|
||
"last_collect_ok": True, "last_collect_age_s": 60,
|
||
"last_collect_reason": None,
|
||
}):
|
||
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")
|
||
service = app.query_one("#service-strip")
|
||
quit_rail = app.query_one("#quit-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 "q quit" not in strip
|
||
assert rail == "q Quit TUI"
|
||
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
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_deliberate_pause_banner_is_visible_and_quit_preserves_periods(self, tmp_path):
|
||
"""The Pilot sees the paused block; q leaves persisted monitoring state alone."""
|
||
db = tmp_path / "test.db"
|
||
conn = init_store(db)
|
||
conn.execute(
|
||
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||
"VALUES (?, ?, ?)",
|
||
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
app = FenrisTuiApp(store_path=db)
|
||
|
||
with patch("fenris.status.query_service_state", return_value={
|
||
"boot_enabled": False, "timer_active": False,
|
||
"last_collect_ok": None, "last_collect_age_s": None,
|
||
"last_collect_reason": None,
|
||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||
app, "_run_helper"
|
||
) as run_helper:
|
||
async with app.run_test(size=(80, 24)) as pilot:
|
||
await pilot.pause()
|
||
paused_banner = app.query_one("#paused-banner")
|
||
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) == 5
|
||
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(
|
||
"#service-strip"
|
||
).region.y
|
||
screenshot = app.export_screenshot()
|
||
visible_text = " ".join(
|
||
"".join(ElementTree.fromstring(screenshot).itertext()).split()
|
||
)
|
||
assert "Paused time is excluded from your usage habit" in visible_text
|
||
assert "resume: fenris monitor resume" in visible_text
|
||
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()
|
||
).split()
|
||
)
|
||
assert "q Quit TUI" in footer_text
|
||
await pilot.press("q")
|
||
assert not app.is_running
|
||
subprocess_run.assert_not_called()
|
||
run_helper.assert_not_called()
|
||
|
||
conn = sqlite3.connect(db)
|
||
row = conn.execute(
|
||
"SELECT ended_at, end_cause FROM monitoring_periods"
|
||
).fetchone()
|
||
conn.close()
|
||
assert row == ("2026-09-01T10:00:00+00:00", "user_disabled")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_quit_preserves_an_active_monitoring_period(self, tmp_path):
|
||
"""Quitting an active dashboard never closes or mutates its period."""
|
||
db = tmp_path / "test.db"
|
||
conn = init_store(db)
|
||
_open_period(conn, "2026-09-01T09:00:00+00:00")
|
||
conn.close()
|
||
app = FenrisTuiApp(store_path=db)
|
||
|
||
with patch("fenris.status.query_service_state", return_value={
|
||
"boot_enabled": True, "timer_active": True,
|
||
"last_collect_ok": None, "last_collect_age_s": None,
|
||
"last_collect_reason": None,
|
||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||
app, "_run_helper"
|
||
) as run_helper:
|
||
async with app.run_test() as pilot:
|
||
await pilot.press("q")
|
||
assert not app.is_running
|
||
subprocess_run.assert_not_called()
|
||
run_helper.assert_not_called()
|
||
|
||
conn = sqlite3.connect(db)
|
||
row = conn.execute(
|
||
"SELECT started_at, ended_at, end_cause FROM monitoring_periods"
|
||
).fetchone()
|
||
conn.close()
|
||
assert row == ("2026-09-01T09:00:00+00:00", None, None)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_branding_and_one_time_auth_banner(self, tmp_path):
|
||
"""Identity is visible at launch; auth notice clears once per session.
|
||
|
||
Maker credit in titlebox only (issue #79: Fenris by Bongbetic).
|
||
"""
|
||
app = FenrisTuiApp(
|
||
store_path=tmp_path / "nonexistent.db",
|
||
refresh_interval_s=0.2,
|
||
)
|
||
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
|
||
|
||
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
|
||
# 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())
|
||
|
||
await pilot.pause(0.25)
|
||
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||
|
||
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())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CI-4: Disclosures, empty-store greeting, first-run
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestDisclosuresAndGreeting:
|
||
@pytest.mark.asyncio
|
||
async def test_disclosures_screen(self, tmp_path):
|
||
"""Disclosures view renders six disclosures verbatim."""
|
||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||
async with app.run_test() as pilot:
|
||
await pilot.press("d")
|
||
# Modal should be pushed
|
||
assert len(app.screen_stack) > 1
|
||
disc_text = str(app.screen.query_one("Static").render())
|
||
for i in range(1, 7):
|
||
assert "%d." % i in disc_text
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_store_greeting(self, tmp_path):
|
||
"""Empty store shows 'no observations yet' with enable hint."""
|
||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||
async with app.run_test() as pilot:
|
||
headline = str(app.query_one("#headline-band").render())
|
||
assert "no observations yet" in headline.lower()
|
||
assert "enable" in headline.lower() or "resume" in headline.lower()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# IN-3: First-run opt-in
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestFirstRun:
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("size", [(80, 24), (60, 18)])
|
||
async def test_sudo_help_remains_available_after_launch(self, tmp_path, size):
|
||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||
with patch.object(app, "_run_helper") as run_helper:
|
||
async with app.run_test(size=size) as pilot:
|
||
app.on_refresh_tick()
|
||
await pilot.press("?")
|
||
help_text = str(app.screen.query_one("#help-text").render())
|
||
assert "The dashboard does not need sudo" in help_text
|
||
assert "sudo fenris monitor resume" in help_text
|
||
assert "sudo fenris monitor pause" in help_text
|
||
assert "sudo fenris sample" in help_text
|
||
assert 'sudo usermod -aG fenris "$USER"' in help_text
|
||
assert "log out and back in" in help_text
|
||
await pilot.press("end")
|
||
visible = " ".join("".join(
|
||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||
).split())
|
||
assert "Esc Close" in visible
|
||
await pilot.press("escape")
|
||
assert len(app.screen_stack) == 1
|
||
run_helper.assert_not_called()
|
||
|
||
def test_missing_polkit_identifies_command_and_points_to_help(self, tmp_path):
|
||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", "pkexec")), \
|
||
patch.object(app, "suspend"), patch.object(app, "_refresh"), \
|
||
patch.object(app, "notify") as notify:
|
||
app.action_resume()
|
||
message = notify.call_args.args[0]
|
||
assert "Command not found: pkexec" in message
|
||
assert "sudo /usr/libexec/fenris/fenris-monitor enable --now" in message
|
||
|
||
def test_unprivileged_resume_uses_polkit(self, tmp_path):
|
||
"""TUI controls use the same authenticated path as the CLI."""
|
||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||
|
||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||
patch("fenris.control.subprocess.run") as run, \
|
||
patch.object(app, "suspend"), \
|
||
patch.object(app, "_refresh"):
|
||
run.return_value.returncode = 0
|
||
app._run_helper("enable", ["--now"])
|
||
|
||
run.assert_called_once_with(
|
||
["pkexec", "/usr/libexec/fenris/fenris-monitor", "enable", "--now"],
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_first_run_prompt(self, tmp_path):
|
||
"""First-run prompt makes the keyboard action and boot effect explicit."""
|
||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||
async with app.run_test() as pilot:
|
||
headline = str(app.query_one("#headline-band").render())
|
||
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()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TUI-4: Layout regions normative
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestLayoutNormative:
|
||
@pytest.mark.asyncio
|
||
async def test_no_page_navigation(self, tmp_path):
|
||
"""No page navigation keys exist (variant switching was prototype-only)."""
|
||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||
# Check that only production bindings exist
|
||
binding_keys = {b.key for b in app.BINDINGS}
|
||
assert "left" not in binding_keys
|
||
assert "right" not in binding_keys
|
||
assert "1" not in binding_keys
|
||
assert "2" not in binding_keys
|
||
assert "3" not in binding_keys
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pause_resume_asymmetry(self, tmp_path):
|
||
"""Pause asks, resume does not (TUI-2)."""
|
||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||
async with app.run_test() as pilot:
|
||
# Pause should push a confirmation screen
|
||
await pilot.press("p")
|
||
assert len(app.screen_stack) > 1
|
||
# Press n to cancel
|
||
await pilot.press("n")
|
||
assert len(app.screen_stack) == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CI-1: State matrix exhaustive combinations
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestStateMatrixCombinations:
|
||
"""CI-1: confidence × freshness × baseline tier combinations."""
|
||
|
||
def test_unsupported_with_fresh_data(self, tmp_path):
|
||
"""Fresh data but no baseline → Unavailable + fresh."""
|
||
conn = init_store(tmp_path / "test.db")
|
||
_insert_segment(conn)
|
||
_open_period(conn)
|
||
for i in range(20):
|
||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||
_insert_day(conn, d, bw=1024*1024*100)
|
||
# Sample must be within FRESH_THRESHOLD_S of clock
|
||
fresh_ts = (_clock() - timedelta(seconds=FRESH_THRESHOLD_S - 10)).isoformat()
|
||
_insert_sample(conn, fresh_ts)
|
||
proj = compute_projection(conn, _clock())
|
||
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||
freshness = grade_freshness(fresh_ts, _clock())
|
||
assert freshness == "fresh"
|
||
conn.close()
|
||
|
||
def test_limited_with_stale_data(self, tmp_path):
|
||
"""Stale data with baseline → Limited or Unsupported (young regime + stale)."""
|
||
conn = init_store(tmp_path / "test.db")
|
||
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||
_insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00")
|
||
_open_period(conn)
|
||
for i in range(20):
|
||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||
_insert_day(conn, d, bw=1024*1024*100)
|
||
stale_ts = (_clock() - timedelta(days=3)).isoformat()
|
||
_insert_sample(conn, stale_ts)
|
||
proj = compute_projection(conn, _clock())
|
||
assert proj.confidence_state != ConfidenceState.SUPPORTED
|
||
freshness = grade_freshness(stale_ts, _clock())
|
||
assert freshness == "stale"
|
||
conn.close()
|
||
|
||
def test_supported_with_unverified_baseline(self, tmp_path):
|
||
"""Incomplete provenance → unverified baseline tier."""
|
||
conn = init_store(tmp_path / "test.db")
|
||
# Insert baseline with incomplete provenance (missing source_url)
|
||
conn.execute(
|
||
"INSERT INTO endurance_baseline "
|
||
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
|
||
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(1.0, None, "v1.0", "2026-01-01", "Samsung SSD 970 EVO Plus 1TB",
|
||
1024000000000, None, False,
|
||
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
|
||
)
|
||
conn.commit()
|
||
_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, coverage=0.95, samples=24)
|
||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||
proj = compute_projection(conn, _clock())
|
||
# 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()
|
||
|
||
def test_window_is_consecutive_utc_days_with_explicit_gaps(self, tmp_path):
|
||
conn = init_store(tmp_path / "test.db")
|
||
_insert_day(conn, "2026-09-14", bw=500)
|
||
result = _query_daily_graph_data(
|
||
conn, datetime(2026, 9, 16, 12, tzinfo=timezone.utc),
|
||
)
|
||
assert len(result) == 90
|
||
assert result[-3]["day"] == "2026-09-14"
|
||
assert result[-2]["day"] == "2026-09-15"
|
||
assert result[-2]["is_gap"] is True
|
||
assert result[-1]["day"] == "2026-09-16"
|
||
assert result[-1]["is_partial"] 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", datetime(2026, 9, 21, tzinfo=timezone.utc),
|
||
)
|
||
assert len(result) == 24
|
||
assert all(item["is_gap"] for item in 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", datetime(2026, 9, 21, tzinfo=timezone.utc),
|
||
)
|
||
assert len(result) == 24
|
||
assert result[12]["local_label"] == "12"
|
||
assert result[12]["bytes_written"] == 1000
|
||
conn.close()
|
||
|
||
def test_current_hour_is_partial_and_future_hours_are_not_gaps(self, tmp_path):
|
||
conn = init_store(tmp_path / "test.db")
|
||
result = _query_hourly_graph_data(
|
||
conn, "2026-09-20",
|
||
datetime(2026, 9, 20, 14, 30, tzinfo=timezone.utc),
|
||
)
|
||
assert result[13]["is_gap"] is True
|
||
assert result[14]["is_partial"] is True
|
||
assert result[14]["is_gap"] is True
|
||
assert result[15]["is_future"] is True
|
||
assert result[15]["is_gap"] is False
|
||
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
|
||
|
||
def test_scale_includes_allocated_and_unallocated_writes(self):
|
||
graph = DailyBarGraph()
|
||
graph._all_day_data = [{
|
||
"day": "2026-09-16",
|
||
"total_bytes": 800,
|
||
"allocated_bytes": 500,
|
||
"unallocated_bytes": 300,
|
||
}]
|
||
graph._trim_to_range()
|
||
assert graph._max_bytes == 800
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
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
|
||
|
||
@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()
|
||
|
||
# The newest day is selected by default; left moves backward.
|
||
initial = graph.selected_index
|
||
await pilot.press("left")
|
||
await pilot.pause()
|
||
assert graph.selected_index == initial - 1
|
||
|
||
# Right returns to the newest day.
|
||
await pilot.press("right")
|
||
await pilot.pause()
|
||
assert graph.selected_index == initial
|
||
|
||
@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 30-day range
|
||
await pilot.press("3")
|
||
await pilot.pause()
|
||
assert graph.range_days == 30
|
||
assert len(graph._day_data) == 30
|
||
|
||
# Exact windows retain gap slots instead of compressing time.
|
||
await pilot.press("4")
|
||
await pilot.pause()
|
||
assert graph.range_days == 90
|
||
assert len(graph._day_data) == 90
|
||
|
||
@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()
|
||
|
||
# Newest day is selected initially.
|
||
readout = str(app.query_one("#bar-readout").render())
|
||
assert "UTC" in readout
|
||
assert "GB" in readout
|
||
|
||
# Moving left updates the selected-day readout.
|
||
await pilot.press("left")
|
||
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()
|
||
|
||
# The newest day is selected automatically.
|
||
assert graph.selected_index == len(graph._day_data) - 1
|
||
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
|
||
|
||
# Five-minute data refreshes retain the selected hour.
|
||
graph._hourly_selected = 12
|
||
selected_hour = graph._hour_data[12]["hour"]
|
||
app._refresh()
|
||
await pilot.pause()
|
||
assert graph.view_mode == "hourly"
|
||
assert graph._hour_data[graph._hourly_selected]["hour"] == selected_hour
|
||
|
||
# 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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" 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()
|
||
assert graph.selected_index == len(graph._day_data) - 1
|
||
|
||
# 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
|