Files
Fenris/tests/test_tui.py
T

1441 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
HistoryGraph,
_format_remaining,
_query_drive_health,
_query_daily_graph_data,
_query_hourly_graph_data,
_RANGE_OPTIONS,
_RANGE_DEFAULT,
_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)
class _FrozenTuiDateTime(datetime):
@classmethod
def now(cls, tz=None):
instant = datetime(2026, 9, 28, 12, 0, 0, tzinfo=timezone.utc)
return instant.replace(tzinfo=None) if tz is None else instant.astimezone(tz)
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))
conn.commit()
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=1024*1024*100,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row (issue #94 gate prerequisite)."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
"""Insert multiple complete local days to satisfy the issue #94 gate."""
for i in range(count):
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(conn, d, bw=bw)
# ---------------------------------------------------------------------------
# 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")
_insert_complete_local_days(conn, "2026-09-29", 1)
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")
_insert_complete_local_days(conn, "2026-09-29", 1)
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("#action-rail").render())
usage = app.query_one("#activity-panel")
service = app.query_one("#service-strip")
quit_rail = app.query_one("#action-rail")
assert "continuity" in strip
assert "monitoring: active in background · persists across reboots" in strip
assert "r Resume" in str(app.query_one("#action-rail").render())
assert "q quit" not in strip
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
@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")
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 >= 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()
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()
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 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 not app.query_one("#auth-notice").display
await pilot.pause(0.25)
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("#auth-notice").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" in str(app.query_one("#action-rail").render()).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()
# ---------------------------------------------------------------------------
# HistoryGraph widget unit tests (issue #75)
# ---------------------------------------------------------------------------
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 = HistoryGraph()
# 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 = 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 = HistoryGraph()
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 = HistoryGraph()
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 = HistoryGraph()
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 history plot."""
@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_local_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.press("v", "v")
graph = app.query_one("#usage-history")
assert isinstance(graph, HistoryGraph)
assert len(graph._day_data) > 0
# Legend should be visible
legend = str(app.query_one("#bar-legend").render())
assert "Unalloc" in legend
assert "Zero" in legend
range_label = str(app.query_one("#bar-range").render())
assert "14 local days" in range_label
plotted = str(app.query_one("#bar-render").render())
assert "Writes (" in legend
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_local_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.press("v", "v")
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:
await pilot.press("v", "v")
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, monkeypatch):
"""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_local_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime)
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()
await pilot.pause()
# Newest day is selected initially.
readout = str(app.query_one("#bar-readout").render())
assert "totals so far" in readout
assert "W 0.105 GB known" 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 "incomplete" in readout
assert "W 0.105 GB known" in readout
@pytest.mark.asyncio
async def test_hourly_drill_down_and_back(self, tmp_path, monkeypatch):
"""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_local_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()
monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime)
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()
await pilot.pause()
assert app.query_one("#activity-tabs").active == "view-history"
daily_readout = str(app.query_one("#bar-readout").render())
# Enter drill-down
await pilot.press("enter")
await pilot.pause()
assert app.query_one("#activity-tabs").active == "view-day"
assert "UTC" in str(app.query_one("#bar-readout").render())
# Inspection identity survives refresh through visible readout.
await pilot.press("left")
selected_hour = str(app.query_one("#bar-readout").render())
app._refresh()
await pilot.pause()
assert str(app.query_one("#bar-readout").render()) == selected_hour
# Esc returns to daily
await pilot.press("escape")
await pilot.pause()
assert app.query_one("#activity-tabs").active == "view-history"
assert str(app.query_one("#bar-readout").render()) == daily_readout
@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:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
assert isinstance(graph, HistoryGraph)
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:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
assert app.query_one("#activity-panel").border_title == "Drive activity"
@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.press("v", "v")
await pilot.pause()
legend = str(app.query_one("#bar-legend").render())
for meaning in ("Writes", "Unalloc", "Gap", "Zero", "Partial"):
assert meaning 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:
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
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("#bar-render")
assert "Graph needs" not in str(summary.render())
@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:
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("#bar-render")
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:
await pilot.press("v", "v")
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should be visible
summary = app.query_one("#bar-render")
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:
await pilot.press("v", "v")
# Start in constrained mode
assert app._is_constrained_mode
graph = app.query_one("#usage-history")
# 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)
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:
await pilot.press("v", "v")
# 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("#bar-render")
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:
await pilot.press("v", "v")
assert app._is_constrained_mode
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()
@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:
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("#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("#action-rail").render())
assert "p Pause" in strip
assert "r Resume" in strip
assert "q Quit TUI" in strip
# Quit rail should be visible
rail = str(app.query_one("#action-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:
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()
@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:
await pilot.press("v", "v")
# 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:
await pilot.press("v", "v")
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):
"""HistoryGraph._is_constrained checks terminal width."""
graph = HistoryGraph()
# 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