Files
atm/tests/test_layout.py
Marius Mutu c950a5a699 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>
2026-05-05 17:59:18 +03:00

164 lines
5.2 KiB
Python

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