Files
atm/tests/test_handle_tick.py
Claude Agent 840c23f74c 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>
2026-04-16 22:40:17 +00:00

490 lines
18 KiB
Python

"""Unit tests for atm.main._handle_tick.
Covers the six cases from the arm+prime notification plan:
1. fresh BUY arm (turquoise)
2. fresh SELL arm (yellow)
3. BUY prime (turquoise → dark_green)
4. silent refresh (turquoise → turquoise)
5. catchup synth-arm from dark_green + prime alert, audit tagged catchup:true
6. late-start guard (light_green from IDLE with first_accepted=True)
"""
from __future__ import annotations
from atm.main import _handle_tick
from atm.notifier import Alert
from atm.state_machine import State, StateMachine
class FakeNotifier:
"""Captures alerts instead of fanning out. send() is synchronous."""
def __init__(self) -> None:
self.alerts: list[Alert] = []
def send(self, alert: Alert) -> None:
self.alerts.append(alert)
class FakeAudit:
"""Captures audit events in-memory."""
def __init__(self) -> None:
self.events: list[dict] = []
def log(self, event: dict) -> None:
self.events.append(event)
# ---------------------------------------------------------------------------
# 1. fresh BUY arm
# ---------------------------------------------------------------------------
def test_handle_tick_arm_buy():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
assert tr is not None
assert tr.next == State.ARMED_BUY
assert tr.reason == "arm"
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "arm"
assert notif.alerts[0].direction == "BUY"
assert "turquoise" in notif.alerts[0].title
# ---------------------------------------------------------------------------
# 2. fresh SELL arm
# ---------------------------------------------------------------------------
def test_handle_tick_arm_sell():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False)
assert tr is not None
assert tr.next == State.ARMED_SELL
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "arm"
assert notif.alerts[0].direction == "SELL"
assert "yellow" in notif.alerts[0].title
# ---------------------------------------------------------------------------
# 3. BUY prime — turquoise then dark_green
# ---------------------------------------------------------------------------
def test_handle_tick_prime_buy():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
tr2 = _handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False)
assert tr2 is not None
assert tr2.next == State.PRIMED_BUY
assert len(notif.alerts) == 2
assert notif.alerts[0].kind == "arm"
assert notif.alerts[1].kind == "prime"
assert notif.alerts[1].direction == "BUY"
# Non-catchup prime alert must not mention catchup
assert "catchup" not in notif.alerts[1].title.lower()
# ---------------------------------------------------------------------------
# 4. refresh is silent — second turquoise in ARMED_BUY emits no alert
# ---------------------------------------------------------------------------
def test_handle_tick_refresh_no_alert():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
tr2 = _handle_tick(fsm, "turquoise", 2.0, notif, audit, first_accepted=False)
assert tr2 is not None
assert tr2.reason == "refresh"
assert len(notif.alerts) == 1 # only the first (real) arm
# ---------------------------------------------------------------------------
# 5. catchup synth-arm from dark_green with first_accepted=True
# ---------------------------------------------------------------------------
def test_handle_tick_catchup_dark_green():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "dark_green", 1.0, notif, audit, first_accepted=True)
# Synth arm + real prime feed → FSM lands in PRIMED_BUY
assert tr is not None
assert tr.next == State.PRIMED_BUY
assert tr.reason == "prime"
# Two alerts: synth-arm (catchup-tagged) + prime
assert len(notif.alerts) == 2
assert notif.alerts[0].kind == "arm"
assert notif.alerts[0].direction == "BUY"
assert "catchup" in notif.alerts[0].title.lower() or "catchup" in notif.alerts[0].body.lower()
assert notif.alerts[1].kind == "prime"
assert notif.alerts[1].direction == "BUY"
assert "catchup" in notif.alerts[1].title.lower() or "catchup" in notif.alerts[1].body.lower()
# Audit: both tick entries tagged catchup:true
catchup_events = [e for e in audit.events if e.get("catchup")]
assert len(catchup_events) == 2
synth = next(e for e in catchup_events if e.get("synthesized_from") == "dark_green")
assert synth["color"] == "turquoise"
assert synth["reason"] == "arm"
def test_handle_tick_catchup_dark_red():
"""Mirror of dark_green — SELL side."""
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "dark_red", 1.0, notif, audit, first_accepted=True)
assert tr is not None
assert tr.next == State.PRIMED_SELL
assert len(notif.alerts) == 2
assert notif.alerts[0].direction == "SELL"
assert notif.alerts[1].direction == "SELL"
# ---------------------------------------------------------------------------
# 6. late-start: light_green from IDLE with first_accepted=True
# ---------------------------------------------------------------------------
def test_handle_tick_late_start():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "light_green", 1.0, notif, audit, first_accepted=True)
# FSM untouched
assert tr is None
assert fsm.state == State.IDLE
# Exactly one late_start alert
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "late_start"
assert notif.alerts[0].direction == "BUY"
# Audit has a late_start event
assert any(e.get("event") == "late_start" for e in audit.events)
def test_handle_tick_late_start_sell():
"""Mirror of light_green — light_red triggers SELL late_start."""
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "light_red", 1.0, notif, audit, first_accepted=True)
assert tr is None
assert fsm.state == State.IDLE
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "late_start"
assert notif.alerts[0].direction == "SELL"
# ---------------------------------------------------------------------------
# Extra guard: first_accepted=True with turquoise is a normal arm (decision 3A)
# ---------------------------------------------------------------------------
def test_handle_tick_first_turquoise_no_catchup_label():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=True)
assert tr is not None
assert tr.next == State.ARMED_BUY
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "arm"
# Decision 3A: cannot distinguish fresh arm from catchup-at-arm-phase
assert "catchup" not in notif.alerts[0].title.lower()
assert "catchup" not in notif.alerts[0].body.lower()
# ---------------------------------------------------------------------------
# Regression: user bug 2026-04-16. Catchup must fire whenever IDLE + dark_* is
# observed, regardless of first_accepted. Prior gray noise tick consumed
# first_accepted and the subsequent dark_red was gated out of catchup.
# ---------------------------------------------------------------------------
def test_handle_tick_catchup_dark_red_when_not_first_accepted():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "dark_red", 1.0, notif, audit, first_accepted=False)
assert tr is not None
assert tr.next == State.PRIMED_SELL
assert len(notif.alerts) == 2
assert notif.alerts[0].kind == "arm"
assert notif.alerts[0].direction == "SELL"
assert "catchup" in (notif.alerts[0].title + notif.alerts[0].body).lower()
assert notif.alerts[1].kind == "prime"
assert notif.alerts[1].direction == "SELL"
def test_handle_tick_catchup_dark_green_when_not_first_accepted():
"""Mirror — BUY side."""
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "dark_green", 1.0, notif, audit, first_accepted=False)
assert tr is not None
assert tr.next == State.PRIMED_BUY
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)