initial: import DOLPHIN baseline 2026-04-21 from dolphinng5_predict working tree
Includes core prod + GREEN/BLUE subsystems: - prod/ (BLUE harness, configs, scripts, docs) - nautilus_dolphin/ (GREEN Nautilus-native impl + dvae/ preserved) - adaptive_exit/ (AEM engine + models/bucket_assignments.pkl) - Observability/ (EsoF advisor, TUI, dashboards) - external_factors/ (EsoF producer) - mc_forewarning_qlabs_fork/ (MC regime/envelope) Excludes runtime caches, logs, backups, and reproducible artifacts per .gitignore.
This commit is contained in:
272
Observability/TUI/test_dolphin_tui_malformed_json.py
Executable file
272
Observability/TUI/test_dolphin_tui_malformed_json.py
Executable file
@@ -0,0 +1,272 @@
|
||||
# Tests for graceful handling of malformed JSON in HZ values.
|
||||
# Validates: Requirements 12.3
|
||||
# The TUI MUST NOT crash when any individual HZ key contains malformed JSON.
|
||||
# Malformed JSON MUST result in all fields being None (no crash, no exception).
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure the TUI module is importable without textual/hazelcast/httpx installed
|
||||
# ---------------------------------------------------------------------------
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
for _mod in ("textual", "textual.app", "textual.widgets", "textual.containers", "httpx", "hazelcast"):
|
||||
if _mod not in sys.modules:
|
||||
sys.modules[_mod] = types.ModuleType(_mod)
|
||||
|
||||
import textual.app as _textual_app
|
||||
import textual.widgets as _textual_widgets
|
||||
import textual.containers as _textual_containers
|
||||
|
||||
_textual_app.App = object
|
||||
_textual_app.ComposeResult = object
|
||||
_textual_widgets.Static = object
|
||||
_textual_widgets.VerticalScroll = object
|
||||
_textual_containers.Horizontal = object
|
||||
|
||||
from dolphin_tui import (
|
||||
color_age,
|
||||
fmt_float,
|
||||
fmt_pnl,
|
||||
DataSnapshot,
|
||||
DolphinDataFetcher,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Malformed JSON inputs to test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MALFORMED_JSON_INPUTS = [
|
||||
"{bad json",
|
||||
"not-json",
|
||||
"null",
|
||||
"[]",
|
||||
"123",
|
||||
"",
|
||||
"{",
|
||||
"}",
|
||||
"{'key': 'value'}", # single quotes — invalid JSON
|
||||
"undefined",
|
||||
"NaN",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_fetcher():
|
||||
return DolphinDataFetcher()
|
||||
|
||||
|
||||
def _make_mock_map_returning(value):
|
||||
"""Return a mock IMap whose .get(key).result() returns the given value."""
|
||||
future = MagicMock()
|
||||
future.result.return_value = value
|
||||
hz_map = MagicMock()
|
||||
hz_map.get.return_value = future
|
||||
hz_map.key_set.return_value = future
|
||||
return hz_map
|
||||
|
||||
|
||||
def _make_fetcher_with_malformed_json(malformed: str):
|
||||
"""Create a DolphinDataFetcher whose hz_client returns malformed JSON for every map key."""
|
||||
fetcher = DolphinDataFetcher()
|
||||
fetcher.hz_connected = True
|
||||
|
||||
mock_map = _make_mock_map_returning(malformed)
|
||||
map_future = MagicMock()
|
||||
map_future.result.return_value = mock_map
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_map.return_value = map_future
|
||||
fetcher.hz_client = mock_client
|
||||
return fetcher
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _fetch_with_malformed_json(malformed: str):
|
||||
fetcher = _make_fetcher_with_malformed_json(malformed)
|
||||
with patch.object(fetcher, "fetch_prefect", new=AsyncMock(return_value=(False, []))):
|
||||
with patch.object(fetcher, "tail_log", return_value=[]):
|
||||
with patch.object(fetcher, "_start_reconnect", return_value=None):
|
||||
return _run(fetcher.fetch())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_scan: malformed JSON -> all None, no exception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("bad_json", MALFORMED_JSON_INPUTS)
|
||||
def test_parse_scan_malformed_no_crash(bad_json):
|
||||
"""_parse_scan must not raise on malformed JSON."""
|
||||
fetcher = _make_fetcher()
|
||||
result = fetcher._parse_scan(bad_json) # must not raise
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_json", MALFORMED_JSON_INPUTS)
|
||||
def test_parse_scan_malformed_returns_none_fields(bad_json):
|
||||
"""_parse_scan must return all-None fields for malformed JSON."""
|
||||
fetcher = _make_fetcher()
|
||||
result = fetcher._parse_scan(bad_json)
|
||||
for key in ("scan_number", "vel_div", "w50_velocity", "w750_velocity",
|
||||
"instability_50", "scan_bridge_ts", "scan_age_s"):
|
||||
assert result[key] is None, f"_parse_scan({bad_json!r})[{key!r}] should be None"
|
||||
assert result["asset_prices"] == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_safety: malformed JSON -> all None, no exception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("bad_json", MALFORMED_JSON_INPUTS)
|
||||
def test_parse_safety_malformed_no_crash(bad_json):
|
||||
"""_parse_safety must not raise on malformed JSON."""
|
||||
fetcher = _make_fetcher()
|
||||
result = fetcher._parse_safety(bad_json) # must not raise
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_json", MALFORMED_JSON_INPUTS)
|
||||
def test_parse_safety_malformed_returns_none_fields(bad_json):
|
||||
"""_parse_safety must return all-None fields for malformed JSON."""
|
||||
fetcher = _make_fetcher()
|
||||
result = fetcher._parse_safety(bad_json)
|
||||
for key in ("posture", "rm", "cat1", "cat2", "cat3", "cat4", "cat5"):
|
||||
assert result[key] is None, f"_parse_safety({bad_json!r})[{key!r}] should be None"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_heartbeat: malformed JSON -> all None, no exception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("bad_json", MALFORMED_JSON_INPUTS)
|
||||
def test_parse_heartbeat_malformed_no_crash(bad_json):
|
||||
"""_parse_heartbeat must not raise on malformed JSON."""
|
||||
fetcher = _make_fetcher()
|
||||
result = fetcher._parse_heartbeat(bad_json) # must not raise
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_json", MALFORMED_JSON_INPUTS)
|
||||
def test_parse_heartbeat_malformed_returns_none_fields(bad_json):
|
||||
"""_parse_heartbeat must return all-None fields for malformed JSON."""
|
||||
fetcher = _make_fetcher()
|
||||
result = fetcher._parse_heartbeat(bad_json)
|
||||
for key in ("heartbeat_ts", "heartbeat_phase", "heartbeat_flow", "heartbeat_age_s"):
|
||||
assert result[key] is None, f"_parse_heartbeat({bad_json!r})[{key!r}] should be None"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch() with malformed JSON from HZ -> DataSnapshot, no crash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("bad_json", ["{bad json", "not-json", "null", "[]", "123", ""])
|
||||
def test_fetch_malformed_json_returns_datasnapshot(bad_json):
|
||||
"""fetch() must return a DataSnapshot even when HZ returns malformed JSON."""
|
||||
snap = _fetch_with_malformed_json(bad_json)
|
||||
assert isinstance(snap, DataSnapshot)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_json", ["{bad json", "not-json", "null", "[]", "123", ""])
|
||||
def test_fetch_malformed_json_scan_fields_none(bad_json):
|
||||
"""fetch() with malformed JSON must produce None scan fields."""
|
||||
snap = _fetch_with_malformed_json(bad_json)
|
||||
assert snap.scan_number is None
|
||||
assert snap.vel_div is None
|
||||
assert snap.w50_velocity is None
|
||||
assert snap.instability_50 is None
|
||||
assert snap.scan_bridge_ts is None
|
||||
assert snap.scan_age_s is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_json", ["{bad json", "not-json", "null", "[]", "123", ""])
|
||||
def test_fetch_malformed_json_safety_fields_none(bad_json):
|
||||
"""fetch() with malformed JSON must produce None safety fields."""
|
||||
snap = _fetch_with_malformed_json(bad_json)
|
||||
assert snap.posture is None
|
||||
assert snap.rm is None
|
||||
assert snap.cat1 is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_json", ["{bad json", "not-json", "null", "[]", "123", ""])
|
||||
def test_fetch_malformed_json_heartbeat_fields_none(bad_json):
|
||||
"""fetch() with malformed JSON must produce None heartbeat fields."""
|
||||
snap = _fetch_with_malformed_json(bad_json)
|
||||
assert snap.heartbeat_ts is None
|
||||
assert snap.heartbeat_phase is None
|
||||
assert snap.heartbeat_age_s is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_json", ["{bad json", "not-json", "null", "[]", "123", ""])
|
||||
def test_fetch_malformed_json_no_crash(bad_json):
|
||||
"""fetch() must not raise any exception when HZ returns malformed JSON."""
|
||||
# If this doesn't raise, the test passes
|
||||
snap = _fetch_with_malformed_json(bad_json)
|
||||
assert snap is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fmt_float, fmt_pnl, color_age handle None gracefully (parse errors -> None fields)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_fmt_float_handles_none_from_parse_error():
|
||||
"""fmt_float(None) must return '--' — parse errors produce None fields."""
|
||||
assert fmt_float(None) == "--"
|
||||
|
||||
|
||||
def test_fmt_pnl_handles_none_from_parse_error():
|
||||
"""fmt_pnl(None) must return ('white', '--') — parse errors produce None fields."""
|
||||
color, text = fmt_pnl(None)
|
||||
assert text == "--"
|
||||
assert color == "white"
|
||||
|
||||
|
||||
def test_color_age_handles_none_from_parse_error():
|
||||
"""color_age(None) must return ('dim', 'N/A') — parse errors produce None fields."""
|
||||
color, text = color_age(None)
|
||||
assert text == "N/A"
|
||||
assert color == "dim"
|
||||
|
||||
|
||||
def test_all_none_snapshot_fmt_float_no_crash():
|
||||
"""All float fields on an all-None DataSnapshot must format without crashing."""
|
||||
snap = DataSnapshot()
|
||||
for field_name in (
|
||||
"vel_div", "w50_velocity", "w750_velocity", "instability_50",
|
||||
"acb_boost", "acb_beta", "funding_btc", "dvol_btc", "fng",
|
||||
"vix", "capital", "pnl", "rm",
|
||||
):
|
||||
val = getattr(snap, field_name)
|
||||
result = fmt_float(val)
|
||||
assert result == "--", f"fmt_float({field_name}=None) should be '--', got {result!r}"
|
||||
|
||||
|
||||
def test_all_none_snapshot_fmt_pnl_no_crash():
|
||||
"""PnL fields on an all-None DataSnapshot must format without crashing."""
|
||||
snap = DataSnapshot()
|
||||
for field_name in ("pnl", "nautilus_pnl"):
|
||||
val = getattr(snap, field_name)
|
||||
color, text = fmt_pnl(val)
|
||||
assert text == "--"
|
||||
assert color == "white"
|
||||
|
||||
|
||||
def test_all_none_snapshot_color_age_no_crash():
|
||||
"""Age fields on an all-None DataSnapshot must format without crashing."""
|
||||
snap = DataSnapshot()
|
||||
for field_name in ("scan_age_s", "exf_age_s", "esof_age_s", "heartbeat_age_s"):
|
||||
val = getattr(snap, field_name)
|
||||
color, text = color_age(val)
|
||||
assert text == "N/A"
|
||||
assert color == "dim"
|
||||
Reference in New Issue
Block a user