feat(telegram): /ss + /resume cu verify multi-bulină și header FSM step
/ss și /resume afișează acum markerii top-3 buline sub ROI (cercuri pline,
r=7, culoarea clasificată) cu tick vertical roșu pe pick-ul FSM (rightmost).
Caption compact: `N/3 STATE` header + `emoji c1/c2/c3: name ← pick`.
FIRE_{BUY|SELL} afișat ca 3/3 când fire_ts e în ultimele 30s.
/resume face capture ÎNAINTE de clearing state → zero race cu FSM tick
simultan. Capture fail → title marchează "⚠️ captură eșuată", resume-ul
rulează oricum.
config: <version> mutat din caption în /status (acolo are sens pentru
verificare de calibrare, nu la fiecare /ss).
Adaugă find_top_dots în vision.py (top-N variantă a find_rightmost_dot,
tie-break determinist pe y). 5 teste sintetice noi + 4 teste noi pentru
dispatcher resume (screenshot inline, capture-fail, order-of-ops,
parity /ss <-> fire path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -865,9 +867,9 @@ async def test_resume_plain_also_clears_canary_drift():
|
||||
# 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())
|
||||
# Message mentions drift-pause was cleared (kind is "screenshot" now since /resume attaches image)
|
||||
alerts = ctx.notifier.alerts
|
||||
assert alerts and ("drift" in (alerts[0].title + alerts[0].body).lower())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -921,12 +923,163 @@ async def test_resume_out_of_window_responds_with_pending_message():
|
||||
_mm.time = real_time
|
||||
|
||||
assert ctx.lifecycle.user_paused is False
|
||||
status = [a for a in ctx.notifier.alerts if a.kind == "status"]
|
||||
assert status
|
||||
combined = (status[0].title + status[0].body).lower()
|
||||
alerts = ctx.notifier.alerts
|
||||
assert alerts
|
||||
combined = (alerts[0].title + alerts[0].body).lower()
|
||||
assert "închis" in combined or "piața" in combined or "ferestr" in combined
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_resume_sends_inline_screenshot(monkeypatch, tmp_path):
|
||||
"""/resume produces a single Alert with image_path + FSM pick caption when capture succeeds."""
|
||||
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
|
||||
ctx.capture = lambda: object() # non-None frame
|
||||
ctx.fires_dir = tmp_path
|
||||
ctx.cfg.window_title = None
|
||||
|
||||
_fake_detections = [{
|
||||
"idx": 0, "name": "light_green", "rgb": (0, 255, 0),
|
||||
"distance": 5.0, "confidence": 0.9, "pos_abs": (100, 200),
|
||||
}]
|
||||
monkeypatch.setattr(_main, "_save_inspect_frame",
|
||||
lambda *a, **kw: (tmp_path / "fake_resume.png", _fake_detections))
|
||||
|
||||
await _main._dispatch_command(ctx, Command(action="resume"))
|
||||
|
||||
# Exactly one alert, with image attached + caption in body.
|
||||
alerts = ctx.notifier.alerts
|
||||
assert len(alerts) == 1
|
||||
alert = alerts[0]
|
||||
assert alert.image_path == tmp_path / "fake_resume.png"
|
||||
assert "Monitorizare reluată" in alert.title
|
||||
assert "← pick" in alert.body
|
||||
assert "captură eșuată" not in alert.title
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_resume_capture_failed_still_resumes(monkeypatch, tmp_path):
|
||||
"""/resume with capture=None → Alert title contains capture-failed, no image, resume still executes."""
|
||||
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
|
||||
ctx.capture = lambda: None # capture fails
|
||||
ctx.fires_dir = tmp_path
|
||||
ctx.cfg.window_title = None
|
||||
|
||||
await _main._dispatch_command(ctx, Command(action="resume"))
|
||||
|
||||
# State still cleared despite capture failure.
|
||||
assert ctx.lifecycle.user_paused is False
|
||||
assert canary.is_paused is False
|
||||
alerts = ctx.notifier.alerts
|
||||
assert len(alerts) == 1
|
||||
assert alerts[0].image_path is None
|
||||
assert "captură eșuată" in alerts[0].title
|
||||
assert "Monitorizare reluată" in alerts[0].title
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_resume_captures_before_state_clear(monkeypatch, tmp_path):
|
||||
"""Capture must run BEFORE clearing user_paused / canary.resume() to avoid race with FSM tick."""
|
||||
import atm.main as _main
|
||||
from atm.commands import Command
|
||||
|
||||
class _Canary:
|
||||
def __init__(self):
|
||||
self._p = True
|
||||
self.resumed_at: float | None = None
|
||||
@property
|
||||
def is_paused(self): return self._p
|
||||
def resume(self):
|
||||
self._p = False
|
||||
self.resumed_at = _capture_sequence[0] if _capture_sequence else 0
|
||||
canary = _Canary()
|
||||
|
||||
ctx = _dispatch_ctx(canary=canary)
|
||||
ctx.lifecycle.user_paused = True
|
||||
ctx.fires_dir = tmp_path
|
||||
ctx.cfg.window_title = None
|
||||
|
||||
_capture_sequence: list[int] = []
|
||||
_capture_called = [0]
|
||||
|
||||
def _capture():
|
||||
_capture_called[0] += 1
|
||||
# State must still be paused at capture time.
|
||||
assert ctx.lifecycle.user_paused is True, "capture ran AFTER user_paused was cleared"
|
||||
assert canary.is_paused is True, "capture ran AFTER canary.resume()"
|
||||
_capture_sequence.append(_capture_called[0])
|
||||
return object()
|
||||
ctx.capture = _capture
|
||||
|
||||
monkeypatch.setattr(_main, "_save_inspect_frame",
|
||||
lambda *a, **kw: (tmp_path / "ok.png", []))
|
||||
|
||||
await _main._dispatch_command(ctx, Command(action="resume"))
|
||||
|
||||
assert _capture_called[0] == 1
|
||||
assert ctx.lifecycle.user_paused is False # cleared after capture
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ss_and_fire_agree_on_rightmost_dot(tmp_path):
|
||||
"""Parity: _save_inspect_frame's detections[0].pos_abs must match find_rightmost_dot
|
||||
output on the same frame + ROI. Prevents silent drift between /ss verify and fire path."""
|
||||
import atm.main as _main
|
||||
from atm.vision import find_rightmost_dot, crop_roi
|
||||
from atm.config import ROI, ColorSpec, YAxisCalib
|
||||
|
||||
# Synthetic frame with one bright green dot.
|
||||
frame = np.zeros((100, 200, 3), dtype=np.uint8)
|
||||
frame[:, :] = (18, 18, 18) # BGR background matching the palette entry below
|
||||
cv2.circle(frame, (150, 50), 5, (0, 255, 0), -1)
|
||||
|
||||
cfg = types.SimpleNamespace(
|
||||
dot_roi=ROI(x=10, y=10, w=180, h=80),
|
||||
colors={
|
||||
"background": ColorSpec(rgb=(18, 18, 18), tolerance=15.0),
|
||||
"light_green": ColorSpec(rgb=(0, 255, 0), tolerance=60.0),
|
||||
},
|
||||
y_axis=YAxisCalib(p1_y=10, p1_price=100.0, p2_y=90, p2_price=50.0),
|
||||
version="parity-test",
|
||||
)
|
||||
|
||||
dot_crop = crop_roi(frame, cfg.dot_roi)
|
||||
fire_pos = find_rightmost_dot(dot_crop, cfg.colors["background"].rgb)
|
||||
assert fire_pos is not None
|
||||
fire_abs = (cfg.dot_roi.x + fire_pos[0], cfg.dot_roi.y + fire_pos[1])
|
||||
|
||||
path, detections = _main._save_inspect_frame(frame, cfg, tmp_path, now=123.0)
|
||||
|
||||
assert path is not None
|
||||
assert detections, "inspect should detect the green dot"
|
||||
inspect_abs = detections[0]["pos_abs"]
|
||||
assert inspect_abs == fire_abs, (
|
||||
f"Parity break: fire={fire_abs} inspect={inspect_abs} — "
|
||||
"fire path and /ss would show different rightmost positions."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_command_reports_pause_reason():
|
||||
"""/status body must mention pause reason + window state."""
|
||||
@@ -993,9 +1146,13 @@ async def test_ss_includes_warn_body_while_canary_paused(monkeypatch, tmp_path):
|
||||
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")
|
||||
# stub _save_inspect_frame to return (path, detections)
|
||||
_fake_detections = [{
|
||||
"idx": 0, "name": "light_green", "rgb": (0, 255, 0),
|
||||
"distance": 5.0, "confidence": 0.9, "pos_abs": (100, 200),
|
||||
}]
|
||||
monkeypatch.setattr(_main, "_save_inspect_frame",
|
||||
lambda *a, **kw: (tmp_path / "fake_ss.png", _fake_detections))
|
||||
|
||||
await _main._dispatch_command(ctx, Command(action="ss"))
|
||||
|
||||
@@ -1003,11 +1160,13 @@ async def test_ss_includes_warn_body_while_canary_paused(monkeypatch, tmp_path):
|
||||
assert screenshots
|
||||
assert "DETECȚIE OPRITĂ" in screenshots[0].body or "drift" in screenshots[0].body.lower()
|
||||
assert "/resume" in screenshots[0].body
|
||||
# Caption with FSM pick must appear alongside the warn.
|
||||
assert "← pick" 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)."""
|
||||
"""/ss body contains caption only when canary is not paused (no warn prefix)."""
|
||||
import atm.main as _main
|
||||
from atm.commands import Command
|
||||
|
||||
@@ -1016,14 +1175,20 @@ async def test_ss_no_warn_when_canary_healthy(monkeypatch, tmp_path):
|
||||
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")
|
||||
_fake_detections = [{
|
||||
"idx": 0, "name": "light_green", "rgb": (0, 255, 0),
|
||||
"distance": 5.0, "confidence": 0.9, "pos_abs": (100, 200),
|
||||
}]
|
||||
monkeypatch.setattr(_main, "_save_inspect_frame",
|
||||
lambda *a, **kw: (tmp_path / "fake_ss.png", _fake_detections))
|
||||
|
||||
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 == ""
|
||||
# Body should contain the caption (no warn), not be empty.
|
||||
assert "← pick" in screenshots[0].body
|
||||
assert "DETECȚIE OPRITĂ" not in screenshots[0].body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
73
tests/test_vision.py
Normal file
73
tests/test_vision.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for vision primitives (synthetic BGR masks, fast, deterministic)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from atm.vision import find_top_dots
|
||||
|
||||
|
||||
BG_RGB = (18, 18, 18) # background in RGB
|
||||
|
||||
|
||||
def _make_frame(h: int = 30, w: int = 100) -> np.ndarray:
|
||||
"""Blank BGR frame filled with BG_RGB."""
|
||||
bgr_bg = (BG_RGB[2], BG_RGB[1], BG_RGB[0])
|
||||
frame = np.zeros((h, w, 3), dtype=np.uint8)
|
||||
frame[:, :] = bgr_bg
|
||||
return frame
|
||||
|
||||
|
||||
def _paint_dot(frame: np.ndarray, cx: int, cy: int, radius: int = 5,
|
||||
bgr: tuple[int, int, int] = (0, 255, 0)) -> None:
|
||||
# radius ≥ 5 keeps blob above min_cluster_px after 2× erosion by 3x3 kernel.
|
||||
cv2.circle(frame, (cx, cy), radius, bgr, -1)
|
||||
|
||||
|
||||
def test_find_top_dots_happy_three_blobs_sorted_desc():
|
||||
frame = _make_frame()
|
||||
_paint_dot(frame, 10, 15)
|
||||
_paint_dot(frame, 30, 15)
|
||||
_paint_dot(frame, 50, 15)
|
||||
result = find_top_dots(frame, BG_RGB, n=3)
|
||||
assert len(result) == 3
|
||||
# Sorted by right edge descending → x=50 first, then 30, then 10.
|
||||
xs = [pt[0] for pt in result]
|
||||
assert xs[0] > xs[1] > xs[2]
|
||||
assert xs[0] >= 48 and xs[2] <= 12 # allow ±2px wobble from centroid
|
||||
|
||||
|
||||
def test_find_top_dots_zero_blobs_returns_empty():
|
||||
frame = _make_frame()
|
||||
assert find_top_dots(frame, BG_RGB, n=3) == []
|
||||
|
||||
|
||||
def test_find_top_dots_one_blob_n3_returns_one():
|
||||
frame = _make_frame()
|
||||
_paint_dot(frame, 25, 15)
|
||||
result = find_top_dots(frame, BG_RGB, n=3)
|
||||
assert len(result) == 1
|
||||
cx, _cy = result[0]
|
||||
assert 23 <= cx <= 27
|
||||
|
||||
|
||||
def test_find_top_dots_fused_wide_blob_anchors_to_right_edge():
|
||||
frame = _make_frame()
|
||||
# Paint a wide stripe (width > 12) — simulates fused anti-aliased dots.
|
||||
cv2.rectangle(frame, (20, 13), (60, 17), (0, 255, 0), -1)
|
||||
result = find_top_dots(frame, BG_RGB, n=1)
|
||||
assert len(result) == 1
|
||||
cx, _cy = result[0]
|
||||
# Anchor should be near right edge (~58 = 60-2), not centroid (~40).
|
||||
assert cx >= 55
|
||||
|
||||
|
||||
def test_find_top_dots_tie_break_by_y_ascending():
|
||||
frame = _make_frame(h=40)
|
||||
# Two dots at same right-edge x=50, different y.
|
||||
_paint_dot(frame, 50, 10) # upper — should come first
|
||||
_paint_dot(frame, 50, 30) # lower
|
||||
result = find_top_dots(frame, BG_RGB, n=2)
|
||||
assert len(result) == 2
|
||||
# Tie-break: smaller y first.
|
||||
assert result[0][1] < result[1][1]
|
||||
Reference in New Issue
Block a user