fix: opposite_rearm/rearm alerts + /resume unified + canary-pause UX guards
Trei găuri observate în sesiunea 2026-04-21: A. _handle_tick nu avea branch pentru reason=opposite_rearm (PRIMED_* ↔ ARMED_opus) sau reason=rearm (PRIMED_* → ARMED_* aceeași direcție). La 17:45 yellow a trecut FSM-ul PRIMED_BUY→ARMED_SELL corect, dar zero alert pe Telegram. Adaugă helper _emit_arm_alert (DRY cu branch-ul arm existent) și două branch-uri noi cu kind=opposite_rearm / kind=rearm. B. Canary drift se curăța doar cu /resume force — user ușor confundă /set_interval cu „relansare" și rămâne în drift-pause (cazul 18:09 azi). /resume acum curăță user_paused + canary.resume() într-o singură comandă. /resume force rămâne alias acceptat (muscle memory legacy). C. Heartbeat-ul afișa „activ ARMED_SELL" deși detecția era oprită de 3 ore (state FSM înghețat). Extract _build_heartbeat_alert care arată „⚠️ pauzat (drift)" + „[drift-pause]" când canary.is_paused. Guard-uri pentru comenzi când canary e paused: - /set_interval: refuzat cu warn „Trimite /resume" - /ss: screenshot trimis + body-ul include „⚠️ DETECȚIE OPRITĂ" 11 teste noi (1 critical regression pentru bug-ul A observat azi), plus actualizarea test-ului /resume existent care aserta vechiul comportament. Total: 235 passed + 8 scenarii regresie. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -567,3 +567,169 @@ def test_state_machine_is_locked_and_record_fire_public_api():
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# opposite_rearm — bug observat 2026-04-21 17:45
|
||||
# PRIMED_BUY + yellow → ARMED_SELL; reason=opposite_rearm; must emit alert
|
||||
# with screenshot attached. CRITICAL regression.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_opposite_rearm_primed_buy_to_armed_sell_emits_alert():
|
||||
"""REGRESSION (2026-04-21): PRIMED_BUY + yellow → ARMED_SELL silently."""
|
||||
from pathlib import Path
|
||||
|
||||
fsm = StateMachine(lockout_s=60)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
|
||||
def snap(kind, label):
|
||||
return Path(f"/tmp/{label}.png")
|
||||
|
||||
# Drive to PRIMED_BUY
|
||||
_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 fsm.state == State.PRIMED_BUY
|
||||
notif.alerts.clear()
|
||||
|
||||
# Yellow → ARMED_SELL via opposite_rearm
|
||||
tr = _handle_tick(fsm, "yellow", 3.0, notif, audit, first_accepted=False, snapshot=snap)
|
||||
|
||||
assert tr is not None
|
||||
assert tr.next == State.ARMED_SELL
|
||||
assert tr.reason == "opposite_rearm"
|
||||
assert len(notif.alerts) == 1
|
||||
a = notif.alerts[0]
|
||||
assert a.kind == "opposite_rearm"
|
||||
assert a.direction == "SELL"
|
||||
assert "yellow" in a.title
|
||||
assert "opus" in a.title.lower()
|
||||
assert a.image_path == Path("/tmp/opposite_rearm_sell.png")
|
||||
|
||||
|
||||
def test_opposite_rearm_primed_sell_to_armed_buy_emits_alert():
|
||||
"""Mirror: PRIMED_SELL + turquoise → ARMED_BUY."""
|
||||
fsm = StateMachine(lockout_s=60)
|
||||
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)
|
||||
assert fsm.state == State.PRIMED_SELL
|
||||
notif.alerts.clear()
|
||||
|
||||
tr = _handle_tick(fsm, "turquoise", 3.0, notif, audit, first_accepted=False)
|
||||
|
||||
assert tr is not None
|
||||
assert tr.next == State.ARMED_BUY
|
||||
assert tr.reason == "opposite_rearm"
|
||||
assert len(notif.alerts) == 1
|
||||
assert notif.alerts[0].kind == "opposite_rearm"
|
||||
assert notif.alerts[0].direction == "BUY"
|
||||
|
||||
|
||||
def test_opposite_rearm_armed_buy_to_armed_sell_emits_alert():
|
||||
"""Flip direct ARMED_BUY → ARMED_SELL (fără prime între)."""
|
||||
fsm = StateMachine(lockout_s=60)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
|
||||
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
|
||||
assert fsm.state == State.ARMED_BUY
|
||||
notif.alerts.clear()
|
||||
|
||||
tr = _handle_tick(fsm, "yellow", 2.0, notif, audit, first_accepted=False)
|
||||
|
||||
assert tr is not None
|
||||
assert tr.next == State.ARMED_SELL
|
||||
assert tr.reason == "opposite_rearm"
|
||||
assert len(notif.alerts) == 1
|
||||
assert notif.alerts[0].kind == "opposite_rearm"
|
||||
assert notif.alerts[0].direction == "SELL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rearm — PRIMED_* + arm-color aceeași direcție → ARMED_* (reset ciclu)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_rearm_primed_buy_to_armed_buy_emits_alert():
|
||||
from pathlib import Path
|
||||
|
||||
fsm = StateMachine(lockout_s=60)
|
||||
notif = FakeNotifier()
|
||||
audit = FakeAudit()
|
||||
|
||||
def snap(kind, label):
|
||||
return 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 fsm.state == State.PRIMED_BUY
|
||||
notif.alerts.clear()
|
||||
|
||||
tr = _handle_tick(fsm, "turquoise", 3.0, notif, audit, first_accepted=False, snapshot=snap)
|
||||
|
||||
assert tr is not None
|
||||
assert tr.next == State.ARMED_BUY
|
||||
assert tr.reason == "rearm"
|
||||
assert len(notif.alerts) == 1
|
||||
a = notif.alerts[0]
|
||||
assert a.kind == "rearm"
|
||||
assert a.direction == "BUY"
|
||||
assert "reluat" in a.title.lower()
|
||||
assert a.image_path == Path("/tmp/rearm_buy.png")
|
||||
|
||||
|
||||
def test_rearm_primed_sell_to_armed_sell_emits_alert():
|
||||
fsm = StateMachine(lockout_s=60)
|
||||
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)
|
||||
assert fsm.state == State.PRIMED_SELL
|
||||
notif.alerts.clear()
|
||||
|
||||
tr = _handle_tick(fsm, "yellow", 3.0, notif, audit, first_accepted=False)
|
||||
|
||||
assert tr is not None
|
||||
assert tr.next == State.ARMED_SELL
|
||||
assert tr.reason == "rearm"
|
||||
assert len(notif.alerts) == 1
|
||||
assert notif.alerts[0].kind == "rearm"
|
||||
assert notif.alerts[0].direction == "SELL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _emit_arm_alert helper — unit test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_emit_arm_alert_helper_builds_expected_alert():
|
||||
from pathlib import Path
|
||||
from atm.main import _emit_arm_alert
|
||||
|
||||
notif = FakeNotifier()
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def snap(kind, label):
|
||||
calls.append((kind, label))
|
||||
return Path(f"/tmp/{label}.png")
|
||||
|
||||
_emit_arm_alert(
|
||||
notif,
|
||||
kind="opposite_rearm",
|
||||
direction="SELL",
|
||||
now=1700000000.0,
|
||||
title="SELL re-armat (yellow) — ciclu opus",
|
||||
snap=snap,
|
||||
snap_kind="opposite_rearm",
|
||||
snap_label="opposite_rearm_sell",
|
||||
)
|
||||
|
||||
assert len(notif.alerts) == 1
|
||||
a = notif.alerts[0]
|
||||
assert a.kind == "opposite_rearm"
|
||||
assert a.direction == "SELL"
|
||||
assert a.title == "SELL re-armat (yellow) — ciclu opus"
|
||||
assert a.image_path == Path("/tmp/opposite_rearm_sell.png")
|
||||
assert calls == [("opposite_rearm", "opposite_rearm_sell")]
|
||||
|
||||
@@ -558,7 +558,7 @@ def _oh_cfg(enabled=True, weekdays=("MON", "TUE", "WED", "THU", "FRI"),
|
||||
stop_hhmm=stop,
|
||||
_tz_cache=_ZI(tz) if enabled else None,
|
||||
)
|
||||
return types.SimpleNamespace(operating_hours=oh)
|
||||
return types.SimpleNamespace(operating_hours=oh, window_title=None)
|
||||
|
||||
|
||||
def _fake_canary(paused=False):
|
||||
@@ -841,8 +841,10 @@ async def test_resume_clears_user_paused_and_canary_when_forced():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_during_drift_keeps_canary_paused_without_force():
|
||||
"""R2 #21: plain /resume during drift clears user_paused but NOT canary."""
|
||||
async def test_resume_plain_also_clears_canary_drift():
|
||||
"""2026-04-21 decision: /resume (no arg) now clears BOTH user_paused and
|
||||
canary drift. /resume force remains accepted as legacy alias. Previous
|
||||
behavior (force required for drift) was a UX trap — see plan doc."""
|
||||
import atm.main as _main
|
||||
from atm.commands import Command
|
||||
|
||||
@@ -859,15 +861,37 @@ async def test_resume_during_drift_keeps_canary_paused_without_force():
|
||||
await _main._dispatch_command(ctx, Command(action="resume")) # no force
|
||||
|
||||
assert ctx.lifecycle.user_paused is False
|
||||
assert canary.is_paused is True # still drift-paused
|
||||
# Message must mention drift
|
||||
assert canary.is_paused is False # drift cleared without force
|
||||
# Audit event still records was_drift + force=False for traceability
|
||||
resumed = [e for e in ctx.audit.events if e.get("event") == "user_resumed"]
|
||||
assert resumed and resumed[0]["was_drift"] is True and resumed[0]["force"] is False
|
||||
# Message mentions drift-pause was cleared
|
||||
status = [a for a in ctx.notifier.alerts if a.kind == "status"]
|
||||
assert status and ("drift" in (status[0].title + status[0].body).lower())
|
||||
|
||||
# Now force
|
||||
ctx.notifier.alerts.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_force_alias_still_works():
|
||||
"""/resume force (value=1) remains accepted — legacy muscle memory."""
|
||||
import atm.main as _main
|
||||
from atm.commands import Command
|
||||
|
||||
class _Canary:
|
||||
def __init__(self): self._p = True
|
||||
@property
|
||||
def is_paused(self): return self._p
|
||||
def resume(self): self._p = False
|
||||
canary = _Canary()
|
||||
|
||||
ctx = _dispatch_ctx(canary=canary)
|
||||
ctx.lifecycle.user_paused = True
|
||||
|
||||
await _main._dispatch_command(ctx, Command(action="resume", value=1))
|
||||
|
||||
assert ctx.lifecycle.user_paused is False
|
||||
assert canary.is_paused is False
|
||||
resumed = [e for e in ctx.audit.events if e.get("event") == "user_resumed"]
|
||||
assert resumed and resumed[0]["force"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -923,6 +947,85 @@ async def test_status_command_reports_pause_reason():
|
||||
assert "pauză manuală" in body or "pauza" in body.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_interval_refused_while_canary_paused():
|
||||
"""2026-04-21: /set_interval must not start scheduler while canary is drift-paused.
|
||||
Previously started scheduler silently, misleading the user into thinking
|
||||
detection was live. Now emits a warn and refuses."""
|
||||
import atm.main as _main
|
||||
from atm.commands import Command
|
||||
|
||||
canary = types.SimpleNamespace(is_paused=True, resume=lambda: None)
|
||||
ctx = _dispatch_ctx(canary=canary)
|
||||
|
||||
await _main._dispatch_command(ctx, Command(action="set_interval", value=60))
|
||||
|
||||
# Scheduler must NOT have started
|
||||
assert ctx.scheduler.is_running is False
|
||||
# No scheduler_started audit event
|
||||
assert not any(e.get("event") == "scheduler_started" for e in ctx.audit.events)
|
||||
# A warn alert must have been sent referencing /resume
|
||||
warns = [a for a in ctx.notifier.alerts if a.kind == "warn"]
|
||||
assert warns
|
||||
combined = (warns[0].title + warns[0].body).lower()
|
||||
assert "drift" in combined and "/resume" in combined
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ss_includes_warn_body_while_canary_paused(monkeypatch, tmp_path):
|
||||
"""2026-04-21: /ss still captures while canary is paused, but the alert body
|
||||
must warn that detection is off."""
|
||||
import atm.main as _main
|
||||
from atm.commands import Command
|
||||
|
||||
canary = types.SimpleNamespace(is_paused=True, resume=lambda: None)
|
||||
ctx = _dispatch_ctx(canary=canary)
|
||||
|
||||
# Bypass window focus + use a simple non-None capture result
|
||||
fake_frame = object()
|
||||
ctx = types.SimpleNamespace(**{**ctx.__dict__}) # shallow copy RunContext fields
|
||||
# Simpler: just override the capture and save functions used
|
||||
async_capture_called = {"n": 0}
|
||||
def _capture():
|
||||
async_capture_called["n"] += 1
|
||||
return fake_frame
|
||||
ctx.capture = _capture
|
||||
ctx.fires_dir = tmp_path
|
||||
# window_title off so we skip focus branch
|
||||
ctx.cfg.window_title = None
|
||||
# stub _save_annotated_frame to return a dummy path
|
||||
monkeypatch.setattr(_main, "_save_annotated_frame",
|
||||
lambda *a, **kw: tmp_path / "fake_ss.png")
|
||||
|
||||
await _main._dispatch_command(ctx, Command(action="ss"))
|
||||
|
||||
screenshots = [a for a in ctx.notifier.alerts if a.kind == "screenshot"]
|
||||
assert screenshots
|
||||
assert "DETECȚIE OPRITĂ" in screenshots[0].body or "drift" in screenshots[0].body.lower()
|
||||
assert "/resume" in screenshots[0].body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ss_no_warn_when_canary_healthy(monkeypatch, tmp_path):
|
||||
"""/ss body must be empty when canary is not paused (no warn noise)."""
|
||||
import atm.main as _main
|
||||
from atm.commands import Command
|
||||
|
||||
canary = types.SimpleNamespace(is_paused=False, resume=lambda: None)
|
||||
ctx = _dispatch_ctx(canary=canary)
|
||||
ctx.capture = lambda: object()
|
||||
ctx.fires_dir = tmp_path
|
||||
ctx.cfg.window_title = None
|
||||
monkeypatch.setattr(_main, "_save_annotated_frame",
|
||||
lambda *a, **kw: tmp_path / "fake_ss.png")
|
||||
|
||||
await _main._dispatch_command(ctx, Command(action="ss"))
|
||||
|
||||
screenshots = [a for a in ctx.notifier.alerts if a.kind == "screenshot"]
|
||||
assert screenshots
|
||||
assert screenshots[0].body == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_with_drift_then_resume_then_fire(monkeypatch, tmp_path):
|
||||
"""E2E #16: drift paused → /resume force → dark_red/light_red produce FIRE alert.
|
||||
@@ -1040,6 +1143,32 @@ def test_heartbeat_suppressed_outside_hours(monkeypatch):
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_build_heartbeat_alert_active_when_not_paused():
|
||||
"""Healthy state → title=activ, body shows fsm.state plainly."""
|
||||
import atm.main as _main
|
||||
a = _main._build_heartbeat_alert(
|
||||
fsm_state="ARMED_BUY", fire_count=2, uptime_h=1.5, canary_paused=False,
|
||||
)
|
||||
assert a.kind == "heartbeat"
|
||||
assert a.title == "activ"
|
||||
assert "ARMED_BUY" in a.body
|
||||
assert "[drift-pause]" not in a.body
|
||||
assert "semnale: 2" in a.body
|
||||
|
||||
|
||||
def test_build_heartbeat_alert_paused_when_canary_drift():
|
||||
"""2026-04-21: heartbeat must reflect canary drift instead of lying with 'activ'."""
|
||||
import atm.main as _main
|
||||
a = _main._build_heartbeat_alert(
|
||||
fsm_state="ARMED_SELL", fire_count=0, uptime_h=3.2, canary_paused=True,
|
||||
)
|
||||
assert a.kind == "heartbeat"
|
||||
assert "pauzat" in a.title
|
||||
assert "drift" in a.title.lower()
|
||||
assert "ARMED_SELL" in a.body
|
||||
assert "[drift-pause]" in a.body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_compact_active():
|
||||
"""/status produces compact 2-line format; 'Canary' absent."""
|
||||
|
||||
Reference in New Issue
Block a user