When the FSM observes ARMED → light_{green,red} directly (the dark
prime was missed), the color classifier likely mis-labeled the dark
phase as gray/background. Missing a fire is worse than a noisy alert,
so the new [options.alerts] fire_on_phase_skip flag (default True)
emits a phase_skip_fire alert on that transition with the standard
240s lockout to dedupe detector bounces.
Adds public StateMachine.is_locked / record_fire so the handler does
not reach into private attrs. _handle_tick now accepts an optional
cfg to read the flag; falls back to True if cfg is absent (tests).
Config gains AlertBehaviorCfg (new alerts field), parsed from
[options.alerts]. Example TOML updated with an explanatory comment.
Tests: test_phase_skip_fire_when_flag_on,
test_phase_skip_no_fire_when_flag_off,
test_phase_skip_lockout_suppresses_spam,
test_state_machine_is_locked_and_record_fire_public_api.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
570 lines
21 KiB
Python
570 lines
21 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 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
|