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:
2026-05-05 17:59:18 +03:00
parent 8a1be979fe
commit c950a5a699
14 changed files with 1952 additions and 255 deletions

View File

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

View File

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

View File

@@ -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] "