feat(run): screenshot attach, Telegram ok:false fix, post-FIRE catchup guard
Three bundled fixes on the dispatch + FSM + notifier triangle:
1. Telegram silent-success bug: parse JSON body after 200 OK, raise on
ok:false so FanoutNotifier retries + DLQs + stats surface the failure.
Previously Discord succeeded while Telegram silently dropped.
2. Per-kind screenshot attach: new AlertsCfg dataclass with per-kind toggle
(late_start, catchup, arm, prime, trigger). _save_annotated_frame helper
extracted from inline FIRE block, threaded via Snapshot closure into
_handle_tick. Failures audit-logged, never silent.
3. Post-FIRE catchup regression (d7305fb): residual dark_green/dark_red dots
after a FIRE cycle look like startup-catchup from (color, state) alone.
New fsm.fired_in_session(direction) gate suppresses synth-arm after a
cycle already fired in that direction. Opposite direction unaffected.
Also: queue-overflow on_drop audit callback, periodic + shutdown heartbeat
stats per-backend, config back-compat (bool or dict for attach_screenshots).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
99
tests/test_config.py
Normal file
99
tests/test_config.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Tests for atm.config — focused on attach_screenshots parsing (legacy bool vs new dict)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from atm.config import AlertsCfg, Config
|
||||
|
||||
|
||||
_BASE = {
|
||||
"window_title": "X",
|
||||
"dot_roi": {"x": 0, "y": 0, "w": 10, "h": 10},
|
||||
"chart_roi": {"x": 0, "y": 0, "w": 100, "h": 100},
|
||||
"colors": {
|
||||
"turquoise": {"rgb": [64, 224, 208], "tolerance": 30.0},
|
||||
"yellow": {"rgb": [255, 215, 0], "tolerance": 30.0},
|
||||
"dark_green": {"rgb": [0, 100, 0], "tolerance": 30.0},
|
||||
"dark_red": {"rgb": [139, 0, 0], "tolerance": 30.0},
|
||||
"light_green": {"rgb": [0, 230, 118], "tolerance": 30.0},
|
||||
"light_red": {"rgb": [255, 82, 82], "tolerance": 30.0},
|
||||
"gray": {"rgb": [128, 128, 128], "tolerance": 25.0},
|
||||
},
|
||||
"y_axis": {"p1_y": 100, "p1_price": 485.0, "p2_y": 200, "p2_price": 484.0},
|
||||
"canary": {
|
||||
"roi": {"x": 0, "y": 0, "w": 10, "h": 10},
|
||||
"baseline_phash": "0" * 16,
|
||||
"drift_threshold": 8,
|
||||
},
|
||||
"discord": {"webhook_url": "https://example.com/hook"},
|
||||
"telegram": {"bot_token": "tok", "chat_id": "123"},
|
||||
}
|
||||
|
||||
|
||||
def _with_opts(opts: dict) -> dict:
|
||||
d = {k: v for k, v in _BASE.items()}
|
||||
d["options"] = opts
|
||||
return d
|
||||
|
||||
|
||||
def test_attach_screenshots_default_all_true() -> None:
|
||||
"""Missing attach_screenshots → all fields True."""
|
||||
cfg = Config._from_dict(_with_opts({}))
|
||||
assert cfg.attach_screenshots == AlertsCfg(
|
||||
late_start=True, catchup=True, arm=True, prime=True, trigger=True,
|
||||
)
|
||||
|
||||
|
||||
def test_attach_screenshots_legacy_bool_true() -> None:
|
||||
"""Legacy: attach_screenshots = true → all fields True."""
|
||||
cfg = Config._from_dict(_with_opts({"attach_screenshots": True}))
|
||||
assert cfg.attach_screenshots.arm is True
|
||||
assert cfg.attach_screenshots.catchup is True
|
||||
assert cfg.attach_screenshots.trigger is True
|
||||
|
||||
|
||||
def test_attach_screenshots_legacy_bool_false() -> None:
|
||||
"""Legacy: attach_screenshots = false → all fields False."""
|
||||
cfg = Config._from_dict(_with_opts({"attach_screenshots": False}))
|
||||
assert cfg.attach_screenshots.arm is False
|
||||
assert cfg.attach_screenshots.catchup is False
|
||||
assert cfg.attach_screenshots.trigger is False
|
||||
assert cfg.attach_screenshots.late_start is False
|
||||
|
||||
|
||||
def test_attach_screenshots_partial_dict() -> None:
|
||||
"""Dict form with only some keys → specified False, others default True."""
|
||||
cfg = Config._from_dict(_with_opts({
|
||||
"attach_screenshots": {"arm": False, "prime": False},
|
||||
}))
|
||||
assert cfg.attach_screenshots.arm is False
|
||||
assert cfg.attach_screenshots.prime is False
|
||||
# Unspecified → dataclass default True
|
||||
assert cfg.attach_screenshots.trigger is True
|
||||
assert cfg.attach_screenshots.catchup is True
|
||||
assert cfg.attach_screenshots.late_start is True
|
||||
|
||||
|
||||
def test_attach_screenshots_full_dict() -> None:
|
||||
"""Dict form with all keys specified."""
|
||||
cfg = Config._from_dict(_with_opts({
|
||||
"attach_screenshots": {
|
||||
"late_start": False,
|
||||
"catchup": True,
|
||||
"arm": False,
|
||||
"prime": True,
|
||||
"trigger": True,
|
||||
},
|
||||
}))
|
||||
assert cfg.attach_screenshots.late_start is False
|
||||
assert cfg.attach_screenshots.catchup is True
|
||||
assert cfg.attach_screenshots.arm is False
|
||||
assert cfg.attach_screenshots.prime is True
|
||||
assert cfg.attach_screenshots.trigger is True
|
||||
|
||||
|
||||
def test_attach_screenshots_unknown_keys_ignored() -> None:
|
||||
"""Unknown keys are silently dropped (dataclass won't accept them)."""
|
||||
cfg = Config._from_dict(_with_opts({
|
||||
"attach_screenshots": {"arm": False, "nonexistent_knob": True},
|
||||
}))
|
||||
assert cfg.attach_screenshots.arm is False
|
||||
# Should not raise even with unknown key
|
||||
@@ -256,3 +256,234 @@ def test_handle_tick_catchup_dark_green_when_not_first_accepted():
|
||||
assert len(notif.alerts) == 2
|
||||
assert notif.alerts[0].direction == "BUY"
|
||||
assert notif.alerts[1].direction == "BUY"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: user bug 2026-04-16 (image.png). After a BUY FIRE, the chart
|
||||
# shows residual dark_green dots for the rest of the 15m window. Those are
|
||||
# noise, NOT a new prime signal. Previously triggered false catchup arm+prime
|
||||
# because FSM returns to IDLE after fire and the catchup branch only checked
|
||||
# (color, state). Now gated on fsm.fired_in_session(direction).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_handle_tick_dark_green_after_light_green_fire_no_catchup():
|
||||
"""REGRESSION: post-FIRE dark_green must NOT re-arm catchup."""
|
||||
fsm = StateMachine(lockout_s=240)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
|
||||
# Full BUY cycle: arm → prime → fire
|
||||
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
|
||||
_handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False)
|
||||
_handle_tick(fsm, "light_green", 3.0, notif, audit, first_accepted=False)
|
||||
# After fire: FSM is IDLE, _last_fire[BUY]=3.0
|
||||
assert fsm.state == State.IDLE
|
||||
assert fsm.fired_in_session("BUY") is True
|
||||
baseline_alerts = len(notif.alerts) # arm + prime (fire alert is handled in main, not here)
|
||||
|
||||
# Residual dark_green post-FIRE — must stay IDLE, must not fire any alert
|
||||
tr = _handle_tick(fsm, "dark_green", 10.0, notif, audit, first_accepted=False)
|
||||
|
||||
assert fsm.state == State.IDLE
|
||||
assert tr is not None
|
||||
assert tr.reason == "noise"
|
||||
assert len(notif.alerts) == baseline_alerts, (
|
||||
f"post-FIRE dark_green falsely fired: "
|
||||
f"new alerts {notif.alerts[baseline_alerts:]}"
|
||||
)
|
||||
|
||||
|
||||
def test_handle_tick_dark_red_after_light_red_fire_no_catchup():
|
||||
"""REGRESSION mirror — SELL side."""
|
||||
fsm = StateMachine(lockout_s=240)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
|
||||
_handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False)
|
||||
_handle_tick(fsm, "dark_red", 2.0, notif, audit, first_accepted=False)
|
||||
_handle_tick(fsm, "light_red", 3.0, notif, audit, first_accepted=False)
|
||||
assert fsm.state == State.IDLE
|
||||
assert fsm.fired_in_session("SELL") is True
|
||||
baseline_alerts = len(notif.alerts)
|
||||
|
||||
tr = _handle_tick(fsm, "dark_red", 10.0, notif, audit, first_accepted=False)
|
||||
|
||||
assert fsm.state == State.IDLE
|
||||
assert tr is not None
|
||||
assert tr.reason == "noise"
|
||||
assert len(notif.alerts) == baseline_alerts
|
||||
|
||||
|
||||
def test_handle_tick_opposite_direction_catchup_after_fire():
|
||||
"""After BUY fires, seeing dark_red at IDLE should STILL trigger SELL
|
||||
catchup (direction-scoped gate, not session-wide). Proves the gate only
|
||||
suppresses same-direction residual, not a genuine opposite-direction cycle
|
||||
the user joined mid-way."""
|
||||
fsm = StateMachine(lockout_s=240)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
|
||||
# Fire BUY cycle
|
||||
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
|
||||
_handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False)
|
||||
_handle_tick(fsm, "light_green", 3.0, notif, audit, first_accepted=False)
|
||||
assert fsm.fired_in_session("BUY") is True
|
||||
assert fsm.fired_in_session("SELL") is False
|
||||
baseline_alerts = len(notif.alerts)
|
||||
|
||||
# Now dark_red at IDLE — SELL hasn't fired, so catchup must still work
|
||||
tr = _handle_tick(fsm, "dark_red", 10.0, notif, audit, first_accepted=False)
|
||||
|
||||
assert tr is not None
|
||||
assert tr.next == State.PRIMED_SELL
|
||||
assert tr.reason == "prime"
|
||||
# synth-arm alert + real prime alert = 2 new
|
||||
assert len(notif.alerts) == baseline_alerts + 2
|
||||
assert notif.alerts[baseline_alerts].kind == "arm"
|
||||
assert notif.alerts[baseline_alerts].direction == "SELL"
|
||||
assert "catchup" in (notif.alerts[baseline_alerts].title + notif.alerts[baseline_alerts].body).lower()
|
||||
assert notif.alerts[baseline_alerts + 1].kind == "prime"
|
||||
assert notif.alerts[baseline_alerts + 1].direction == "SELL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot callable: _handle_tick should call snapshot(kind, label) for each
|
||||
# alert and attach the returned path to Alert.image_path. None default keeps
|
||||
# existing tests oblivious.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_handle_tick_snapshot_called_for_each_alert():
|
||||
"""snapshot callable invoked with (kind, label); returned path attached."""
|
||||
from pathlib import Path
|
||||
|
||||
fsm = StateMachine(lockout_s=60)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def snap(kind: str, label: str):
|
||||
calls.append((kind, label))
|
||||
return Path(f"/tmp/fake_{label}.png")
|
||||
|
||||
# BUY cycle arm + prime (2 alerts, 2 snapshot calls)
|
||||
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False, snapshot=snap)
|
||||
_handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False, snapshot=snap)
|
||||
|
||||
assert len(notif.alerts) == 2
|
||||
assert calls == [("arm", "arm_buy"), ("prime", "prime_buy")]
|
||||
assert notif.alerts[0].image_path == Path("/tmp/fake_arm_buy.png")
|
||||
assert notif.alerts[1].image_path == Path("/tmp/fake_prime_buy.png")
|
||||
|
||||
|
||||
def test_handle_tick_snapshot_none_for_gated_kind():
|
||||
"""snapshot() returning None (config-gated off) → Alert.image_path=None,
|
||||
alert still sends text-only."""
|
||||
fsm = StateMachine(lockout_s=60)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
|
||||
def snap(kind: str, label: str):
|
||||
# Simulate cfg.attach_screenshots.arm = False
|
||||
return None if kind == "arm" else __import__("pathlib").Path(f"/tmp/{label}.png")
|
||||
|
||||
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False, snapshot=snap)
|
||||
_handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False, snapshot=snap)
|
||||
|
||||
assert notif.alerts[0].image_path is None # arm gated off
|
||||
assert notif.alerts[1].image_path is not None # prime went through
|
||||
|
||||
|
||||
def test_handle_tick_snapshot_called_for_late_start():
|
||||
"""late_start path also invokes snapshot."""
|
||||
fsm = StateMachine(lockout_s=60)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def snap(kind: str, label: str):
|
||||
calls.append((kind, label))
|
||||
return None
|
||||
|
||||
_handle_tick(fsm, "light_green", 1.0, notif, audit, first_accepted=True, snapshot=snap)
|
||||
|
||||
assert len(notif.alerts) == 1
|
||||
assert notif.alerts[0].kind == "late_start"
|
||||
assert calls == [("late_start", "late_start_buy")]
|
||||
|
||||
|
||||
def test_handle_tick_snapshot_called_for_catchup_prime():
|
||||
"""catchup path: arm snapshot uses kind=catchup, prime snapshot uses
|
||||
kind=catchup (so user's catchup toggle also gates the catchup prime)."""
|
||||
fsm = StateMachine(lockout_s=60)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def snap(kind: str, label: str):
|
||||
calls.append((kind, label))
|
||||
return None
|
||||
|
||||
_handle_tick(fsm, "dark_red", 1.0, notif, audit, first_accepted=False, snapshot=snap)
|
||||
|
||||
# Synth-arm catchup alert + real prime alert (post-synth) — both tagged catchup
|
||||
assert len(notif.alerts) == 2
|
||||
assert calls == [("catchup", "catchup_arm_sell"), ("catchup", "prime_sell_catchup")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _save_annotated_frame — audit-log failures instead of swallowing silently.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_save_annotated_frame_logs_audit_on_failure(tmp_path, monkeypatch):
|
||||
"""cv2.imwrite raising → return None AND audit.log event=snapshot_fail."""
|
||||
import atm.main as main_mod
|
||||
|
||||
# Force the lazy cv2 import to succeed but fail on imwrite
|
||||
class _FakeCv2:
|
||||
@staticmethod
|
||||
def rectangle(*a, **kw): pass
|
||||
@staticmethod
|
||||
def imwrite(*a, **kw):
|
||||
raise OSError("disk full")
|
||||
|
||||
import sys
|
||||
monkeypatch.setitem(sys.modules, "cv2", _FakeCv2)
|
||||
|
||||
cfg = type("C", (), {"dot_roi": type("R", (), {"x": 0, "y": 0, "w": 10, "h": 10})()})()
|
||||
frame = type("F", (), {"copy": lambda self: self})() # dummy with .copy()
|
||||
audit = FakeAudit()
|
||||
|
||||
result = main_mod._save_annotated_frame(frame, cfg, tmp_path, "test_label", 123.0, audit=audit)
|
||||
|
||||
assert result is None
|
||||
assert any(e.get("event") == "snapshot_fail" and e.get("label") == "test_label"
|
||||
for e in audit.events)
|
||||
|
||||
|
||||
def test_save_annotated_frame_succeeds(tmp_path, monkeypatch):
|
||||
"""Happy path: cv2 present + imwrite succeeds → returns path."""
|
||||
import atm.main as main_mod
|
||||
|
||||
written: list[str] = []
|
||||
|
||||
class _FakeCv2:
|
||||
@staticmethod
|
||||
def rectangle(*a, **kw): pass
|
||||
@staticmethod
|
||||
def imwrite(path, _img):
|
||||
written.append(path)
|
||||
|
||||
import sys
|
||||
monkeypatch.setitem(sys.modules, "cv2", _FakeCv2)
|
||||
|
||||
cfg = type("C", (), {"dot_roi": type("R", (), {"x": 0, "y": 0, "w": 10, "h": 10})()})()
|
||||
frame = type("F", (), {"copy": lambda self: self})()
|
||||
audit = FakeAudit()
|
||||
|
||||
result = main_mod._save_annotated_frame(frame, cfg, tmp_path, "BUY", 1700000000.0, audit=audit)
|
||||
|
||||
assert result is not None
|
||||
assert result.parent == tmp_path
|
||||
assert "BUY" in result.name
|
||||
assert len(written) == 1
|
||||
assert not any(e.get("event") == "snapshot_fail" for e in audit.events)
|
||||
|
||||
@@ -156,19 +156,43 @@ def test_stop_drains(tmp_path: Path) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _MockResponse:
|
||||
def __init__(self, status_code: int, text: str = "") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
text: str = "",
|
||||
json_body: dict | None = None,
|
||||
raise_on_json: bool = False,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self._json_body = json_body if json_body is not None else {"ok": True, "result": {}}
|
||||
self._raise_on_json = raise_on_json
|
||||
|
||||
def json(self):
|
||||
if self._raise_on_json:
|
||||
raise ValueError("no JSON body")
|
||||
return self._json_body
|
||||
|
||||
|
||||
class _MockSession:
|
||||
def __init__(self, status_code: int = 204) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int = 204,
|
||||
json_body: dict | None = None,
|
||||
raise_on_json: bool = False,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self._json_body = json_body
|
||||
self._raise_on_json = raise_on_json
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def post(self, url: str, **kwargs):
|
||||
self.calls.append({"url": url, **kwargs})
|
||||
return _MockResponse(self.status_code)
|
||||
return _MockResponse(
|
||||
self.status_code,
|
||||
json_body=self._json_body,
|
||||
raise_on_json=self._raise_on_json,
|
||||
)
|
||||
|
||||
|
||||
def test_discord_send_ok() -> None:
|
||||
@@ -219,3 +243,118 @@ def test_telegram_5xx_raises() -> None:
|
||||
n = TelegramNotifier("token", "chat123", session=_MockSession(500))
|
||||
with pytest.raises(RuntimeError, match="500"):
|
||||
n.send(_alert("x"))
|
||||
|
||||
|
||||
# Telegram returns 200 OK with {"ok": false, ...} for logical failures (bot
|
||||
# blocked, invalid chat_id, parse_mode errors). Previously silent — now raises
|
||||
# so FanoutNotifier retries + DLQs + stats count the failure.
|
||||
|
||||
def test_telegram_ok_true_passes() -> None:
|
||||
"""200 + {ok:true} → success, no raise."""
|
||||
from atm.notifier.telegram import TelegramNotifier
|
||||
session = _MockSession(200, json_body={"ok": True, "result": {"message_id": 42}})
|
||||
n = TelegramNotifier("token", "chat123", session=session)
|
||||
n.send(_alert("ok body")) # must not raise
|
||||
assert len(session.calls) == 1
|
||||
|
||||
|
||||
def test_telegram_ok_false_raises() -> None:
|
||||
"""200 + {ok:false, ...} → RuntimeError with code + description."""
|
||||
from atm.notifier.telegram import TelegramNotifier
|
||||
session = _MockSession(
|
||||
200,
|
||||
json_body={
|
||||
"ok": False,
|
||||
"error_code": 403,
|
||||
"description": "Forbidden: bot was blocked by the user",
|
||||
},
|
||||
)
|
||||
n = TelegramNotifier("token", "chat123", session=session)
|
||||
with pytest.raises(RuntimeError, match="logical failure.*403.*blocked"):
|
||||
n.send(_alert("x"))
|
||||
|
||||
|
||||
def test_telegram_malformed_json_treated_as_success() -> None:
|
||||
"""200 with non-JSON body → no raise (edge case, shouldn't happen in practice)."""
|
||||
from atm.notifier.telegram import TelegramNotifier
|
||||
session = _MockSession(200, raise_on_json=True)
|
||||
n = TelegramNotifier("token", "chat123", session=session)
|
||||
n.send(_alert("x")) # must not raise
|
||||
|
||||
|
||||
def test_telegram_ok_false_goes_to_dlq(tmp_path: Path) -> None:
|
||||
"""Integration: ok:false → 3 retries → DLQ entry written with description."""
|
||||
from atm.notifier.telegram import TelegramNotifier
|
||||
session = _MockSession(
|
||||
200,
|
||||
json_body={"ok": False, "error_code": 400, "description": "chat not found"},
|
||||
)
|
||||
backend = TelegramNotifier("token", "chat123", session=session)
|
||||
|
||||
dl = tmp_path / "dead.jsonl"
|
||||
fan = FanoutNotifier([backend], dl, max_retries=3, backoff_base=0.01)
|
||||
fan.send(_alert("will-fail"))
|
||||
fan.stop(timeout=5.0)
|
||||
|
||||
# 4 HTTP calls (1 initial + 3 retries)
|
||||
assert len(session.calls) == 4
|
||||
s = fan.stats()
|
||||
assert s["telegram"]["failed"] == 1
|
||||
assert s["telegram"]["retries"] == 3
|
||||
assert s["telegram"]["sent"] == 0
|
||||
|
||||
assert dl.exists()
|
||||
lines = [json.loads(l) for l in dl.read_text().splitlines()]
|
||||
assert len(lines) == 1
|
||||
entry = lines[0]
|
||||
assert entry["backend"] == "telegram"
|
||||
assert entry["alert_title"] == "will-fail"
|
||||
assert "chat not found" in entry["error_str"]
|
||||
assert "400" in entry["error_str"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_drop callback — queue overflow audit trail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_fanout_on_drop_callback_invoked(tmp_path: Path) -> None:
|
||||
"""Queue-overflow drop calls on_drop(backend_name, dropped_alert)."""
|
||||
drops: list[tuple[str, Alert]] = []
|
||||
|
||||
def on_drop(name: str, alert: Alert) -> None:
|
||||
drops.append((name, alert))
|
||||
|
||||
dl = tmp_path / "dead.jsonl"
|
||||
slow = FakeBackend("slow", sleep_s=0.2)
|
||||
fan = FanoutNotifier(
|
||||
[slow], dl, queue_size=2, backoff_base=0.01, on_drop=on_drop,
|
||||
)
|
||||
for i in range(10):
|
||||
fan.send(_alert(f"a{i}"))
|
||||
fan.stop(timeout=10.0)
|
||||
|
||||
assert len(drops) > 0
|
||||
assert all(name == "slow" for name, _ in drops)
|
||||
# Oldest alerts are the ones dropped
|
||||
dropped_titles = {a.title for _, a in drops}
|
||||
assert "a0" in dropped_titles or "a1" in dropped_titles
|
||||
|
||||
|
||||
def test_fanout_on_drop_exception_swallowed(tmp_path: Path) -> None:
|
||||
"""on_drop raising must not break dispatch — audit failure must not silence alerts."""
|
||||
def bad_on_drop(_name: str, _alert: Alert) -> None:
|
||||
raise RuntimeError("audit broken")
|
||||
|
||||
dl = tmp_path / "dead.jsonl"
|
||||
slow = FakeBackend("slow", sleep_s=0.2)
|
||||
fan = FanoutNotifier(
|
||||
[slow], dl, queue_size=2, backoff_base=0.01, on_drop=bad_on_drop,
|
||||
)
|
||||
# Must not raise despite every drop invoking bad_on_drop
|
||||
for i in range(10):
|
||||
fan.send(_alert(f"a{i}"))
|
||||
fan.stop(timeout=10.0)
|
||||
|
||||
s = fan.stats()
|
||||
# Some alerts still went through
|
||||
assert s["slow"]["sent"] > 0 or s["slow"]["dropped"] > 0
|
||||
|
||||
@@ -269,6 +269,58 @@ def test_refresh_arm_ts() -> None:
|
||||
assert t2.arm_ts == 9.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. fired_in_session — public API for catchup suppression after fire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fired_in_session_fresh_fsm() -> None:
|
||||
"""Fresh FSM — neither direction has fired."""
|
||||
sm = StateMachine()
|
||||
assert sm.fired_in_session("BUY") is False
|
||||
assert sm.fired_in_session("SELL") is False
|
||||
|
||||
|
||||
def test_fired_in_session_after_buy_fire() -> None:
|
||||
"""After a BUY fire: BUY=True, SELL=False (direction-scoped)."""
|
||||
sm = StateMachine(lockout_s=240)
|
||||
sm.feed("turquoise", 1.0)
|
||||
sm.feed("dark_green", 2.0)
|
||||
t = sm.feed("light_green", 3.0)
|
||||
assert t.reason == "fire"
|
||||
|
||||
assert sm.fired_in_session("BUY") is True
|
||||
assert sm.fired_in_session("SELL") is False
|
||||
|
||||
|
||||
def test_fired_in_session_after_sell_fire() -> None:
|
||||
"""Mirror — after SELL fire: SELL=True, BUY=False."""
|
||||
sm = StateMachine(lockout_s=240)
|
||||
sm.feed("yellow", 1.0)
|
||||
sm.feed("dark_red", 2.0)
|
||||
t = sm.feed("light_red", 3.0)
|
||||
assert t.reason == "fire"
|
||||
|
||||
assert sm.fired_in_session("SELL") is True
|
||||
assert sm.fired_in_session("BUY") is False
|
||||
|
||||
|
||||
def test_fired_in_session_both_directions() -> None:
|
||||
"""Fire both directions — both True."""
|
||||
sm = StateMachine(lockout_s=240)
|
||||
# BUY cycle
|
||||
sm.feed("turquoise", 1.0)
|
||||
sm.feed("dark_green", 2.0)
|
||||
sm.feed("light_green", 3.0)
|
||||
# SELL cycle
|
||||
sm.feed("yellow", 100.0)
|
||||
sm.feed("dark_red", 101.0)
|
||||
sm.feed("light_red", 102.0)
|
||||
|
||||
assert sm.fired_in_session("BUY") is True
|
||||
assert sm.fired_in_session("SELL") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. exhaustive — parameterize over every (state, color) pair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user