""" test_dolphin_tui_prefect_offline.py Verifies Prefect-offline behavior of DolphinDataFetcher and PrefectPanel. Tests: - test_fetch_prefect_returns_false_on_connection_error - test_fetch_prefect_returns_false_on_timeout - test_fetch_prefect_returns_false_on_non_200 - test_fetch_prefect_does_not_crash - test_snapshot_prefect_offline_fields - test_prefect_panel_shows_offline_text All tests are self-contained and do NOT require a live Prefect instance. """ from __future__ import annotations import asyncio import sys import os import unittest from unittest.mock import AsyncMock, MagicMock, patch import types # --------------------------------------------------------------------------- # Make sure the TUI module is importable from this directory # --------------------------------------------------------------------------- sys.path.insert(0, os.path.dirname(__file__)) # --------------------------------------------------------------------------- # Stub hazelcast so the import succeeds without the package # --------------------------------------------------------------------------- _hz_stub = types.ModuleType("hazelcast") _hz_stub.HazelcastClient = MagicMock() sys.modules.setdefault("hazelcast", _hz_stub) # --------------------------------------------------------------------------- # Stub textual so dolphin_tui imports cleanly without a terminal # --------------------------------------------------------------------------- for _mod in [ "textual", "textual.app", "textual.containers", "textual.widgets", ]: if _mod not in sys.modules: sys.modules[_mod] = types.ModuleType(_mod) _textual_app = sys.modules["textual.app"] _textual_app.App = object _textual_app.ComposeResult = object _textual_containers = sys.modules["textual.containers"] _textual_containers.Horizontal = object _textual_widgets = sys.modules["textual.widgets"] _textual_widgets.Static = object _textual_widgets.VerticalScroll = object # --------------------------------------------------------------------------- # Stub httpx — we will patch individual methods per test # --------------------------------------------------------------------------- if "httpx" not in sys.modules: _httpx_stub = types.ModuleType("httpx") _httpx_stub.AsyncClient = MagicMock() _httpx_stub.ConnectError = type("ConnectError", (Exception,), {}) _httpx_stub.TimeoutException = type("TimeoutException", (Exception,), {}) sys.modules["httpx"] = _httpx_stub # --------------------------------------------------------------------------- # Now import the module under test # --------------------------------------------------------------------------- from dolphin_tui import ( # noqa: E402 DolphinDataFetcher, DataSnapshot, PrefectPanel, ) import httpx # noqa: E402 (the stub or real module) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _run(coro): """Run a coroutine in the current event loop.""" return asyncio.get_event_loop().run_until_complete(coro) def _make_fetcher() -> DolphinDataFetcher: fetcher = DolphinDataFetcher(hz_host="localhost", hz_port=5701) fetcher._start_reconnect = MagicMock() # prevent background reconnect tasks return fetcher # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestFetchPrefectConnectionError(unittest.TestCase): """test_fetch_prefect_returns_false_on_connection_error When httpx raises ConnectError, fetch_prefect() must return (False, []). """ def test_fetch_prefect_returns_false_on_connection_error(self): fetcher = _make_fetcher() mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) with patch.object(httpx, "AsyncClient", return_value=mock_client): result = _run(fetcher.fetch_prefect()) self.assertEqual(result, (False, [])) class TestFetchPrefectTimeout(unittest.TestCase): """test_fetch_prefect_returns_false_on_timeout When httpx raises TimeoutException, fetch_prefect() must return (False, []). """ def test_fetch_prefect_returns_false_on_timeout(self): fetcher = _make_fetcher() mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timed out")) with patch.object(httpx, "AsyncClient", return_value=mock_client): result = _run(fetcher.fetch_prefect()) self.assertEqual(result, (False, [])) class TestFetchPrefectNon200(unittest.TestCase): """test_fetch_prefect_returns_false_on_non_200 When /api/health returns HTTP 503, fetch_prefect() must return (False, []). """ def test_fetch_prefect_returns_false_on_non_200(self): fetcher = _make_fetcher() health_resp = MagicMock() health_resp.status_code = 503 mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(return_value=health_resp) with patch.object(httpx, "AsyncClient", return_value=mock_client): healthy, flows = _run(fetcher.fetch_prefect()) self.assertFalse(healthy, "healthy must be False when /api/health returns 503") # flows may be empty or populated depending on whether the flows call was made; # the key requirement is that healthy is False self.assertIsInstance(flows, list) class TestFetchPrefectDoesNotCrash(unittest.TestCase): """test_fetch_prefect_does_not_crash fetch_prefect() must never raise, even on unexpected exceptions. """ def test_fetch_prefect_does_not_crash_on_unexpected_exception(self): fetcher = _make_fetcher() # Simulate AsyncClient itself raising an unexpected error with patch.object(httpx, "AsyncClient", side_effect=RuntimeError("unexpected")): try: result = _run(fetcher.fetch_prefect()) except Exception as exc: self.fail(f"fetch_prefect() raised unexpectedly: {exc}") self.assertEqual(result, (False, [])) def test_fetch_prefect_does_not_crash_on_os_error(self): fetcher = _make_fetcher() mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(side_effect=OSError("network unreachable")) with patch.object(httpx, "AsyncClient", return_value=mock_client): try: result = _run(fetcher.fetch_prefect()) except Exception as exc: self.fail(f"fetch_prefect() raised unexpectedly: {exc}") self.assertEqual(result, (False, [])) class TestSnapshotPrefectOfflineFields(unittest.TestCase): """test_snapshot_prefect_offline_fields When fetch_prefect() returns (False, []), the assembled DataSnapshot must have prefect_healthy=False and prefect_flows=[]. """ def test_snapshot_prefect_offline_fields(self): snap = DataSnapshot( prefect_healthy=False, prefect_flows=[], ) self.assertFalse(snap.prefect_healthy, "prefect_healthy must be False") self.assertEqual(snap.prefect_flows, [], "prefect_flows must be empty list") def test_snapshot_default_is_offline(self): """Default DataSnapshot should represent offline state.""" snap = DataSnapshot() self.assertFalse(snap.prefect_healthy) self.assertEqual(snap.prefect_flows, []) class TestPrefectPanelShowsOfflineText(unittest.TestCase): """test_prefect_panel_shows_offline_text When DataSnapshot.prefect_healthy=False, PrefectPanel._render_markup() must contain the string "PREFECT OFFLINE". """ def test_prefect_panel_shows_offline_text_when_unhealthy(self): panel = PrefectPanel() snap = DataSnapshot(prefect_healthy=False, prefect_flows=[]) panel._snap = snap markup = panel._render_markup() self.assertIn( "PREFECT OFFLINE", markup, f"Expected 'PREFECT OFFLINE' in panel markup, got:\n{markup}", ) def test_prefect_panel_shows_offline_text_when_snap_is_none(self): """Panel must show PREFECT OFFLINE when no snapshot has been set.""" panel = PrefectPanel() # _snap defaults to None markup = panel._render_markup() self.assertIn( "PREFECT OFFLINE", markup, f"Expected 'PREFECT OFFLINE' when snap is None, got:\n{markup}", ) def test_prefect_panel_does_not_show_offline_when_healthy(self): """Sanity check: healthy snapshot should NOT show PREFECT OFFLINE.""" panel = PrefectPanel() snap = DataSnapshot(prefect_healthy=True, prefect_flows=[]) panel._snap = snap markup = panel._render_markup() self.assertNotIn( "PREFECT OFFLINE", markup, f"'PREFECT OFFLINE' should not appear when prefect_healthy=True", ) self.assertIn("PREFECT ✓", markup) def test_update_data_does_not_crash_when_offline(self): """update_data() must not raise when called with an offline snapshot.""" panel = PrefectPanel() # Patch the inherited update() method (from Static/object) so it's a no-op panel.update = MagicMock() snap = DataSnapshot(prefect_healthy=False, prefect_flows=[]) try: panel.update_data(snap) except Exception as exc: self.fail(f"update_data() raised unexpectedly: {exc}") panel.update.assert_called_once() if __name__ == "__main__": unittest.main()