838 lines
33 KiB
Python
838 lines
33 KiB
Python
"""Tests for issue #92: Browse activity dates from the keyboard.
|
|
|
|
Covers:
|
|
- AC92-1: [ and ] select previous/next day, g opens date entry, t returns to today/live
|
|
- AC92-2: Date entry accepts valid dates, reports errors, returns on cancel
|
|
- AC92-3: Selecting a date obtains correct local-day evidence
|
|
- AC92-4: Historical date, measurement, and context remain stable across refresh
|
|
- AC92-5: t resolves to today/live, themes through discoverable control
|
|
- AC92-6: Drill-down shows available data with distinguishable states
|
|
- AC92-7: Keyboard actions verified from normal launch with headless driver
|
|
- AC92-8: Equivalent clickable actions at 80x24 and constrained widths
|
|
"""
|
|
import sqlite3
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import sys
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
from fenris.store import init_store
|
|
from fenris.monitoring_periods import ensure_period_open
|
|
from fenris.local_day import (
|
|
LocalDaySummary,
|
|
local_day_boundaries,
|
|
persist_local_day,
|
|
)
|
|
from fenris.tz_util import detect_system_tz
|
|
from fenris.tui import (
|
|
FenrisTuiApp,
|
|
_RANGE_DEFAULT,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _clock(year=2026, month=9, day=30, hour=12):
|
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
|
identity_key="nqn.test"):
|
|
conn.execute(
|
|
"INSERT INTO controller_segments "
|
|
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(opened_at, identity_key, False, "nqn.test", "SN123",
|
|
"Samsung SSD 970 EVO Plus 1TB", "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 _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
|
|
|
|
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
|
bw=300, br=130, complete=True,
|
|
utc_start=None, utc_end=None):
|
|
if utc_start is None:
|
|
utc_start = f"{local_date}T00:00:00+00:00"
|
|
if utc_end is None:
|
|
utc_end = f"{(datetime.fromisoformat(local_date) + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+00:00"
|
|
summary = LocalDaySummary(
|
|
local_date=local_date,
|
|
tz_name=tz_name,
|
|
tz_offset=tz_offset,
|
|
utc_start=utc_start,
|
|
utc_end=utc_end,
|
|
bytes_written=bw,
|
|
bytes_read=br,
|
|
coverage=0.95,
|
|
sample_count=24,
|
|
complete=complete,
|
|
)
|
|
persist_local_day(conn, summary)
|
|
conn.commit()
|
|
|
|
|
|
def _setup_store(tmp_path, n_days=30, today=None):
|
|
"""Create a store with n_days of data and return (conn, clock)."""
|
|
if today is None:
|
|
today = _clock()
|
|
conn = init_store(tmp_path / "test.db")
|
|
_insert_segment(conn)
|
|
_open_period(conn)
|
|
for i in range(n_days):
|
|
d = (today - timedelta(days=n_days - 1 - i)).strftime("%Y-%m-%d")
|
|
_insert_day(conn, d)
|
|
return conn, today
|
|
|
|
|
|
def _make_app(tmp_path, clock=None):
|
|
"""Create a FenrisTuiApp with the clock mocked to *clock*.
|
|
|
|
Returns (app, patcher) - caller must keep patcher alive during the test.
|
|
"""
|
|
if clock is None:
|
|
clock = _clock()
|
|
from unittest.mock import patch
|
|
import fenris.tui as tui_mod
|
|
from datetime import datetime as _real_datetime
|
|
|
|
class MockDatetime(_real_datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return clock
|
|
|
|
mock_dt = MockDatetime
|
|
|
|
patcher = patch.object(tui_mod, "datetime", mock_dt)
|
|
patcher.start()
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
return app, patcher
|
|
|
|
|
|
def _visible_selected_date(app):
|
|
return str(app.query_one("#local-day").render())[:10]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC92-1: Keyboard bindings exist
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBindingExistence:
|
|
def test_open_square_bracket_binding(self, tmp_path):
|
|
"""The [ binding exists for previous day navigation."""
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
|
binding_keys = {b.key for b in app.BINDINGS}
|
|
assert "left_square_bracket" in binding_keys
|
|
|
|
def test_close_square_bracket_binding(self, tmp_path):
|
|
"""The ] binding exists for next day navigation."""
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
|
binding_keys = {b.key for b in app.BINDINGS}
|
|
assert "right_square_bracket" in binding_keys
|
|
|
|
def test_g_binding_exists(self, tmp_path):
|
|
"""The g binding exists for date entry."""
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
|
binding_keys = {b.key for b in app.BINDINGS}
|
|
assert "g" in binding_keys
|
|
|
|
def test_t_binding_is_today(self, tmp_path):
|
|
"""The t binding maps to action_today, not a theme action."""
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
|
t_binding = next(b for b in app.BINDINGS if b.key == "t")
|
|
assert "today" in t_binding.action.lower()
|
|
|
|
def test_pause_resume_quit_bindings_exist(self, tmp_path):
|
|
"""Existing bindings are preserved."""
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
|
binding_keys = {b.key for b in app.BINDINGS}
|
|
assert "p" in binding_keys # pause
|
|
assert "r" in binding_keys # resume
|
|
assert "q" in binding_keys # quit
|
|
assert "d" in binding_keys # disclosures
|
|
assert "m" in binding_keys # motion toggle
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC92-1: Bracket navigation from normal launch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBracketNavigation:
|
|
@pytest.mark.asyncio
|
|
async def test_right_bracket_advances_day(self, tmp_path):
|
|
"""The ] key moves to the next (newer) day when not at the end."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
# Move to an older day first so ] can advance
|
|
for _ in range(3):
|
|
await pilot.press("left_square_bracket")
|
|
day_before = _visible_selected_date(app)
|
|
|
|
await pilot.press("right_square_bracket")
|
|
day_after = _visible_selected_date(app)
|
|
# ] should move to a newer day (closer to today)
|
|
assert day_after > day_before
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_left_bracket_goes_to_previous_day(self, tmp_path):
|
|
"""The [ key moves to the previous (older) day."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
day_before = _visible_selected_date(app)
|
|
|
|
await pilot.press("left_square_bracket")
|
|
day_after = _visible_selected_date(app)
|
|
# [ should move to an older day (further from today)
|
|
assert day_after < day_before
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bracket_navigates_outside_visible_range(self, tmp_path):
|
|
"""[ beyond the current range shifts the visible window."""
|
|
app_clock = _clock()
|
|
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
|
|
conn.close()
|
|
|
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
|
try:
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
graph = app.query_one("#usage-history")
|
|
assert graph.range_days == _RANGE_DEFAULT
|
|
|
|
# Navigate to oldest day by pressing [ many times
|
|
for _ in range(29):
|
|
await pilot.press("left_square_bracket")
|
|
|
|
# Visible readout identifies oldest selected date.
|
|
selected_day = _visible_selected_date(app)
|
|
oldest_day = (app_clock - timedelta(days=29)).strftime("%Y-%m-%d")
|
|
assert selected_day == oldest_day
|
|
finally:
|
|
patcher.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bracket_at_boundary_does_not_move(self, tmp_path):
|
|
"""] at the last day does not move the selection."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
initial_date = _visible_selected_date(app)
|
|
|
|
await pilot.press("right_square_bracket")
|
|
assert _visible_selected_date(app) == initial_date
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC92-1: Arrow keys inspect the selected view
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestArrowInspection:
|
|
@pytest.mark.asyncio
|
|
async def test_arrow_keys_still_work_in_daily_view(self, tmp_path):
|
|
"""Arrow keys move the selection within the visible daily view."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
await pilot.press("v", "v")
|
|
app.query_one("#usage-history").focus()
|
|
await pilot.pause()
|
|
day_before = _visible_selected_date(app)
|
|
|
|
await pilot.press("left")
|
|
day_after = _visible_selected_date(app)
|
|
assert day_after != day_before
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_arrow_keys_inspect_hours_in_drill(self, tmp_path):
|
|
"""Arrow keys navigate hours when in hourly drill-down."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
local_date = datetime.now().astimezone().date().isoformat()
|
|
timezone_name = detect_system_tz()
|
|
utc_start, utc_end, tz_offset = local_day_boundaries(local_date, timezone_name)
|
|
_insert_local_day(
|
|
conn, local_date, tz_name=timezone_name, tz_offset=tz_offset,
|
|
utc_start=utc_start.isoformat(), utc_end=utc_end.isoformat(),
|
|
)
|
|
hour = utc_start.replace(minute=0, second=0, microsecond=0)
|
|
if hour < utc_start:
|
|
hour += timedelta(hours=1)
|
|
while hour + timedelta(hours=1) <= utc_end:
|
|
conn.execute(
|
|
"INSERT INTO hour_observations "
|
|
"(hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
|
|
"VALUES (?, 1000000, 2000000, 1, 1)",
|
|
(hour.isoformat(),),
|
|
)
|
|
hour += timedelta(hours=1)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
await pilot.press("v", "v")
|
|
await pilot.click("#usage-history")
|
|
|
|
# Enter drill-down
|
|
await pilot.press("enter")
|
|
assert app.query_one("#activity-tabs").active == "view-day"
|
|
|
|
# Arrow keys navigate hours
|
|
readout_before = str(app.query_one("#bar-readout").render())
|
|
await pilot.press("left")
|
|
assert str(app.query_one("#bar-readout").render()) != readout_before
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC92-1: t returns to today/live
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTodayBinding:
|
|
@pytest.mark.asyncio
|
|
async def test_t_returns_to_today_after_browsing(self, tmp_path):
|
|
"""The t key returns to today's live view after browsing."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
|
|
# Browse to an older day using [
|
|
for _ in range(5):
|
|
await pilot.press("left_square_bracket")
|
|
browsed_day = _visible_selected_date(app)
|
|
today = datetime.now().astimezone().date().isoformat()
|
|
assert browsed_day != today
|
|
|
|
# Press t to return to today
|
|
await pilot.press("t")
|
|
assert app.query_one("#activity-tabs").active == "view-live"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_t_exits_hourly_drill(self, tmp_path):
|
|
"""The t key exits hourly drill-down back to daily view."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
await pilot.press("v", "v")
|
|
await pilot.click("#usage-history")
|
|
|
|
# Enter drill
|
|
await pilot.press("enter")
|
|
assert app.query_one("#activity-tabs").active == "view-day"
|
|
|
|
# t returns to daily
|
|
await pilot.press("t")
|
|
assert app.query_one("#activity-tabs").active == "view-live"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC92-2: Date entry screen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDateEntry:
|
|
@pytest.mark.asyncio
|
|
async def test_g_opens_date_entry(self, tmp_path):
|
|
"""The g key opens the date entry screen."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
await pilot.press("g")
|
|
await pilot.pause()
|
|
# Date picker should be pushed as a screen
|
|
assert app.screen.query_one("#date-input") is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_valid_date_navigates(self, tmp_path):
|
|
"""Entering a valid date navigates to that day."""
|
|
app_clock = _clock()
|
|
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
|
|
conn.close()
|
|
|
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
|
try:
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
target_date = (app_clock - timedelta(days=5)).strftime("%Y-%m-%d")
|
|
|
|
await pilot.press("g")
|
|
await pilot.press(*target_date)
|
|
await pilot.press("enter")
|
|
await pilot.pause()
|
|
|
|
assert _visible_selected_date(app) == target_date
|
|
finally:
|
|
patcher.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_date_shows_error(self, tmp_path):
|
|
"""Entering an invalid date shows an error message."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
await pilot.press("g")
|
|
await pilot.pause()
|
|
await pilot.press("n", "o", "t", " ", "a", " ", "d", "a", "t", "e")
|
|
await pilot.press("enter")
|
|
await pilot.pause()
|
|
# Should remain on date picker (error shown, not dismissed)
|
|
error = str(app.screen.query_one("#date-error").render())
|
|
assert "Invalid date format" in error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_returns_to_prior(self, tmp_path):
|
|
"""Pressing escape in date entry returns to the prior view."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
prior_readout = str(app.query_one("#bar-readout").render())
|
|
|
|
await pilot.press("g")
|
|
await pilot.pause()
|
|
await pilot.press("escape")
|
|
await pilot.pause()
|
|
|
|
# Should be back to the graph with the same selection
|
|
assert str(app.query_one("#bar-readout").render()) == prior_readout
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC92-3: Selecting a date obtains correct local-day evidence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLocalDayEvidence:
|
|
@pytest.mark.asyncio
|
|
async def test_bracket_shows_local_day_for_selected_date(self, tmp_path):
|
|
"""Navigating with brackets shows the local-day summary for that date."""
|
|
app_clock = _clock()
|
|
conn, clock = _setup_store(tmp_path, n_days=14, today=app_clock)
|
|
# Insert local-day data for a day within the 14-day range
|
|
target = (app_clock - timedelta(days=4)).strftime("%Y-%m-%d")
|
|
_insert_local_day(conn, target, bw=500, br=200)
|
|
conn.close()
|
|
|
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
|
try:
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
# Navigate to the target day
|
|
for _ in range(4):
|
|
await pilot.press("left_square_bracket")
|
|
await pilot.pause()
|
|
|
|
local_day = app.query_one("#local-day")
|
|
text = str(local_day.render())
|
|
assert target in text
|
|
finally:
|
|
patcher.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_date_picker_shows_local_day(self, tmp_path):
|
|
"""Picking a date via the date picker shows its local-day summary."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
target = (clock - timedelta(days=10)).strftime("%Y-%m-%d")
|
|
_insert_local_day(conn, target, bw=100, br=50)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
# Simulate the date picker callback directly
|
|
app._go_to_date_callback(target)
|
|
await pilot.pause()
|
|
|
|
local_day = app.query_one("#local-day")
|
|
text = str(local_day.render())
|
|
assert target in text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC92-4: Browsing stability across background refresh
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBrowsingStability:
|
|
@pytest.mark.asyncio
|
|
async def test_selection_stable_across_refresh(self, tmp_path):
|
|
"""Browsing selection is preserved when background refresh fires."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
# Browse to a specific day
|
|
for _ in range(3):
|
|
await pilot.press("left_square_bracket")
|
|
browsed_day = _visible_selected_date(app)
|
|
|
|
# Wait for at least one refresh tick
|
|
import asyncio
|
|
await asyncio.sleep(0.3)
|
|
await pilot.pause()
|
|
|
|
# Selection should be preserved
|
|
assert _visible_selected_date(app) == browsed_day
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_navigate_away_does_not_jump_on_refresh(self, tmp_path):
|
|
"""After navigating away from today, refresh does not jump back."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
# Browse to older day using [
|
|
for _ in range(5):
|
|
await pilot.press("left_square_bracket")
|
|
|
|
browsed_day = _visible_selected_date(app)
|
|
today = datetime.now().astimezone().date().isoformat()
|
|
assert browsed_day != today
|
|
|
|
# Wait for refresh
|
|
import asyncio
|
|
await asyncio.sleep(0.3)
|
|
await pilot.pause()
|
|
|
|
# Should NOT jump back to today
|
|
assert _visible_selected_date(app) == browsed_day
|
|
assert _visible_selected_date(app) != today
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC92-6: Drill-down shows available data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDrillDownData:
|
|
@pytest.mark.asyncio
|
|
async def test_drill_down_shows_loading_then_data(self, tmp_path):
|
|
"""Drill-down transitions from loading to actual data."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
await pilot.press("v", "v")
|
|
await pilot.click("#usage-history")
|
|
|
|
# Enter drill-down
|
|
await pilot.press("enter")
|
|
assert app.query_one("#activity-tabs").active == "view-day"
|
|
|
|
assert "hourly evidence unavailable" in str(
|
|
app.query_one("#bar-readout").render()
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AC92-8: Constrained width behavior
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestConstrainedWidth:
|
|
@pytest.mark.asyncio
|
|
async def test_bracket_navigation_works_at_80x24(self, tmp_path):
|
|
"""Date navigation works at minimum 80x24 terminal size."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(80, 24)) as pilot:
|
|
await pilot.pause()
|
|
day_before = _visible_selected_date(app)
|
|
|
|
await pilot.press("left_square_bracket")
|
|
day_after = _visible_selected_date(app)
|
|
assert day_after < day_before
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_g_opens_date_entry_at_80x24(self, tmp_path):
|
|
"""Date entry opens at minimum terminal size."""
|
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(80, 24)) as pilot:
|
|
await pilot.pause()
|
|
await pilot.press("g")
|
|
await pilot.pause()
|
|
assert app.screen.query_one("#date-input") is not None
|
|
|
|
|
|
class TestClickableNavigation:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("size", [(80, 24), (70, 20)])
|
|
async def test_click_and_keyboard_select_same_local_date(self, tmp_path, size):
|
|
"""Clickable and keyboard navigation share date-entry transitions."""
|
|
app_clock = _clock()
|
|
conn, _ = _setup_store(tmp_path, n_days=14, today=app_clock)
|
|
conn.close()
|
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
|
expected = (app_clock - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
direct = (app_clock - timedelta(days=5)).strftime("%Y-%m-%d")
|
|
try:
|
|
async with app.run_test(size=size) as pilot:
|
|
await pilot.press("left_square_bracket")
|
|
keyboard_date = _visible_selected_date(app)
|
|
assert keyboard_date == expected
|
|
|
|
await pilot.press("t")
|
|
await pilot.click("#activity-tools", offset=(2, 0))
|
|
assert _visible_selected_date(app) == keyboard_date
|
|
|
|
await pilot.press("g")
|
|
await pilot.press(*direct)
|
|
await pilot.press("enter")
|
|
await pilot.pause()
|
|
assert _visible_selected_date(app) == direct
|
|
finally:
|
|
patcher.stop()
|
|
|
|
|
|
class TestHistoricalSelectionIdentity:
|
|
@pytest.mark.asyncio
|
|
async def test_unavailable_entered_date_stays_visible_after_refresh(self, tmp_path):
|
|
"""An unavailable requested date remains selected and explained."""
|
|
conn, clock = _setup_store(tmp_path, n_days=30)
|
|
conn.close()
|
|
app, patcher = _make_app(tmp_path, clock=clock)
|
|
target = (clock - timedelta(days=120)).strftime("%Y-%m-%d")
|
|
try:
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.press("g")
|
|
await pilot.press(*target)
|
|
await pilot.press("enter")
|
|
await pilot.pause()
|
|
|
|
local_day = str(app.query_one("#local-day").render())
|
|
assert target in local_day
|
|
assert "unavailable" in local_day
|
|
|
|
app.on_refresh_tick()
|
|
await pilot.pause()
|
|
local_day = str(app.query_one("#local-day").render())
|
|
assert target in local_day
|
|
assert "unavailable" in local_day
|
|
finally:
|
|
patcher.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retained_summary_outside_recent_window_remains_available(self, tmp_path):
|
|
"""Direct date entry can inspect durable local summaries beyond 90 days."""
|
|
clock = _clock()
|
|
conn, _ = _setup_store(tmp_path, n_days=30, today=clock)
|
|
target = (clock - timedelta(days=120)).strftime("%Y-%m-%d")
|
|
utc_start, utc_end, tz_offset = local_day_boundaries(
|
|
target, "Asia/Kolkata",
|
|
)
|
|
_insert_local_day(
|
|
conn,
|
|
target,
|
|
tz_name="Asia/Kolkata",
|
|
tz_offset=tz_offset,
|
|
bw=2_000_000_000,
|
|
br=1_000_000_000,
|
|
utc_start=utc_start.isoformat(),
|
|
utc_end=utc_end.isoformat(),
|
|
)
|
|
conn.close()
|
|
app, patcher = _make_app(tmp_path, clock=clock)
|
|
try:
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.press("g")
|
|
await pilot.press(*target)
|
|
await pilot.press("enter")
|
|
await pilot.pause()
|
|
await pilot.press("v")
|
|
await pilot.pause()
|
|
|
|
readout = str(app.query_one("#bar-readout").render())
|
|
assert target in readout
|
|
assert "Asia/Kolkata +05:30" in readout
|
|
assert "W 2.000 GB known · R 1.000 GB known" in readout
|
|
finally:
|
|
patcher.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_constrained_history_keeps_known_totals_and_evidence_state(self, tmp_path):
|
|
"""Small textual view retains selected day, totals, and shared evidence."""
|
|
clock = _clock()
|
|
conn, _ = _setup_store(tmp_path, n_days=14, today=clock)
|
|
target = (clock - timedelta(days=2)).strftime("%Y-%m-%d")
|
|
utc_start, utc_end, tz_offset = local_day_boundaries(
|
|
target, "Asia/Kolkata",
|
|
)
|
|
_insert_local_day(
|
|
conn,
|
|
target,
|
|
tz_name="Asia/Kolkata",
|
|
tz_offset=tz_offset,
|
|
bw=1_500_000_000,
|
|
br=500_000_000,
|
|
utc_start=utc_start.isoformat(),
|
|
utc_end=utc_end.isoformat(),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO local_day_unallocated_evidence "
|
|
"(start_sample_id, end_sample_id, start_local_date, end_local_date, "
|
|
" start_tz_name, end_tz_name, started_at, ended_at, bytes_written, "
|
|
" bytes_read, reason) VALUES (1, 2, ?, ?, ?, ?, ?, ?, ?, ?, 'local_midnight')",
|
|
(target, "2026-09-29", "Asia/Kolkata", "Asia/Kolkata",
|
|
utc_start.isoformat(), utc_start.isoformat(), 200_000_000, 300_000_000),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
app, patcher = _make_app(tmp_path, clock=clock)
|
|
try:
|
|
async with app.run_test(size=(70, 20)) as pilot:
|
|
await pilot.press("g")
|
|
await pilot.press(*target)
|
|
await pilot.press("enter")
|
|
await pilot.press("v")
|
|
await pilot.pause()
|
|
|
|
readout = str(app.query_one("#bar-readout").render())
|
|
assert target in readout
|
|
assert "Asia/Kolkata +05:30" in readout
|
|
assert "incomplete" in readout
|
|
assert "W 1.500 GB known · R 0.500 GB known" in readout
|
|
assert "shared at midnight W 0.200 GB · R 0.300 GB" in readout
|
|
finally:
|
|
patcher.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_date_picker_keystrokes_do_not_toggle_measurement(self, tmp_path):
|
|
"""Typing in date entry does not trigger dashboard shortcuts."""
|
|
conn, _ = _setup_store(tmp_path, n_days=14)
|
|
conn.close()
|
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.pause()
|
|
await pilot.press("g")
|
|
await pilot.press("w")
|
|
await pilot.press("escape")
|
|
await pilot.pause()
|
|
|
|
header = str(app.query_one("#live-header").render())
|
|
assert "Writes" in header
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_historical_hour_uses_recorded_timezone_and_utc_identity(self, tmp_path):
|
|
"""Repeated local clock labels retain distinct UTC-hour identities."""
|
|
clock = _clock(2026, 11, 2, 12)
|
|
conn, _ = _setup_store(tmp_path, n_days=30, today=clock)
|
|
target = "2026-11-01"
|
|
_insert_local_day(
|
|
conn,
|
|
target,
|
|
tz_name="America/New_York",
|
|
tz_offset="-04:00",
|
|
utc_start="2026-11-01T04:00:00+00:00",
|
|
utc_end="2026-11-02T05:00:00+00:00",
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO local_day_unallocated_evidence "
|
|
"(start_sample_id, end_sample_id, start_local_date, end_local_date, "
|
|
" start_tz_name, end_tz_name, started_at, ended_at, bytes_written, "
|
|
" bytes_read, reason) VALUES (1, 2, ?, ?, ?, ?, ?, ?, ?, ?, 'local_midnight')",
|
|
(target, "2026-11-02", "America/New_York", "America/New_York",
|
|
"2026-11-02T03:59:00+00:00", "2026-11-02T04:01:00+00:00",
|
|
2_000_000_000, 3_000_000_000),
|
|
)
|
|
for index in range(25):
|
|
hour = datetime(2026, 11, 1, 4, tzinfo=timezone.utc) + timedelta(hours=index)
|
|
conn.execute(
|
|
"INSERT INTO hour_observations "
|
|
"(hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
|
|
"VALUES (?, ?, ?, 1, 1)",
|
|
(hour.isoformat(), (index + 1) * 1_000_000_000,
|
|
(index + 1) * 2_000_000_000),
|
|
)
|
|
conn.execute(
|
|
"UPDATE hour_observations SET coverage = 0.5, unknown_seconds = 1800 "
|
|
"WHERE hour = ?",
|
|
("2026-11-01T04:00:00+00:00",),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
app, patcher = _make_app(tmp_path, clock=clock)
|
|
try:
|
|
async with app.run_test(size=(100, 40)) as pilot:
|
|
await pilot.click("#view-history")
|
|
await pilot.press("g")
|
|
await pilot.press(*target)
|
|
await pilot.press("enter")
|
|
await pilot.pause()
|
|
|
|
local_day = str(app.query_one("#local-day").render())
|
|
assert "America/New_York -04:00" in local_day
|
|
assert "incomplete" in local_day
|
|
assert "shared at midnight W 2.000 GB · R 3.000 GB" in local_day
|
|
|
|
# Move from final local hour to the second 01:00 occurrence.
|
|
await pilot.press(*(["left"] * 22))
|
|
second_occurrence = str(app.query_one("#bar-readout").render())
|
|
assert "01:00 -0500" in second_occurrence
|
|
assert "America/New_York -0500" in second_occurrence
|
|
assert "06:00 UTC" in second_occurrence
|
|
|
|
await pilot.press("left")
|
|
first_occurrence = str(app.query_one("#bar-readout").render())
|
|
assert "01:00 -0400" in first_occurrence
|
|
assert "America/New_York -0400" in first_occurrence
|
|
assert "05:00 UTC" in first_occurrence
|
|
|
|
await pilot.press("left")
|
|
incomplete_hour = str(app.query_one("#bar-readout").render())
|
|
assert "00:00 -0400" in incomplete_hour
|
|
assert "50% coverage · incomplete" in incomplete_hour
|
|
finally:
|
|
patcher.stop()
|