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:
@@ -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