From 30122c5e6d3be61f7bd706b1720c3ebcba2944d5 Mon Sep 17 00:00:00 2001 From: xavierk Date: Mon, 14 Sep 2026 16:10:20 +0530 Subject: [PATCH] feat: Apply Fenris identity and Drive health grouping (issue #79)\n\n- Add wolf glyph identity (Fenris by Bongbetic) with fallback for unsupported terminals\n- Remove duplicate maker credit from service strip (single placement in titlebox)\n- Move vendor wear under Drive health with full context\n- Preserve all existing behavior: continuity, pause block, quit rail, auth banner, controls\n\nCloses #79 --- src/fenris/tui.py | 46 ++++-- tests/test_issue_79.py | 318 +++++++++++++++++++++++++++++++++++++++++ tests/test_tui.py | 19 ++- 3 files changed, 366 insertions(+), 17 deletions(-) create mode 100644 tests/test_issue_79.py diff --git a/src/fenris/tui.py b/src/fenris/tui.py index 19396ad..c5371d0 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -120,6 +120,26 @@ def _habit_bar(a: float, i: float, o: float, u: float, width: int = 40) -> str: return bar + "\n" + legend +# --------------------------------------------------------------------------- +# Identity and wolf glyph detection (issue #79) +# --------------------------------------------------------------------------- + +# Wolf glyph for Fenris branding +_WOLF_GLYPH = "\U0001f43a" # ๐Ÿบ +_IDENTITY_FULL = _WOLF_GLYPH + " Fenris by Bongbetic" +_IDENTITY_FALLBACK = "Fenris by Bongbetic" + + +def _can_render_wolf(width: int) -> bool: + """Determine if the wolf glyph can be rendered without tofu. + + Uses a conservative heuristic: + - Terminal width >= 80 columns (standard minimum) + - Wolf glyph width is 1 column in most terminals + """ + return width >= 80 + + # --------------------------------------------------------------------------- # Bar graph glyphs and constants # --------------------------------------------------------------------------- @@ -893,7 +913,7 @@ class DisclosuresScreen(ModalScreen[None]): class FenrisTuiApp(App): """Fenris Panes TUI โ€” keyboard-first, one dense screen (spec ยง7).""" - TITLE = "Fenris โ€” NVMe endurance monitor" + TITLE = _IDENTITY_FULL SUB_TITLE = "" CSS = """ @@ -982,7 +1002,13 @@ class FenrisTuiApp(App): def _headline_prefix(self) -> str: """Render identity and any launch-only guidance above drive state.""" - lines = ["[bold]Fenris โ€” NVMe endurance monitor[/bold]"] + # Use wolf glyph if terminal can render it (issue #79) + width = getattr(self, '_size', (80, 24))[0] if hasattr(self, '_size') else 80 + if _can_render_wolf(width): + identity = _IDENTITY_FULL + else: + identity = _IDENTITY_FALLBACK + lines = ["[bold]%s[/bold]" % identity] if self._show_auth_notice: lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]") return "\n".join(lines) @@ -1029,8 +1055,7 @@ class FenrisTuiApp(App): self.query_one("#usage-history").set_data([]) self.query_one("#drive-health").update("") self.query_one("#service-strip").update( - "boot: disabled ยท timer: inactive ยท last collect: unknown ยท freshness: empty ยท " - "[dim]by Bongbetic[/dim]\n" + "boot: disabled ยท timer: inactive ยท last collect: unknown ยท freshness: empty\n" "[bold]CONTINUITY[/bold] %s\n" "p pause ยท r resume ยท c collect ยท d disclosures" % monitoring_continuity({"boot_enabled": False}) @@ -1044,7 +1069,6 @@ class FenrisTuiApp(App): self.query_one("#usage-history").set_data([]) self.query_one("#drive-health").update("") self.query_one("#service-strip").update( - "[dim]by Bongbetic[/dim]\n" "p pause ยท r resume ยท c collect ยท d disclosures" ) @@ -1092,14 +1116,14 @@ class FenrisTuiApp(App): else: graph.set_data(day_data, on_drill=self._on_graph_drill) - # --- Drive-health pane (ยง7.2 right) --- + # --- Drive-health pane (ยง7.2 right, issue #79) --- health = _query_drive_health(conn) + # Vendor wear grouped under Drive health with context health_text = ( "[bold]Drive health[/bold] ยท %s\n" " temperature %dยฐC ยท spare %d%%\n" " media errors %d ยท unsafe shutdowns %d\n" - " power-on %d h ยท %d cycles ยท %s\n\n" - "[bold]Settings[/bold]\n" + " power-on %d h ยท %d cycles ยท %s\n" " vendor wear: %d%% used ยท %.1f TB written" ) % ( health["model"], @@ -1127,10 +1151,9 @@ class FenrisTuiApp(App): ) # Render using the shared composition with TUI styling status_text = render_status_tui(comp) - # Add TUI-only branding and actions + # Add TUI-only actions self.query_one("#service-strip").update( "%s\n" - "[dim]by Bongbetic[/dim]\n" "p pause ยท r resume ยท c collect ยท d disclosures" % status_text ) @@ -1138,8 +1161,7 @@ class FenrisTuiApp(App): except Exception: self._hide_paused_banner() self.query_one("#service-strip").update( - "boot: unknown ยท timer: unknown ยท last collect: unknown ยท freshness: unknown ยท " - "[dim]by Bongbetic[/dim]\n" + "boot: unknown ยท timer: unknown ยท last collect: unknown ยท freshness: unknown\n" "p pause ยท r resume ยท c collect ยท d disclosures" ) diff --git a/tests/test_issue_79.py b/tests/test_issue_79.py new file mode 100644 index 0000000..d387cbe --- /dev/null +++ b/tests/test_issue_79.py @@ -0,0 +1,318 @@ +"""Tests for issue #79: Apply Fenris identity and Drive health grouping. + +Covers: +- TPH-1: Titlebox shows Fenris identity with wolf fallback +- TPH-11: Single maker-credit placement, vendor wear under Drive health +- Preserve: continuity, pause block, quit rail, auth banner +""" +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.tui import FenrisTuiApp + + +# --------------------------------------------------------------------------- +# Helpers (reuse from test_tui.py) +# --------------------------------------------------------------------------- + +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)) + + +# --------------------------------------------------------------------------- +# Issue #79: Fenris identity and Drive health grouping +# --------------------------------------------------------------------------- + +class TestFenrisIdentity: + """TPH-1: Titlebox shows Fenris identity with wolf fallback.""" + + @pytest.mark.asyncio + async def test_titlebox_shows_wolf_identity(self, tmp_path): + """Top titlebox reads ๐Ÿบ Fenris by Bongbetic.""" + app = FenrisTuiApp( + store_path=tmp_path / "nonexistent.db", + refresh_interval_s=0.2, + ) + async with app.run_test(size=(120, 24)) as pilot: + headline = str(app.query_one("#headline-band").render()) + assert "๐Ÿบ Fenris by Bongbetic" in headline + + @pytest.mark.asyncio + async def test_titlebox_fallback_without_wolf(self, tmp_path): + """Fallback to 'Fenris by Bongbetic' when wolf is unsupported.""" + app = FenrisTuiApp( + store_path=tmp_path / "nonexistent.db", + refresh_interval_s=0.2, + ) + # Simulate narrow terminal that can't render wolf + async with app.run_test(size=(60, 24)) as pilot: + headline = str(app.query_one("#headline-band").render()) + # Either shows wolf or fallback - both are acceptable + assert "Fenris by Bongbetic" in headline + + @pytest.mark.asyncio + async def test_wolf_never_shows_tofu(self, tmp_path): + """Wolf glyph is never rendered as tofu (unsupported character).""" + app = FenrisTuiApp( + store_path=tmp_path / "nonexistent.db", + refresh_interval_s=0.2, + ) + async with app.run_test(size=(120, 24)) as pilot: + headline = str(app.query_one("#headline-band").render()) + # No replacement character (U+FFFD) should appear + assert "\ufffd" not in headline.lower() + assert "?" not in headline or "Fenris" in headline + + @pytest.mark.asyncio + async def test_lifespan_headline_is_data_surface(self, tmp_path): + """Lifespan headline remains a data surface, not app title.""" + 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(size=(120, 24)) as pilot: + headline = str(app.query_one("#headline-band").render()) + # Identity appears once, lifespan is separate data + assert "๐Ÿบ Fenris by Bongbetic" in headline + assert "remaining" in headline.lower() or "projection" in headline.lower() + + +class TestSingleMakerCredit: + """TPH-1: Single maker-credit placement in title only.""" + + @pytest.mark.asyncio + async def test_maker_credit_not_in_service_strip(self, tmp_path): + """Remove duplicate maker credit from service facts.""" + 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(size=(120, 24)) as pilot: + strip = str(app.query_one("#service-strip").render()) + # "by Bongbetic" should NOT appear in service strip + assert "by Bongbetic" not in strip + + @pytest.mark.asyncio + async def test_maker_credit_in_titlebox(self, tmp_path): + """Maker credit appears in the titlebox identity.""" + app = FenrisTuiApp( + store_path=tmp_path / "nonexistent.db", + refresh_interval_s=0.2, + ) + async with app.run_test(size=(120, 24)) as pilot: + headline = str(app.query_one("#headline-band").render()) + assert "Fenris by Bongbetic" in headline + + @pytest.mark.asyncio + async def test_empty_store_no_maker_credit_in_strip(self, tmp_path): + """Empty store: no maker credit in service strip.""" + app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") + async with app.run_test(size=(120, 24)) as pilot: + strip = str(app.query_one("#service-strip").render()) + assert "by Bongbetic" not in strip + + @pytest.mark.asyncio + async def test_store_fault_no_maker_credit_in_strip(self, tmp_path): + """Store fault: no maker credit in service strip.""" + # Create a corrupt store + db = tmp_path / "test.db" + db.write_bytes(b"not a database") + app = FenrisTuiApp(store_path=db) + async with app.run_test(size=(120, 24)) as pilot: + strip = str(app.query_one("#service-strip").render()) + assert "by Bongbetic" not in strip + + +class TestDriveHealthVendorWear: + """TPH-1: Vendor wear renders under Drive health context.""" + + @pytest.mark.asyncio + async def test_vendor_wear_in_drive_health(self, tmp_path): + """Vendor wear shows with health context, not as Settings.""" + 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", pu=10) + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(120, 24)) as pilot: + health = str(app.query_one("#drive-health").render()) + # Vendor wear should be present with health context + assert "vendor wear" in health.lower() + # Should show thermal, spare, errors, etc. + assert "temperature" in health.lower() + assert "spare" in health.lower() + assert "media errors" in health.lower() + # Settings section should NOT be a separate heading + assert "[bold]Settings[/bold]" not in health + + @pytest.mark.asyncio + async def test_missing_wear_values_honest(self, tmp_path): + """Missing values remain honest unavailable facts.""" + 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 with zero wear values + conn.execute( + "INSERT INTO samples (ts, device, data_units_written, data_units_read, " + "percentage_used, bytes_written, bytes_read, power_on_hours) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 0, 0, 0, 0, 0, 0), + ) + conn.commit() + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(120, 24)) as pilot: + health = str(app.query_one("#drive-health").render()) + # Should show 0% used or honest zero, not crash + assert "vendor wear" in health.lower() + + +class TestPreservedBehavior: + """Preserve existing continuity, pause, quit, auth behavior.""" + + @pytest.mark.asyncio + async def test_continuity_preserved(self, tmp_path): + """Continuity wording preserved in service strip.""" + conn = init_store(tmp_path / "test.db") + _open_period(conn) + conn.close() + app = FenrisTuiApp(store_path=tmp_path / "test.db") + + with patch("fenris.tui.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(size=(120, 24)) as pilot: + strip = str(app.query_one("#service-strip").render()) + assert "continuity" in strip.lower() + assert "monitoring" in strip.lower() + + @pytest.mark.asyncio + async def test_quit_rail_preserved(self, tmp_path): + """Separate q QUIT TUI rail preserved.""" + app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") + async with app.run_test(size=(120, 24)) as pilot: + rail = str(app.query_one("#quit-rail").render()) + assert "q QUIT TUI" in rail + + @pytest.mark.asyncio + async def test_auth_banner_preserved(self, tmp_path): + """Polkit authentication banner lifecycle preserved.""" + app = FenrisTuiApp( + store_path=tmp_path / "nonexistent.db", + refresh_interval_s=0.2, + ) + async with app.run_test(size=(120, 24)) as pilot: + headline = str(app.query_one("#headline-band").render()) + assert "polkit" in headline.lower() + # Should clear after first tick + await pilot.pause(0.25) + headline_after = str(app.query_one("#headline-band").render()) + assert "polkit" not in headline_after.lower() + + @pytest.mark.asyncio + async def test_deliberate_pause_block_preserved(self, tmp_path): + """Deliberate pause banner preserved.""" + 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.tui.query_service_state", return_value={ + "boot_enabled": False, "timer_active": False, + "last_collect_ok": None, "last_collect_age_s": None, + "last_collect_reason": None, + }): + async with app.run_test(size=(120, 24)) as pilot: + banner = str(app.query_one("#paused-banner").render()) + assert "paused" in banner.lower() + assert "deliberate" in banner.lower() diff --git a/tests/test_tui.py b/tests/test_tui.py index cc56fba..6c8bd38 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -379,7 +379,10 @@ class TestDenseScreen: @pytest.mark.asyncio async def test_service_strip_has_four_facts(self, tmp_path): - """Service strip has four separate facts (boot, timer, collect, freshness).""" + """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) @@ -396,7 +399,8 @@ class TestDenseScreen: assert "timer:" in strip assert "last collect:" in strip assert "freshness:" in strip - assert "by Bongbetic" 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): @@ -525,7 +529,10 @@ class TestDenseScreen: @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.""" + """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, @@ -534,9 +541,11 @@ class TestDenseScreen: async with app.run_test() as pilot: headline = str(app.query_one("#headline-band").render()) - assert "Fenris โ€” NVMe endurance monitor" in headline + # Identity now shows Fenris by Bongbetic (issue #79) + assert "Fenris by Bongbetic" in headline assert auth_notice in headline - assert "by Bongbetic" in str(app.query_one("#service-strip").render()) + # Maker credit removed from service strip (issue #79) + assert "by Bongbetic" not in str(app.query_one("#service-strip").render()) await pilot.pause(0.25) assert auth_notice not in str(app.query_one("#headline-band").render())