feat(multi-chart): refactor _run_multi_tick + fix alert spam pe oscilație strip
Bug critic: _strips_match(tol=10) trip pe pulsații naturale de lățime ~18px între ticks (ex. 792↔810px). Fiecare trip → _commit_layout_change → reset FSM + alert Telegram + scheduler stop. Logul 2026-05-04.jsonl arăta 576 evenimente layout_change/zi, plus prime alerts repetate la dark_red/dark_green (FSM resetat înghite lockout-ul) și sincronizare cross-chart pe ambele FSM-uri simultan. Fix: - main.py:1511 — gate doar pe count change (len(new) != len(current)); count stabil → silent update sub_roi indiferent de jitter - main.py:1438 — silent=True pe alert layout_change (Telegram fără sunet) - 2 teste regresie noi: width oscillation 792↔810 + silent assertion - 2 teste async reparate: bootstrap _detect_strips_for_ctx pentru ScriptedDetector (regresie după ce _run_multi_tick a devenit unica cale de detecție) Plus refactor multi-chart pre-existent: layout.py modul nou, _detect_strips_for_ctx, ChartState per-chart FSM/Detector, ROI per-strip pe screenshots, scripts/diag_*. Verificat: 292 passed, 2 skipped în 10s. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -283,6 +283,62 @@ def test_real_screenshot_rightmost_dot(screenshot: str, expected: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_dot_roi_override_uses_sub_roi() -> None:
|
||||
"""dot_roi_override must be used instead of cfg.dot_roi for crop + offset.
|
||||
|
||||
Paint a yellow dot inside the override ROI but **outside** cfg.dot_roi.
|
||||
The default DOT_ROI is (10,10,280,80); we override with an ROI placed
|
||||
well to the right (x=200, w=80) so the painted dot only intersects the
|
||||
override. If the detector still cropped from cfg.dot_roi the yellow dot
|
||||
would land at the rightmost edge of the larger ROI as well — so we use
|
||||
a frame that has nothing in the cfg.dot_roi region except inside the
|
||||
override window, and assert dot_pos_abs falls inside the override.
|
||||
"""
|
||||
override = ROI(x=200, y=20, w=80, h=60)
|
||||
# Background-only frame, then paint yellow only inside the override
|
||||
frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8)
|
||||
fy0, fy1 = override.y, override.y + override.h
|
||||
fx0, fx1 = override.x + 50, override.x + override.w # right edge of override
|
||||
frame[fy0:fy1, fx0:fx1] = YELLOW_BGR
|
||||
|
||||
cfg = _make_cfg(debounce_depth=1)
|
||||
det = Detector(cfg, capture=lambda: frame, dot_roi_override=override)
|
||||
|
||||
r = det.step(0.0)
|
||||
|
||||
assert r.dot_found is True
|
||||
assert r.match is not None
|
||||
assert r.match.name == "yellow"
|
||||
assert r.dot_pos_abs is not None
|
||||
abs_x, abs_y = r.dot_pos_abs
|
||||
assert override.x <= abs_x < override.x + override.w
|
||||
assert override.y <= abs_y < override.y + override.h
|
||||
|
||||
|
||||
def test_dot_pos_abs_with_offset() -> None:
|
||||
"""dot_pos_abs must include the override ROI's (x, y) offset."""
|
||||
override = ROI(x=100, y=20, w=50, h=40)
|
||||
frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8)
|
||||
# Paint a single full-height yellow stripe at roi-local x in [40, 50)
|
||||
# so find_rightmost_dot lands somewhere inside that stripe.
|
||||
fy0, fy1 = override.y, override.y + override.h
|
||||
fx0, fx1 = override.x + 40, override.x + 50
|
||||
frame[fy0:fy1, fx0:fx1] = YELLOW_BGR
|
||||
|
||||
cfg = _make_cfg(debounce_depth=1)
|
||||
det = Detector(cfg, capture=lambda: frame, dot_roi_override=override)
|
||||
|
||||
r = det.step(0.0)
|
||||
|
||||
assert r.dot_found is True
|
||||
assert r.dot_pos_abs is not None
|
||||
abs_x, abs_y = r.dot_pos_abs
|
||||
# Painted stripe: roi-local x in [40,50), y in [0, h). Absolute coords
|
||||
# must be offset by override.(x, y).
|
||||
assert override.x + 40 <= abs_x < override.x + 50
|
||||
assert override.y <= abs_y < override.y + override.h
|
||||
|
||||
|
||||
def test_fused_blob_samples_rightmost_dot() -> None:
|
||||
"""Fused multi-colour stripe must classify the rightmost colour, not the
|
||||
centroid colour. Pre-fix the centroid fell on an interior gray segment
|
||||
|
||||
163
tests/test_layout.py
Normal file
163
tests/test_layout.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from atm.config import ROI
|
||||
from atm.layout import _strips_match, detect_strips
|
||||
|
||||
|
||||
PALETTE = {
|
||||
"turquoise": ((0, 253, 253), 60.0),
|
||||
"yellow": ((253, 253, 0), 60.0),
|
||||
"dark_green": ((0, 128, 0), 60.0),
|
||||
"dark_red": ((128, 0, 0), 60.0),
|
||||
"light_green": ((0, 255, 0), 60.0),
|
||||
"light_red": ((255, 0, 0), 60.0),
|
||||
}
|
||||
|
||||
|
||||
def _blank(h: int = 20, w: int = 200) -> np.ndarray:
|
||||
return np.zeros((h, w, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def _paint(img: np.ndarray, x0: int, x1: int, rgb: tuple[int, int, int]) -> None:
|
||||
"""Paint vivid color into BGR image (palette stores RGB)."""
|
||||
bgr = (rgb[2], rgb[1], rgb[0])
|
||||
img[:, x0:x1] = bgr
|
||||
|
||||
|
||||
def test_single_strip():
|
||||
img = _blank(20, 200)
|
||||
_paint(img, 0, 200, (0, 253, 253)) # turquoise
|
||||
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
|
||||
assert len(out) == 1
|
||||
assert out[0].x == 0
|
||||
assert abs(out[0].w - 200) <= 1
|
||||
assert out[0].h == 20
|
||||
|
||||
|
||||
def test_split_50_50():
|
||||
img = _blank(20, 230)
|
||||
_paint(img, 0, 100, (0, 253, 253))
|
||||
_paint(img, 130, 230, (253, 253, 0))
|
||||
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
|
||||
assert len(out) == 2
|
||||
assert out[0].x < out[1].x # L->R
|
||||
assert out[0].x == 0
|
||||
assert abs(out[0].w - 100) <= 1
|
||||
assert out[1].x == 130
|
||||
assert abs(out[1].w - 100) <= 1
|
||||
|
||||
|
||||
def test_split_asymmetric():
|
||||
img = _blank(20, 230)
|
||||
_paint(img, 0, 70, (0, 253, 253)) # 35% width
|
||||
_paint(img, 100, 230, (253, 253, 0)) # 65% width
|
||||
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
|
||||
assert len(out) == 2
|
||||
assert abs(out[0].w - 70) <= 1
|
||||
assert abs(out[1].w - 130) <= 1
|
||||
|
||||
|
||||
def test_gray_only_no_strip():
|
||||
img = _blank(20, 200)
|
||||
img[:, 50:150] = (128, 128, 128)
|
||||
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_cooldown_gray_dots_no_detect():
|
||||
img = _blank(20, 200)
|
||||
# scattered gray dots
|
||||
for x in (20, 50, 80, 110, 140, 170):
|
||||
img[8:12, x:x + 4] = (100, 100, 100)
|
||||
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_vivid_palette_match():
|
||||
img = _blank(20, 200)
|
||||
_paint(img, 50, 80, (0, 255, 0)) # light_green
|
||||
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
|
||||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_blank_frame():
|
||||
img = _blank(20, 200)
|
||||
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_strip_too_narrow_filtered():
|
||||
img = _blank(20, 200)
|
||||
_paint(img, 50, 53, (0, 253, 253)) # only 3px wide
|
||||
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=10)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_small_gap_fuses():
|
||||
img = _blank(20, 200)
|
||||
_paint(img, 30, 70, (0, 253, 253))
|
||||
_paint(img, 75, 120, (0, 253, 253)) # 5px gap, < min_gap_px=20
|
||||
out = detect_strips(img, PALETTE, min_gap_px=20, min_strip_px=5)
|
||||
assert len(out) == 1
|
||||
assert abs(out[0].x - 30) <= 2
|
||||
assert abs((out[0].x + out[0].w) - 120) <= 2
|
||||
|
||||
|
||||
def test_split_two_charts_with_interleaved_gray():
|
||||
# Regresie 2 ferestre TS: fiecare row de buline e mix vivid + gri, separate
|
||||
# de un gap larg de background (dividerul dintre ferestre). Înainte de fix
|
||||
# detect_strips picka doar runs vivid contiguu și rata fereastra stângă.
|
||||
palette = {**PALETTE, "gray": ((128, 128, 128), 60.0)}
|
||||
img = _blank(35, 1796)
|
||||
# Left chart: dots vivid + gray la fiecare 26px, x=0..820
|
||||
for i, x in enumerate(range(0, 820, 26)):
|
||||
rgb = (128, 128, 128) if i % 3 else (0, 128, 0)
|
||||
_paint(img, x, x + 22, rgb)
|
||||
# Window divider gap: x=820..910 rămâne background
|
||||
# Right chart: same pattern, x=910..1796
|
||||
for i, x in enumerate(range(910, 1790, 26)):
|
||||
rgb = (128, 128, 128) if i % 3 else (0, 128, 0)
|
||||
_paint(img, x, x + 22, rgb)
|
||||
out = detect_strips(img, palette, min_gap_px=28, min_strip_px=280)
|
||||
assert len(out) == 2, f"expected 2 strips, got {len(out)}: {out}"
|
||||
assert out[0].x == 0
|
||||
assert out[1].x >= 900 # right chart starts after divider
|
||||
assert out[0].x + out[0].w < out[1].x # disjoint
|
||||
|
||||
|
||||
def test_three_strips():
|
||||
img = _blank(20, 300)
|
||||
_paint(img, 0, 60, (0, 253, 253))
|
||||
_paint(img, 100, 160, (253, 253, 0))
|
||||
_paint(img, 220, 300, (0, 255, 0))
|
||||
out = detect_strips(img, PALETTE, min_gap_px=10, min_strip_px=5)
|
||||
assert len(out) == 3
|
||||
assert out[0].x < out[1].x < out[2].x
|
||||
assert out[0].x == 0
|
||||
assert out[1].x == 100
|
||||
assert out[2].x == 220
|
||||
|
||||
|
||||
def test_strips_match_identical():
|
||||
a = [ROI(x=0, y=0, w=100, h=20), ROI(x=130, y=0, w=70, h=20)]
|
||||
b = [ROI(x=0, y=0, w=100, h=20), ROI(x=130, y=0, w=70, h=20)]
|
||||
assert _strips_match(a, b) is True
|
||||
|
||||
|
||||
def test_strips_match_jitter_5px():
|
||||
a = [ROI(x=0, y=0, w=100, h=20), ROI(x=130, y=0, w=70, h=20)]
|
||||
b = [ROI(x=5, y=0, w=95, h=20), ROI(x=128, y=0, w=70, h=20)]
|
||||
assert _strips_match(a, b, tol=10) is True
|
||||
|
||||
|
||||
def test_strips_match_drift_12px():
|
||||
a = [ROI(x=0, y=0, w=100, h=20)]
|
||||
b = [ROI(x=12, y=0, w=100, h=20)]
|
||||
assert _strips_match(a, b, tol=10) is False
|
||||
|
||||
|
||||
def test_strips_match_count_different():
|
||||
a = [ROI(x=0, y=0, w=100, h=20)]
|
||||
b = [ROI(x=0, y=0, w=100, h=20), ROI(x=130, y=0, w=70, h=20)]
|
||||
assert _strips_match(a, b) is False
|
||||
@@ -257,6 +257,14 @@ def test_run_live_catchup_sell_from_gray_then_dark_red(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr("atm.commands.TelegramPoller", _StubPoller)
|
||||
monkeypatch.setattr("atm.scheduler.ScreenshotScheduler", _StubScheduler)
|
||||
|
||||
# Bootstrap a single chart so _run_multi_tick populates ctx.charts on tick 1.
|
||||
# Frame is zeros → real detect_strips returns [] → without this, charts stays
|
||||
# empty and the ScriptedDetector loop never advances (regression after the
|
||||
# multi-chart refactor made _run_multi_tick the single detection path).
|
||||
from atm.config import ROI
|
||||
_bootstrap_strip = [ROI(x=0, y=0, w=10, h=10)]
|
||||
monkeypatch.setattr("atm.main._detect_strips_for_ctx", lambda c, f: _bootstrap_strip)
|
||||
|
||||
with pytest.raises(_StopLoop):
|
||||
_main.run_live(cfg, duration_s=None)
|
||||
|
||||
@@ -384,6 +392,11 @@ async def test_lifecycle_idle_armed_primed_autopoll_fire_stop(monkeypatch, tmp_p
|
||||
monkeypatch.setattr("atm.commands.TelegramPoller", FakePoller)
|
||||
monkeypatch.setattr("atm.scheduler.ScreenshotScheduler", lambda *a, **kw: fake_sched)
|
||||
|
||||
# Bootstrap ctx.charts on tick 1 — see test_run_live_catchup_sell for context.
|
||||
from atm.config import ROI
|
||||
_bootstrap_strip = [ROI(x=0, y=0, w=10, h=10)]
|
||||
monkeypatch.setattr("atm.main._detect_strips_for_ctx", lambda c, f: _bootstrap_strip)
|
||||
|
||||
with pytest.raises(_StopLoop):
|
||||
await _main.run_live_async(cfg, duration_s=None)
|
||||
|
||||
@@ -1628,3 +1641,451 @@ async def test_status_window_line_when_oh_enabled():
|
||||
status = [a for a in ctx.notifier.alerts if a.kind == "status"]
|
||||
body = status[0].body
|
||||
assert "fereastră: deschisă" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-chart split workspace tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_chart_state_defaults():
|
||||
"""ChartState's first_accepted defaults to True; last_saved_color to None."""
|
||||
import atm.main as _main
|
||||
from atm.config import ROI
|
||||
|
||||
roi = ROI(x=0, y=0, w=200, h=35)
|
||||
chart = _main.ChartState(
|
||||
chart_id="", sub_roi=roi, detector=MagicMock(), fsm=MagicMock(),
|
||||
)
|
||||
assert chart.first_accepted is True
|
||||
assert chart.last_saved_color is None
|
||||
assert chart.chart_id == ""
|
||||
assert chart.sub_roi is roi
|
||||
|
||||
|
||||
def test_alert_prefix_import():
|
||||
"""_alert_prefix lives in atm.notifier and produces the expected prefixes."""
|
||||
from atm.notifier import _alert_prefix
|
||||
assert _alert_prefix("") == ""
|
||||
assert _alert_prefix("left") == "[stânga] "
|
||||
assert _alert_prefix("right") == "[dreapta] "
|
||||
assert _alert_prefix("chart_0") == "[chart 1] "
|
||||
assert _alert_prefix("chart_2") == "[chart 3] "
|
||||
|
||||
|
||||
def test_save_annotated_frame_no_price_overlay(tmp_path):
|
||||
"""_save_annotated_frame must NOT draw the price overlay anymore.
|
||||
|
||||
Top-right area must contain no white pixels (text was rendered there before).
|
||||
"""
|
||||
import atm.main as _main
|
||||
from atm.config import ROI, YAxisCalib, ColorSpec
|
||||
|
||||
frame = np.zeros((200, 400, 3), dtype=np.uint8)
|
||||
cfg = types.SimpleNamespace(
|
||||
dot_roi=ROI(x=10, y=10, w=380, h=180),
|
||||
y_axis=YAxisCalib(p1_y=10, p1_price=100.0, p2_y=190, p2_price=50.0),
|
||||
colors={"background": ColorSpec(rgb=(0, 0, 0), tolerance=15.0)},
|
||||
)
|
||||
|
||||
fpath = _main._save_annotated_frame(
|
||||
frame, cfg, tmp_path, "test", now=123.0,
|
||||
dot_pos_abs=(200, 100), canary_ok=True,
|
||||
)
|
||||
assert fpath is not None
|
||||
saved = cv2.imread(str(fpath))
|
||||
assert saved is not None
|
||||
# Top-right corner where the "$..." text used to live (rows 0..40, cols 300..390).
|
||||
# The cyan ROI rect is on top edge but only 2px thick → vast majority of that
|
||||
# corner is still pure black. White text would average a much higher value.
|
||||
corner = saved[0:40, 300:390]
|
||||
# No bright white pixels (text was 255,255,255).
|
||||
white_pixels = ((corner[:, :, 0] > 200) & (corner[:, :, 1] > 200) & (corner[:, :, 2] > 200)).sum()
|
||||
assert white_pixels == 0, f"expected 0 white pixels (no price text), got {white_pixels}"
|
||||
|
||||
|
||||
def test_save_annotated_frame_uses_roi_param(tmp_path):
|
||||
"""When roi= is passed, the cyan rect is drawn there (not at cfg.dot_roi)."""
|
||||
import atm.main as _main
|
||||
from atm.config import ROI, ColorSpec
|
||||
|
||||
frame = np.zeros((200, 400, 3), dtype=np.uint8)
|
||||
cfg = types.SimpleNamespace(
|
||||
dot_roi=ROI(x=0, y=0, w=10, h=10), # tiny cfg ROI
|
||||
colors={"background": ColorSpec(rgb=(0, 0, 0), tolerance=15.0)},
|
||||
)
|
||||
custom_roi = ROI(x=100, y=50, w=200, h=80)
|
||||
fpath = _main._save_annotated_frame(
|
||||
frame, cfg, tmp_path, "rtest", now=123.0, roi=custom_roi,
|
||||
)
|
||||
assert fpath is not None
|
||||
saved = cv2.imread(str(fpath))
|
||||
# ROI rect color is (0, 255, 255) BGR. Find any pixel where G+R are saturated and B=0.
|
||||
edge = saved[49:52, 100:300]
|
||||
rect_present = ((edge[:, :, 0] < 50) & (edge[:, :, 1] > 200) & (edge[:, :, 2] > 200)).any()
|
||||
assert rect_present, "Expected ROI rect along custom roi top edge"
|
||||
# Also check the cfg.dot_roi rect (x=0..10, y=0..10) is NOT drawn — proves we used roi=custom.
|
||||
cfg_corner = saved[0:10, 0:10]
|
||||
rect_at_cfg = ((cfg_corner[:, :, 0] < 50) & (cfg_corner[:, :, 1] > 200) & (cfg_corner[:, :, 2] > 200)).any()
|
||||
assert not rect_at_cfg, "cfg.dot_roi rect must not appear when roi= override is used"
|
||||
|
||||
|
||||
def test_commit_layout_change_resets_fsm(monkeypatch):
|
||||
"""_commit_layout_change rebuilds ctx.charts, resets FSM, zeroes n_primed_global."""
|
||||
import atm.main as _main
|
||||
from atm.config import ROI
|
||||
from atm.state_machine import StateMachine, State
|
||||
|
||||
# Prebuild a ctx with one chart in ARMED_BUY.
|
||||
cfg = MagicMock()
|
||||
cfg.lockout_s = 60
|
||||
cfg.colors = {}
|
||||
cfg.debounce_depth = 3
|
||||
fsm_old = StateMachine(lockout_s=60)
|
||||
fsm_old.feed("turquoise", 1.0) # IDLE -> ARMED_BUY
|
||||
assert fsm_old.state == State.ARMED_BUY
|
||||
initial_chart = _main.ChartState(
|
||||
chart_id="", sub_roi=ROI(x=0, y=0, w=400, h=35),
|
||||
detector=MagicMock(), fsm=fsm_old,
|
||||
)
|
||||
|
||||
state = _main._LoopState()
|
||||
state.n_primed_global = 5 # any non-zero value
|
||||
|
||||
notifier_alerts: list = []
|
||||
audit_events: list = []
|
||||
|
||||
class _N:
|
||||
def send(self, a): notifier_alerts.append(a)
|
||||
|
||||
class _A:
|
||||
def log(self, e): audit_events.append(e)
|
||||
|
||||
class _S:
|
||||
is_running = True
|
||||
def stop(self):
|
||||
type(self).is_running = False
|
||||
|
||||
ctx = _main.RunContext(
|
||||
cfg=cfg, capture=lambda: None, canary=MagicMock(),
|
||||
detector=MagicMock(), fsm=fsm_old,
|
||||
notifier=_N(), audit=_A(), detection_log=_A(),
|
||||
scheduler=_S(), samples_dir=Path("."), fires_dir=Path("."),
|
||||
cmd_queue=MagicMock(), state=state,
|
||||
levels_extractor_factory=lambda *a, **kw: None,
|
||||
)
|
||||
ctx.charts = [initial_chart]
|
||||
|
||||
new_strips = [
|
||||
ROI(x=0, y=0, w=200, h=35),
|
||||
ROI(x=250, y=0, w=200, h=35),
|
||||
]
|
||||
_main._commit_layout_change(ctx, new_strips, now=100.0)
|
||||
|
||||
assert len(ctx.charts) == 2
|
||||
assert ctx.charts[0].chart_id == "left"
|
||||
assert ctx.charts[1].chart_id == "right"
|
||||
# New FSMs must be IDLE (fresh StateMachine)
|
||||
assert ctx.charts[0].fsm.state == State.IDLE
|
||||
assert ctx.charts[1].fsm.state == State.IDLE
|
||||
# Old fsm not reused
|
||||
assert ctx.charts[0].fsm is not fsm_old
|
||||
# n_primed_global reset
|
||||
assert state.n_primed_global == 0
|
||||
# layout_change audit + status alert
|
||||
assert any(e.get("event") == "layout_change" and e["new_n"] == 2 for e in audit_events)
|
||||
assert any("Layout TS schimbat" in a.title for a in notifier_alerts)
|
||||
|
||||
|
||||
def test_commit_layout_change_chart_ids_for_n3(monkeypatch):
|
||||
"""For n>=3 charts use 'chart_0', 'chart_1', ..."""
|
||||
import atm.main as _main
|
||||
from atm.config import ROI
|
||||
|
||||
cfg = MagicMock()
|
||||
cfg.lockout_s = 60
|
||||
cfg.colors = {}
|
||||
cfg.debounce_depth = 3
|
||||
state = _main._LoopState()
|
||||
|
||||
class _N:
|
||||
def send(self, a): pass
|
||||
class _A:
|
||||
def log(self, e): pass
|
||||
class _S:
|
||||
is_running = False
|
||||
def stop(self): pass
|
||||
|
||||
ctx = _main.RunContext(
|
||||
cfg=cfg, capture=lambda: None, canary=MagicMock(),
|
||||
detector=MagicMock(), fsm=MagicMock(),
|
||||
notifier=_N(), audit=_A(), detection_log=_A(),
|
||||
scheduler=_S(), samples_dir=Path("."), fires_dir=Path("."),
|
||||
cmd_queue=MagicMock(), state=state,
|
||||
levels_extractor_factory=lambda *a, **kw: None,
|
||||
)
|
||||
ctx.charts = []
|
||||
|
||||
strips = [ROI(x=i * 100, y=0, w=80, h=35) for i in range(3)]
|
||||
_main._commit_layout_change(ctx, strips, now=100.0)
|
||||
assert [c.chart_id for c in ctx.charts] == ["chart_0", "chart_1", "chart_2"]
|
||||
|
||||
|
||||
def test_strips_match_silent_update_jitter():
|
||||
"""_strips_match returns True for <=10px jitter — used to trigger silent update."""
|
||||
from atm.layout import _strips_match
|
||||
from atm.config import ROI
|
||||
a = [ROI(x=0, y=0, w=200, h=35), ROI(x=250, y=0, w=200, h=35)]
|
||||
b = [ROI(x=3, y=0, w=200, h=35), ROI(x=253, y=0, w=200, h=35)]
|
||||
assert _strips_match(a, b, tol=10) is True
|
||||
# Drift 12px breaks the match
|
||||
c = [ROI(x=12, y=0, w=200, h=35), ROI(x=250, y=0, w=200, h=35)]
|
||||
assert _strips_match(a, c, tol=10) is False
|
||||
|
||||
|
||||
def test_build_heartbeat_alert_single_compat():
|
||||
"""Without charts arg, _build_heartbeat_alert returns the legacy single-chart body."""
|
||||
import atm.main as _main
|
||||
a = _main._build_heartbeat_alert(
|
||||
fsm_state="IDLE", fire_count=0, uptime_h=1.0, canary_paused=False,
|
||||
)
|
||||
assert a.kind == "heartbeat"
|
||||
assert a.title == "activ"
|
||||
assert a.body == "IDLE | semnale: 0 | 1.0h"
|
||||
|
||||
|
||||
def test_build_heartbeat_alert_multi_chart():
|
||||
"""charts=[left,right] body lines reference [stânga] / [dreapta]."""
|
||||
import atm.main as _main
|
||||
from atm.config import ROI
|
||||
|
||||
fsm_left = types.SimpleNamespace(state=types.SimpleNamespace(value="ARMED_BUY"))
|
||||
fsm_right = types.SimpleNamespace(state=types.SimpleNamespace(value="IDLE"))
|
||||
cs_left = _main.ChartState(
|
||||
chart_id="left", sub_roi=ROI(x=0, y=0, w=10, h=10),
|
||||
detector=MagicMock(), fsm=fsm_left,
|
||||
)
|
||||
cs_right = _main.ChartState(
|
||||
chart_id="right", sub_roi=ROI(x=0, y=0, w=10, h=10),
|
||||
detector=MagicMock(), fsm=fsm_right,
|
||||
)
|
||||
a = _main._build_heartbeat_alert(
|
||||
fsm_state="ignored", fire_count=2, uptime_h=1.5, canary_paused=False,
|
||||
charts=[cs_left, cs_right],
|
||||
)
|
||||
assert a.kind == "heartbeat"
|
||||
assert "[stânga]" in a.body
|
||||
assert "[dreapta]" in a.body
|
||||
assert "ARMED_BUY" in a.body
|
||||
assert "IDLE" in a.body
|
||||
assert "semnale: 2" in a.body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_multi_tick_silent_jitter_update(monkeypatch, tmp_path):
|
||||
"""When detect_strips returns positions with <=10px jitter, sub_roi updates
|
||||
silently (no _commit_layout_change → no extra status alert)."""
|
||||
import atm.main as _main
|
||||
from atm.config import ROI
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
initial = [ROI(x=0, y=0, w=200, h=35), ROI(x=250, y=0, w=200, h=35)]
|
||||
jittered = [ROI(x=3, y=0, w=200, h=35), ROI(x=253, y=0, w=200, h=35)]
|
||||
|
||||
class _Det:
|
||||
def __init__(self): self.last_roi = None
|
||||
def step(self, ts, frame=None):
|
||||
from atm.detector import DetectionResult
|
||||
return DetectionResult(
|
||||
ts=ts, window_found=True, dot_found=False,
|
||||
rgb=None, match=None, accepted=False, color=None,
|
||||
)
|
||||
def update_dot_roi(self, roi):
|
||||
self.last_roi = roi
|
||||
|
||||
class _FSM:
|
||||
state = types.SimpleNamespace(value="IDLE")
|
||||
_last_fire: dict = {}
|
||||
|
||||
cfg = MagicMock()
|
||||
cfg.lockout_s = 60
|
||||
cfg.attach_screenshots = types.SimpleNamespace(arm=False, prime=False, trigger=False, late_start=False, catchup=False, opposite_rearm=False, rearm=False, phase_skip=False)
|
||||
cfg.window_title = None
|
||||
state_obj = _main._LoopState()
|
||||
|
||||
notifier_alerts: list = []
|
||||
audit_events: list = []
|
||||
|
||||
class _N:
|
||||
def send(self, a): notifier_alerts.append(a)
|
||||
class _A:
|
||||
def log(self, e): audit_events.append(e)
|
||||
class _S:
|
||||
is_running = False
|
||||
def start(self, s): pass
|
||||
def stop(self): pass
|
||||
|
||||
canary = types.SimpleNamespace(is_paused=False, check=lambda f: types.SimpleNamespace(distance=0, drifted=False))
|
||||
|
||||
ctx = _main.RunContext(
|
||||
cfg=cfg, capture=lambda: np.zeros((200, 600, 3), dtype=np.uint8),
|
||||
canary=canary, detector=MagicMock(), fsm=_FSM(),
|
||||
notifier=_N(), audit=_A(), detection_log=_A(),
|
||||
scheduler=_S(), samples_dir=tmp_path, fires_dir=tmp_path,
|
||||
cmd_queue=MagicMock(), state=state_obj,
|
||||
levels_extractor_factory=lambda *a, **kw: None,
|
||||
lifecycle=None,
|
||||
)
|
||||
det_l, det_r = _Det(), _Det()
|
||||
ctx.charts = [
|
||||
_main.ChartState("left", initial[0], det_l, _FSM()),
|
||||
_main.ChartState("right", initial[1], det_r, _FSM()),
|
||||
]
|
||||
|
||||
# Stub strip detection to return jittered strips.
|
||||
monkeypatch.setattr(_main, "_detect_strips_for_ctx", lambda c, f: jittered)
|
||||
|
||||
results = await _main._run_multi_tick(ctx)
|
||||
assert len(results) == 2
|
||||
# Sub-ROI silently updated, but no layout_change event.
|
||||
assert ctx.charts[0].sub_roi == jittered[0]
|
||||
assert ctx.charts[1].sub_roi == jittered[1]
|
||||
assert det_l.last_roi == jittered[0]
|
||||
assert det_r.last_roi == jittered[1]
|
||||
assert not any(e.get("event") == "layout_change" for e in audit_events)
|
||||
# No status "Layout TS schimbat" alert.
|
||||
assert not any("Layout TS schimbat" in a.title for a in notifier_alerts)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_multi_tick_silent_width_oscillation(monkeypatch, tmp_path):
|
||||
"""Regression: width pulsations (e.g. 792↔810px) on a stable chart count
|
||||
must NOT trigger _commit_layout_change. Reproduces the production bug
|
||||
visible in logs/2026-05-04.jsonl (576 layout_change events/day).
|
||||
"""
|
||||
import atm.main as _main
|
||||
from atm.config import ROI
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
initial = [ROI(x=0, y=0, w=792, h=35), ROI(x=912, y=0, w=845, h=35)]
|
||||
pulsed = [ROI(x=0, y=0, w=810, h=35), ROI(x=912, y=0, w=863, h=35)]
|
||||
|
||||
class _Det:
|
||||
def __init__(self): self.last_roi = None
|
||||
def step(self, ts, frame=None):
|
||||
from atm.detector import DetectionResult
|
||||
return DetectionResult(
|
||||
ts=ts, window_found=True, dot_found=False,
|
||||
rgb=None, match=None, accepted=False, color=None,
|
||||
)
|
||||
def update_dot_roi(self, roi):
|
||||
self.last_roi = roi
|
||||
|
||||
class _FSM:
|
||||
state = types.SimpleNamespace(value="PRIMED_BUY")
|
||||
_last_fire: dict = {}
|
||||
|
||||
cfg = MagicMock()
|
||||
cfg.lockout_s = 60
|
||||
cfg.attach_screenshots = types.SimpleNamespace(arm=False, prime=False, trigger=False, late_start=False, catchup=False, opposite_rearm=False, rearm=False, phase_skip=False)
|
||||
cfg.window_title = None
|
||||
state_obj = _main._LoopState()
|
||||
|
||||
notifier_alerts: list = []
|
||||
audit_events: list = []
|
||||
|
||||
class _N:
|
||||
def send(self, a): notifier_alerts.append(a)
|
||||
class _A:
|
||||
def log(self, e): audit_events.append(e)
|
||||
class _S:
|
||||
is_running = False
|
||||
def start(self, s): pass
|
||||
def stop(self): pass
|
||||
|
||||
canary = types.SimpleNamespace(is_paused=False, check=lambda f: types.SimpleNamespace(distance=0, drifted=False))
|
||||
|
||||
fsm_left = _FSM()
|
||||
fsm_right = _FSM()
|
||||
ctx = _main.RunContext(
|
||||
cfg=cfg, capture=lambda: np.zeros((200, 1800, 3), dtype=np.uint8),
|
||||
canary=canary, detector=MagicMock(), fsm=fsm_left,
|
||||
notifier=_N(), audit=_A(), detection_log=_A(),
|
||||
scheduler=_S(), samples_dir=tmp_path, fires_dir=tmp_path,
|
||||
cmd_queue=MagicMock(), state=state_obj,
|
||||
levels_extractor_factory=lambda *a, **kw: None,
|
||||
lifecycle=None,
|
||||
)
|
||||
det_l, det_r = _Det(), _Det()
|
||||
ctx.charts = [
|
||||
_main.ChartState("left", initial[0], det_l, fsm_left),
|
||||
_main.ChartState("right", initial[1], det_r, fsm_right),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(_main, "_detect_strips_for_ctx", lambda c, f: pulsed)
|
||||
|
||||
await _main._run_multi_tick(ctx)
|
||||
|
||||
# Sub-ROI silently updated.
|
||||
assert ctx.charts[0].sub_roi == pulsed[0]
|
||||
assert ctx.charts[1].sub_roi == pulsed[1]
|
||||
assert det_l.last_roi == pulsed[0]
|
||||
assert det_r.last_roi == pulsed[1]
|
||||
# FSM identity preserved (not rebuilt).
|
||||
assert ctx.charts[0].fsm is fsm_left
|
||||
assert ctx.charts[1].fsm is fsm_right
|
||||
# Critical: no layout_change event, no status alert.
|
||||
assert not any(e.get("event") == "layout_change" for e in audit_events)
|
||||
assert not any("Layout TS schimbat" in a.title for a in notifier_alerts)
|
||||
|
||||
|
||||
def test_commit_layout_change_alert_is_silent(monkeypatch):
|
||||
"""Layout-change alert on real count change uses silent=True so Telegram
|
||||
doesn't ping the user. Re-anchor info still visible in chat history.
|
||||
"""
|
||||
import atm.main as _main
|
||||
from atm.config import ROI
|
||||
from atm.state_machine import StateMachine
|
||||
|
||||
cfg = MagicMock()
|
||||
cfg.lockout_s = 60
|
||||
cfg.colors = {}
|
||||
cfg.debounce_depth = 3
|
||||
cfg.confidence_min = 0.5
|
||||
cfg.dot_roi = ROI(x=0, y=0, w=600, h=35)
|
||||
|
||||
notifier_alerts: list = []
|
||||
audit_events: list = []
|
||||
|
||||
class _N:
|
||||
def send(self, a): notifier_alerts.append(a)
|
||||
class _A:
|
||||
def log(self, e): audit_events.append(e)
|
||||
class _S:
|
||||
is_running = False
|
||||
def stop(self): pass
|
||||
|
||||
state_obj = _main._LoopState()
|
||||
ctx = _main.RunContext(
|
||||
cfg=cfg, capture=lambda: None, canary=MagicMock(),
|
||||
detector=MagicMock(), fsm=StateMachine(60),
|
||||
notifier=_N(), audit=_A(), detection_log=_A(),
|
||||
scheduler=_S(), samples_dir=Path("."), fires_dir=Path("."),
|
||||
cmd_queue=MagicMock(), state=state_obj,
|
||||
levels_extractor_factory=lambda *a, **kw: None,
|
||||
lifecycle=None,
|
||||
)
|
||||
ctx.charts = [
|
||||
_main.ChartState("only", ROI(x=0, y=0, w=400, h=35), MagicMock(), StateMachine(60)),
|
||||
]
|
||||
|
||||
new_strips = [
|
||||
ROI(x=0, y=0, w=200, h=35),
|
||||
ROI(x=250, y=0, w=200, h=35),
|
||||
]
|
||||
_main._commit_layout_change(ctx, new_strips, now=100.0)
|
||||
|
||||
layout_alerts = [a for a in notifier_alerts if "Layout TS schimbat" in a.title]
|
||||
assert len(layout_alerts) == 1
|
||||
assert layout_alerts[0].silent is True
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from atm.notifier import Alert
|
||||
from atm.notifier import Alert, _alert_prefix
|
||||
from atm.notifier.fanout import FanoutNotifier
|
||||
|
||||
|
||||
@@ -358,3 +358,32 @@ def test_fanout_on_drop_exception_swallowed(tmp_path: Path) -> None:
|
||||
s = fan.stats()
|
||||
# Some alerts still went through
|
||||
assert s["slow"]["sent"] > 0 or s["slow"]["dropped"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alert.chart_id + _alert_prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_alert_chart_id_default() -> None:
|
||||
assert Alert(kind="arm", title="t", body="b").chart_id == ""
|
||||
|
||||
|
||||
def test_alert_chart_id_set() -> None:
|
||||
assert Alert(kind="arm", title="t", body="b", chart_id="left").chart_id == "left"
|
||||
|
||||
|
||||
def test_alert_prefix_empty() -> None:
|
||||
assert _alert_prefix("") == ""
|
||||
|
||||
|
||||
def test_alert_prefix_left() -> None:
|
||||
assert _alert_prefix("left") == "[stânga] "
|
||||
|
||||
|
||||
def test_alert_prefix_right() -> None:
|
||||
assert _alert_prefix("right") == "[dreapta] "
|
||||
|
||||
|
||||
def test_alert_prefix_chart_n() -> None:
|
||||
assert _alert_prefix("chart_0") == "[chart 1] "
|
||||
assert _alert_prefix("chart_1") == "[chart 2] "
|
||||
|
||||
Reference in New Issue
Block a user