"""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 types import SimpleNamespace 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" assert "recuperare" 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 "recuperare" in notif.alerts[0].title.lower() or "recuperare" in notif.alerts[0].body.lower() assert notif.alerts[1].kind == "prime" assert notif.alerts[1].direction == "BUY" assert "recuperare" in notif.alerts[1].title.lower() or "recuperare" 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 "recuperare" not in notif.alerts[0].title.lower() assert "recuperare" 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 "recuperare" 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 "recuperare" 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) # --------------------------------------------------------------------------- # Commit 3: fire_on_phase_skip backstop # --------------------------------------------------------------------------- def _cfg_with_flag(enabled: bool): return SimpleNamespace(alerts=SimpleNamespace(fire_on_phase_skip=enabled)) def test_phase_skip_fire_when_flag_on(): """ARMED_SELL → light_red directly with flag=True → phase_skip_fire alert.""" fsm = StateMachine(lockout_s=240) notif = FakeNotifier() audit = FakeAudit() # Arm SELL (yellow from IDLE) _handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False, cfg=_cfg_with_flag(True)) assert fsm.state == State.ARMED_SELL notif.alerts.clear() # ARMED_SELL → light_red (skips dark_red) → phase_skip_fire tr = _handle_tick(fsm, "light_red", 2.0, notif, audit, first_accepted=False, cfg=_cfg_with_flag(True)) assert tr is not None and tr.reason == "phase_skip" ps_alerts = [a for a in notif.alerts if a.kind == "phase_skip_fire"] assert len(ps_alerts) == 1 assert ps_alerts[0].direction == "SELL" assert "SELL" in ps_alerts[0].title def test_phase_skip_no_fire_when_flag_off(): """Same scenario, flag=False → no phase_skip_fire emitted.""" fsm = StateMachine(lockout_s=240) notif = FakeNotifier() audit = FakeAudit() _handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False, cfg=_cfg_with_flag(False)) notif.alerts.clear() _handle_tick(fsm, "light_red", 2.0, notif, audit, first_accepted=False, cfg=_cfg_with_flag(False)) ps_alerts = [a for a in notif.alerts if a.kind == "phase_skip_fire"] assert ps_alerts == [] def test_phase_skip_lockout_suppresses_spam(): """Two phase_skip events within lockout_s → only the first emits an alert.""" fsm = StateMachine(lockout_s=240) notif = FakeNotifier() audit = FakeAudit() cfg = _cfg_with_flag(True) # First cycle _handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False, cfg=cfg) _handle_tick(fsm, "light_red", 2.0, notif, audit, first_accepted=False, cfg=cfg) # Second arm + phase_skip well within 240s _handle_tick(fsm, "yellow", 60.0, notif, audit, first_accepted=False, cfg=cfg) _handle_tick(fsm, "light_red", 61.0, notif, audit, first_accepted=False, cfg=cfg) ps_alerts = [a for a in notif.alerts if a.kind == "phase_skip_fire"] assert len(ps_alerts) == 1, ( f"expected 1 phase_skip_fire (lockout), got {len(ps_alerts)}" ) def test_state_machine_is_locked_and_record_fire_public_api(): """Public lockout helpers mirror the private _is_locked / _last_fire behavior.""" fsm = StateMachine(lockout_s=100) assert fsm.is_locked("BUY", 0.0) is False fsm.record_fire("BUY", 10.0) assert fsm.is_locked("BUY", 50.0) is True # within 100s assert fsm.is_locked("BUY", 150.0) is False # past lockout assert fsm.is_locked("SELL", 50.0) is False # other direction unaffected